fix: review findings (async SQS dispatcher, backoff, batch insert, shutdown order, pagination, HMAC) + loadtest fixes; v0.1.6

This commit is contained in:
“Naeel”
2026-08-16 18:24:09 +04:00
parent 090cedca67
commit 3fedf9317c
27 changed files with 581 additions and 122 deletions
+17 -5
View File
@@ -15,6 +15,7 @@ import (
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/store"
@@ -72,12 +73,19 @@ func deviceToResponse(d *store.Device, password string) iotDeviceResponse {
}
// generateMQTTPassword — 32 случайных байта в hex (как старый контроллер).
// crypto/rand ошибку даёт только при сбое системного PRNG — 3 попытки
// (ревью 2026-08-16: не возвращать пустой пароль).
func generateMQTTPassword() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err == nil {
return hex.EncodeToString(buf), nil
} else {
lastErr = err
}
}
return hex.EncodeToString(buf), nil
return "", lastErr
}
// CreateIoTDevice — POST /v1/namespaces/{ns}/iot/devices.
@@ -142,13 +150,17 @@ func (h *Handler) CreateIoTDevice(w http.ResponseWriter, r *http.Request) {
}
// ListIoTDevices — GET /v1/namespaces/{ns}/iot/devices (без паролей).
// Пагинация: ?limit=N&offset=M (0 = без ограничения).
func (h *Handler) ListIoTDevices(w http.ResponseWriter, r *http.Request) {
ns := pathVar(r, "namespace")
if ns == "" {
ns = "default"
}
devices, err := h.Devices.List(r.Context(), ns)
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
devices, err := h.Devices.List(r.Context(), ns, limit, offset)
if err != nil {
h.Log.Error("list devices", "namespace", ns, "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to list devices"))
+7 -1
View File
@@ -25,8 +25,14 @@ func (h *Handler) ListIoTTelemetry(w http.ResponseWriter, r *http.Request) {
limit = n
}
}
offset := 0
if os := r.URL.Query().Get("offset"); os != "" {
if n, err := strconv.Atoi(os); err == nil && n >= 0 {
offset = n
}
}
rows, err := h.IoTPG.QueryTelemetry(r.Context(), ns, deviceID, limit)
rows, err := h.IoTPG.QueryTelemetry(r.Context(), ns, deviceID, limit, offset)
if err != nil {
h.Log.Error("query IoT telemetry", "namespace", ns, "device", deviceID, "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to query telemetry"))
+34 -9
View File
@@ -2,6 +2,8 @@
package middleware
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"log/slog"
"net/http"
@@ -12,11 +14,10 @@ import (
//
// authTestMode (env AUTH_TEST_MODE, дефолт false):
// - true — принимается любая строка без пробелов (для локальных тестов);
// - false — структурная проверка JWT (sub + exp), как в старом коде.
//
// В новой архитектуре подпись JWT не проверяется (как и раньше): внешний
// периметр обеспечивает платформа, полная валидация — на стороне шлюза.
func Auth(authTestMode bool, log *slog.Logger, next http.Handler) http.Handler {
// - false — проверка JWT: sub + exp; если задан hmacSecret — обязательна
// HS256-подпись (HMAC-SHA256, env JWT_HMAC_SECRET). Без секрета —
// структурная проверка (периметр обеспечивает платформа).
func Auth(authTestMode bool, hmacSecret string, log *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Authorization")
if header == "" {
@@ -38,7 +39,7 @@ func Auth(authTestMode bool, log *slog.Logger, next http.Handler) http.Handler {
return
}
if err := validateJWT(token); err != nil {
if err := validateJWT(token, hmacSecret); err != nil {
log.Warn("auth: invalid token", "remote", r.RemoteAddr, "path", r.URL.Path, "reason", err.Error())
http.Error(w, `{"error":"invalid token"}`, http.StatusForbidden)
return
@@ -60,13 +61,37 @@ type jwtError struct{ msg string }
func (e *jwtError) Error() string { return e.msg }
// validateJWT проверяет структуру JWT: три части, корректный payload, sub и exp.
// Подпись НЕ проверяется — см. комментарий пакета.
func validateJWT(token string) error {
// validateJWT проверяет структуру JWT (sub + exp) и, если задан secret,
// HS256-подпись. Без secret подпись не проверяется — см. комментарий Auth.
func validateJWT(token, secret string) error {
jwtParts := strings.Split(token, ".")
if len(jwtParts) != 3 {
return &jwtError{"not a JWT: expected 3 parts"}
}
if secret != "" {
var header struct {
Alg string `json:"alg"`
}
headerBytes, err := base64.RawURLEncoding.DecodeString(jwtParts[0])
if err != nil {
headerBytes, err = base64.StdEncoding.DecodeString(jwtParts[0])
}
if err != nil || jsonUnmarshal(headerBytes, &header) != nil || header.Alg != "HS256" {
return &jwtError{"JWT must be HS256 when JWT_HMAC_SECRET is set"}
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(jwtParts[0] + "." + jwtParts[1]))
expected := mac.Sum(nil)
sig, err := base64.RawURLEncoding.DecodeString(jwtParts[2])
if err != nil {
return &jwtError{"cannot decode JWT signature"}
}
if !hmac.Equal(expected, sig) {
return &jwtError{"JWT signature mismatch"}
}
}
payload := jwtParts[1]
switch len(payload) % 4 {
case 2:
+2 -2
View File
@@ -26,7 +26,7 @@ func corsMiddleware(next http.Handler) http.Handler {
}
// NewRouter собирает все маршруты сервиса.
func NewRouter(h *handler.Handler, log *slog.Logger, authTestMode bool, version string) http.Handler {
func NewRouter(h *handler.Handler, log *slog.Logger, authTestMode bool, jwtHMACSecret string, version string) http.Handler {
r := mux.NewRouter()
// Health — для платформенных проверок контейнера.
@@ -54,7 +54,7 @@ func NewRouter(h *handler.Handler, log *slog.Logger, authTestMode bool, version
v1.HandleFunc("/namespaces/{namespace}/iot/telemetry", h.ListIoTTelemetry).Methods(http.MethodGet)
v1.Use(func(next http.Handler) http.Handler {
return middleware.Auth(authTestMode, log, next)
return middleware.Auth(authTestMode, jwtHMACSecret, log, next)
})
return corsMiddleware(middleware.Logging(log, r))