fix: review findings (async SQS dispatcher, backoff, batch insert, shutdown order, pagination, HMAC) + loadtest fixes; v0.1.6
This commit is contained in:
@@ -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"))
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user