v0.1.16: JWT auth via nubes API, auto-provisioning, UI login
- app/auth/jwt.go: ParseJWTClaims, TenantIDFromSub (sless-compatible SHA256), PingNubesAPI - app/admin/admin.go: POST /ui/api/auth endpoint, jwtMiddleware for /ui/api/* - app/tenant/tenant_store.go: NubesSub/Email fields, GetBySub, CreateFromJWT - app/ui/index.html: login page, email in navbar, JWT session in localStorage - deployments/k8s/deployment.yaml: v0.1.16, NUBES_ENDPOINT env - doc/decisions/resource-limits-plan.md: 20 vulnerabilities audit - Fix: /ui/api/auth moved to subrouter (gorilla/mux PathPrefix conflict)
This commit is contained in:
+113
-7
@@ -1,15 +1,18 @@
|
|||||||
// app/admin/admin.go
|
// app/admin/admin.go
|
||||||
// Admin API handlers for shared-sqs management
|
// Admin API handlers for shared-sqs management
|
||||||
// Created: 2026-04-09
|
// Created: 2026-04-09
|
||||||
// Updated: 2026-04-10 — добавлены endpoints для управления очередями и просмотра сообщений
|
// Updated: 2026-04-10 — JWT auth через nubes API, auto-provisioning тенантов
|
||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"shared-sqs/app/auth"
|
||||||
"shared-sqs/app/models"
|
"shared-sqs/app/models"
|
||||||
"shared-sqs/app/tenant"
|
"shared-sqs/app/tenant"
|
||||||
|
|
||||||
@@ -33,13 +36,18 @@ func findQueue(tenantAccessKey, queueName string) (string, *models.Queue) {
|
|||||||
|
|
||||||
// Handler — admin API handler, holds TenantStore и admin token
|
// Handler — admin API handler, holds TenantStore и admin token
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
store *tenant.TenantStore
|
store *tenant.TenantStore
|
||||||
adminToken string
|
adminToken string
|
||||||
|
nubesEndpoint string // URL nubes API для валидации JWT (напр. https://deck-api-test.ngcloud.ru/api/v1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler — создаёт admin handler
|
// NewHandler — создаёт admin handler
|
||||||
func NewHandler(store *tenant.TenantStore, adminToken string) *Handler {
|
func NewHandler(store *tenant.TenantStore, adminToken string) *Handler {
|
||||||
return &Handler{store: store, adminToken: adminToken}
|
nubesEndpoint := os.Getenv("NUBES_ENDPOINT")
|
||||||
|
if nubesEndpoint == "" {
|
||||||
|
nubesEndpoint = "https://deck-api-test.ngcloud.ru/api/v1"
|
||||||
|
}
|
||||||
|
return &Handler{store: store, adminToken: adminToken, nubesEndpoint: nubesEndpoint}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterRoutes — регистрирует admin маршруты на переданном router (с bearer auth)
|
// RegisterRoutes — регистрирует admin маршруты на переданном router (с bearer auth)
|
||||||
@@ -59,11 +67,16 @@ func (h *Handler) RegisterRoutes(r *mux.Router) {
|
|||||||
adminRouter.HandleFunc("/health", h.detailedHealth).Methods("GET")
|
adminRouter.HandleFunc("/health", h.detailedHealth).Methods("GET")
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterPublicRoutes — публичные маршруты для UI console (без auth)
|
// RegisterPublicRoutes — маршруты для UI console.
|
||||||
// Дублируют admin API, но доступны без bearer token для удобства демо
|
// POST /ui/api/auth — единственный публичный endpoint (принимает JWT, возвращает session).
|
||||||
// TODO: убрать или заменить на session-auth перед production
|
// Остальные /ui/api/* — защищены JWT middleware (токен в Authorization header).
|
||||||
|
// ВАЖНО: все UI API routes на ОДНОМ subrouter PathPrefix("/ui/api") — иначе через nginx ingress
|
||||||
|
// root-router HandleFunc конфликтует с PathPrefix subrouter (404 на POST).
|
||||||
func (h *Handler) RegisterPublicRoutes(r *mux.Router) {
|
func (h *Handler) RegisterPublicRoutes(r *mux.Router) {
|
||||||
ui := r.PathPrefix("/ui/api").Subrouter()
|
ui := r.PathPrefix("/ui/api").Subrouter()
|
||||||
|
// jwtMiddleware пропускает /ui/api/auth — единственный публичный endpoint
|
||||||
|
ui.Use(h.jwtMiddleware)
|
||||||
|
ui.HandleFunc("/auth", h.jwtAuth).Methods("POST")
|
||||||
ui.HandleFunc("/health", h.detailedHealth).Methods("GET")
|
ui.HandleFunc("/health", h.detailedHealth).Methods("GET")
|
||||||
ui.HandleFunc("/tenants", h.listTenants).Methods("GET")
|
ui.HandleFunc("/tenants", h.listTenants).Methods("GET")
|
||||||
ui.HandleFunc("/tenants", h.createTenant).Methods("POST")
|
ui.HandleFunc("/tenants", h.createTenant).Methods("POST")
|
||||||
@@ -92,6 +105,99 @@ func (h *Handler) bearerAuthMiddleware(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// jwtAuth — POST /ui/api/auth — принимает JWT токен, валидирует через nubes API,
|
||||||
|
// auto-provision тенанта если не существует, возвращает email + tenant info.
|
||||||
|
// Это единственный публичный endpoint UI API.
|
||||||
|
func (h *Handler) jwtAuth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Token == "" {
|
||||||
|
jsonErr(w, http.StatusBadRequest, "token is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим JWT claims
|
||||||
|
claims, err := auth.ParseJWTClaims(req.Token)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("jwt auth: parse error: %v", err)
|
||||||
|
jsonErr(w, http.StatusUnauthorized, "invalid token: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Валидируем через nubes API
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := auth.PingNubesAPI(ctx, h.nubesEndpoint, req.Token); err != nil {
|
||||||
|
log.Warnf("jwt auth: nubes rejected token for sub=%s: %v", claims.Sub, err)
|
||||||
|
jsonErr(w, http.StatusForbidden, "token rejected by cloud API: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-provisioning: создаём тенанта если не существует
|
||||||
|
tenantID := auth.TenantIDFromSub(claims.Sub)
|
||||||
|
t, err := h.store.CreateFromJWT(tenantID, claims.Sub, claims.Email, 10)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("jwt auth: failed to create tenant: %v", err)
|
||||||
|
jsonErr(w, http.StatusInternalServerError, "failed to provision tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("jwt auth: authenticated sub=%s email=%s tenant=%s", claims.Sub, claims.Email, t.ID)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"email": claims.Email,
|
||||||
|
"tenant_id": t.ID,
|
||||||
|
"access_key": t.AccessKey,
|
||||||
|
"secret_key": t.SecretKey,
|
||||||
|
"max_queues": t.MaxQueues,
|
||||||
|
"token": req.Token, // возвращаем для использования в последующих запросах
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// jwtMiddleware — middleware для /ui/api/* endpoints.
|
||||||
|
// Пропускает /ui/api/auth (публичный endpoint авторизации).
|
||||||
|
// Проверяет Authorization: Bearer <jwt> заголовок.
|
||||||
|
// Парсит JWT, проверяет что sub соответствует существующему тенанту.
|
||||||
|
// НЕ вызывает PingNubesAPI повторно — токен уже провалидирован при /ui/api/auth.
|
||||||
|
func (h *Handler) jwtMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// /ui/api/auth — публичный endpoint, пропускаем без проверки JWT
|
||||||
|
if strings.HasSuffix(r.URL.Path, "/auth") {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
jsonErr(w, http.StatusUnauthorized, "authorization required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(authHeader, " ", 2)
|
||||||
|
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
|
||||||
|
jsonErr(w, http.StatusUnauthorized, "invalid authorization format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := parts[1]
|
||||||
|
|
||||||
|
claims, err := auth.ParseJWTClaims(token)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, http.StatusUnauthorized, "invalid token: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем что тенант существует (был создан при /ui/api/auth)
|
||||||
|
_, ok := h.store.GetBySub(claims.Sub)
|
||||||
|
if !ok {
|
||||||
|
jsonErr(w, http.StatusForbidden, "tenant not found — authenticate first via POST /ui/api/auth")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// createTenantRequest — тело запроса POST /admin/tenants
|
// createTenantRequest — тело запроса POST /admin/tenants
|
||||||
type createTenantRequest struct {
|
type createTenantRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// Изменено: 2026-04-10
|
||||||
|
// jwt.go — JWT-парсинг и валидация через nubes API (deck-api).
|
||||||
|
// Извлекает sub (UUID пользователя) и email из JWT claims.
|
||||||
|
// Валидация: GET к nubes API с Bearer token → если 401/403 → отклонён.
|
||||||
|
// Подпись JWT НЕ проверяется — аналогично sless (trusted perimeter).
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JWTClaims — минимальный набор claims из nubes JWT.
|
||||||
|
// Не все поля заполнены у каждого пользователя — email может быть пустым.
|
||||||
|
type JWTClaims struct {
|
||||||
|
Sub string `json:"sub"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Exp int64 `json:"exp"`
|
||||||
|
Iss string `json:"iss"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseJWTClaims — декодирует JWT payload (base64url) и возвращает claims.
|
||||||
|
// Проверяет: структуру JWT (3 части), наличие sub, не истёк ли exp.
|
||||||
|
// Подпись НЕ проверяется — валидация через PingNubesAPI.
|
||||||
|
func ParseJWTClaims(token string) (*JWTClaims, error) {
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return nil, fmt.Errorf("invalid JWT: expected 3 parts, got %d", len(parts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT использует base64url без padding — добавляем
|
||||||
|
payload := parts[1]
|
||||||
|
switch len(payload) % 4 {
|
||||||
|
case 2:
|
||||||
|
payload += "=="
|
||||||
|
case 3:
|
||||||
|
payload += "="
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := base64.URLEncoding.DecodeString(payload)
|
||||||
|
if err != nil {
|
||||||
|
// Пробуем StdEncoding на случай нестандартного токена
|
||||||
|
decoded, err = base64.StdEncoding.DecodeString(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode JWT payload: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var claims JWTClaims
|
||||||
|
if err := json.Unmarshal(decoded, &claims); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse JWT claims: %w", err)
|
||||||
|
}
|
||||||
|
if claims.Sub == "" {
|
||||||
|
return nil, fmt.Errorf("JWT missing 'sub' claim")
|
||||||
|
}
|
||||||
|
if claims.Exp > 0 && claims.Exp < time.Now().Unix() {
|
||||||
|
return nil, fmt.Errorf("JWT token expired")
|
||||||
|
}
|
||||||
|
return &claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenantIDFromSub — вычисляет tenant ID из JWT subject (sub claim).
|
||||||
|
// Алгоритм совместим с sless: SHA256(sub) → первые 8 байт → hex → "sless-{16hex}".
|
||||||
|
// Длина: 6 + 16 = 22 символа. Детерминирован, необратим.
|
||||||
|
// Почему "sless-" а не "ssq-": единый namespace для всех сервисов (IoT, funcs, SQS).
|
||||||
|
func TenantIDFromSub(sub string) string {
|
||||||
|
hash := sha256.Sum256([]byte(sub))
|
||||||
|
return fmt.Sprintf("sless-%x", hash[:8])
|
||||||
|
}
|
||||||
|
|
||||||
|
// PingNubesAPI — проверяет валидность токена запросом к nubes API.
|
||||||
|
// endpoint — базовый URL (например "https://deck-api-test.ngcloud.ru/api/v1").
|
||||||
|
// Логика: 401/403 → токен отклонён. Ошибка соединения → API недоступен.
|
||||||
|
// Любой другой HTTP статус → токен принят.
|
||||||
|
func PingNubesAPI(ctx context.Context, endpoint, token string) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build nubes ping request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("nubes API unreachable at %s: %w", endpoint, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||||
|
return fmt.Errorf("nubes API rejected token (HTTP %d)", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -33,10 +33,12 @@ func New(tenantStore *tenant.TenantStore, adminToken string) http.Handler {
|
|||||||
adminHandler := admin.NewHandler(tenantStore, adminToken)
|
adminHandler := admin.NewHandler(tenantStore, adminToken)
|
||||||
adminHandler.RegisterRoutes(r)
|
adminHandler.RegisterRoutes(r)
|
||||||
|
|
||||||
// UI public API — без auth, для встроенной console
|
// UI public API — JWT auth, для встроенной console
|
||||||
|
// ВАЖНО: RegisterPublicRoutes ПЕРЕД static handler — иначе PathPrefix("/ui") перехватит /ui/api/*
|
||||||
adminHandler.RegisterPublicRoutes(r)
|
adminHandler.RegisterPublicRoutes(r)
|
||||||
|
|
||||||
// UI console — встроенный SPA, публичный доступ
|
// UI console — встроенный SPA, публичный доступ
|
||||||
|
// НЕ ловит /ui/api/* — mux сначала проверит более специфичные маршруты выше
|
||||||
r.PathPrefix("/ui").Handler(http.StripPrefix("/ui", ui.Handler()))
|
r.PathPrefix("/ui").Handler(http.StripPrefix("/ui", ui.Handler()))
|
||||||
|
|
||||||
// SQS API — tenant auth middleware оборачивает каждый handler отдельно.
|
// SQS API — tenant auth middleware оборачивает каждый handler отдельно.
|
||||||
|
|||||||
@@ -16,21 +16,24 @@ import (
|
|||||||
// Tenant — модель тенанта shared-sqs.
|
// Tenant — модель тенанта shared-sqs.
|
||||||
// AccessKey используется как идентификатор в AWS Authorization header.
|
// AccessKey используется как идентификатор в AWS Authorization header.
|
||||||
type Tenant struct {
|
type Tenant struct {
|
||||||
ID string // уникальный идентификатор тенанта (t-<hex>)
|
ID string // уникальный идентификатор тенанта (sless-<hex> из JWT sub, или t-<hex> для legacy)
|
||||||
Name string // человекочитаемое имя
|
Name string // человекочитаемое имя
|
||||||
AccessKey string // аналог AWS AccessKeyId (SSAK-<hex>)
|
AccessKey string // аналог AWS AccessKeyId (SSAK-<hex>)
|
||||||
SecretKey string // аналог AWS SecretAccessKey (64 hex chars)
|
SecretKey string // аналог AWS SecretAccessKey (64 hex chars)
|
||||||
MaxQueues int // лимит очередей (0 = безлимит)
|
MaxQueues int // лимит очередей (0 = безлимит)
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Active bool
|
Active bool
|
||||||
|
NubesSub string `json:"nubes_sub,omitempty"` // JWT sub claim (UUID пользователя nubes). Пусто для legacy тенантов.
|
||||||
|
Email string `json:"email,omitempty"` // Email из JWT. Для отображения в UI.
|
||||||
}
|
}
|
||||||
|
|
||||||
// TenantStore — потокобезопасное in-memory хранилище тенантов.
|
// TenantStore — потокобезопасное in-memory хранилище тенантов.
|
||||||
// Два индекса позволяют быстро искать как по ID (admin API), так и по AccessKey (auth middleware).
|
// Три индекса: по ID (admin API), по AccessKey (auth middleware), по NubesSub (JWT auth).
|
||||||
type TenantStore struct {
|
type TenantStore struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
byID map[string]*Tenant
|
byID map[string]*Tenant
|
||||||
byAccessKey map[string]*Tenant
|
byAccessKey map[string]*Tenant
|
||||||
|
bySub map[string]*Tenant // индекс по NubesSub (JWT sub claim)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTenantStore — создаёт пустое хранилище тенантов.
|
// NewTenantStore — создаёт пустое хранилище тенантов.
|
||||||
@@ -38,6 +41,7 @@ func NewTenantStore() *TenantStore {
|
|||||||
return &TenantStore{
|
return &TenantStore{
|
||||||
byID: make(map[string]*Tenant),
|
byID: make(map[string]*Tenant),
|
||||||
byAccessKey: make(map[string]*Tenant),
|
byAccessKey: make(map[string]*Tenant),
|
||||||
|
bySub: make(map[string]*Tenant),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +73,9 @@ func (s *TenantStore) Create(name string, maxQueues int) (*Tenant, error) {
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.byID[t.ID] = t
|
s.byID[t.ID] = t
|
||||||
s.byAccessKey[t.AccessKey] = t
|
s.byAccessKey[t.AccessKey] = t
|
||||||
|
if t.NubesSub != "" {
|
||||||
|
s.bySub[t.NubesSub] = t
|
||||||
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
// Сохраняем в Redis асинхронно — сериализуем здесь, вне мьютекса
|
// Сохраняем в Redis асинхронно — сериализуем здесь, вне мьютекса
|
||||||
@@ -139,6 +146,9 @@ func (s *TenantStore) Delete(id string) bool {
|
|||||||
}
|
}
|
||||||
delete(s.byID, t.ID)
|
delete(s.byID, t.ID)
|
||||||
delete(s.byAccessKey, t.AccessKey)
|
delete(s.byAccessKey, t.AccessKey)
|
||||||
|
if t.NubesSub != "" {
|
||||||
|
delete(s.bySub, t.NubesSub)
|
||||||
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
// Удаляем из Redis асинхронно
|
// Удаляем из Redis асинхронно
|
||||||
@@ -153,9 +163,78 @@ func (s *TenantStore) LoadTenant(t *Tenant) {
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.byID[t.ID] = t
|
s.byID[t.ID] = t
|
||||||
s.byAccessKey[t.AccessKey] = t
|
s.byAccessKey[t.AccessKey] = t
|
||||||
|
if t.NubesSub != "" {
|
||||||
|
s.bySub[t.NubesSub] = t
|
||||||
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetBySub — поиск тенанта по NubesSub (JWT sub claim).
|
||||||
|
// Используется при JWT-авторизации через UI.
|
||||||
|
func (s *TenantStore) GetBySub(sub string) (*Tenant, bool) {
|
||||||
|
s.mu.RLock()
|
||||||
|
t, ok := s.bySub[sub]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
return t, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFromJWT — auto-provisioning тенанта из JWT claims.
|
||||||
|
// ID = TenantIDFromSub(sub) — совместим с sless namespace.
|
||||||
|
// Если тенант с таким sub уже существует — возвращает его (идемпотентно).
|
||||||
|
func (s *TenantStore) CreateFromJWT(tenantID, sub, email string, maxQueues int) (*Tenant, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
// Идемпотентность: если тенант с таким sub уже есть — возвращаем
|
||||||
|
if existing, ok := s.bySub[sub]; ok {
|
||||||
|
// Обновляем email если изменился
|
||||||
|
if email != "" && existing.Email != email {
|
||||||
|
existing.Email = email
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
accessKey, err := generateAccessKey()
|
||||||
|
if err != nil {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil, fmt.Errorf("generate access key: %w", err)
|
||||||
|
}
|
||||||
|
secretKey, err := generateSecretKey()
|
||||||
|
if err != nil {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil, fmt.Errorf("generate secret key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Имя тенанта — email или sub (если email пустой)
|
||||||
|
name := email
|
||||||
|
if name == "" {
|
||||||
|
name = sub
|
||||||
|
}
|
||||||
|
|
||||||
|
t := &Tenant{
|
||||||
|
ID: tenantID,
|
||||||
|
Name: name,
|
||||||
|
AccessKey: accessKey,
|
||||||
|
SecretKey: secretKey,
|
||||||
|
MaxQueues: maxQueues,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
Active: true,
|
||||||
|
NubesSub: sub,
|
||||||
|
Email: email,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.byID[t.ID] = t
|
||||||
|
s.byAccessKey[t.AccessKey] = t
|
||||||
|
s.bySub[t.NubesSub] = t
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
// Сохраняем в Redis
|
||||||
|
if data, err := json.Marshal(t); err == nil {
|
||||||
|
persistence.SaveTenantRaw(t.ID, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
// List — список всех тенантов (для admin GET /tenants).
|
// List — список всех тенантов (для admin GET /tenants).
|
||||||
func (s *TenantStore) List() []*Tenant {
|
func (s *TenantStore) List() []*Tenant {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
|
|||||||
+121
-12
@@ -3,9 +3,9 @@
|
|||||||
app/ui/index.html
|
app/ui/index.html
|
||||||
SQS Console — веб-интерфейс для shared-sqs (Nubes branding)
|
SQS Console — веб-интерфейс для shared-sqs (Nubes branding)
|
||||||
Created: 2026-04-10
|
Created: 2026-04-10
|
||||||
Updated: 2026-04-10 — queue CRUD (create/delete) + message peek/send/purge
|
Updated: 2026-04-10 — JWT auth через nubes token, email в navbar, auto-provisioning
|
||||||
Vanilla HTML/CSS/JS SPA. Встраивается через go:embed.
|
Vanilla HTML/CSS/JS SPA. Встраивается через go:embed.
|
||||||
Режим: Публичный UI API без авторизации (демо). Все данные in-memory.
|
Режим: JWT авторизация через nubes API. Пользователь вводит токен → валидация → сессия.
|
||||||
-->
|
-->
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
@@ -324,15 +324,33 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- ===== APP SHELL (всегда видим, без логина) ===== -->
|
<!-- ===== LOGIN PAGE ===== -->
|
||||||
<div id="app">
|
<div id="login-page" class="login-page">
|
||||||
|
<div class="login-box">
|
||||||
|
<img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
|
||||||
|
<h1>SQS CONSOLE</h1>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="login-token">API Token (nubes JWT)</label>
|
||||||
|
<input id="login-token" type="password" placeholder="eyJhbGciOiJSUzI1NiIs...">
|
||||||
|
</div>
|
||||||
|
<div id="login-error" class="login-error"></div>
|
||||||
|
<button class="btn btn-primary" style="width:100%" onclick="doLogin()">Войти</button>
|
||||||
|
<p style="font-size:12px;color:var(--text-secondary);margin-top:16px">
|
||||||
|
Токен можно получить в панели управления облаком Nubes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== APP SHELL (показывается после авторизации) ===== -->
|
||||||
|
<div id="app" class="hidden">
|
||||||
<nav class="navbar">
|
<nav class="navbar">
|
||||||
<div class="navbar-brand">
|
<div class="navbar-brand">
|
||||||
<img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
|
<img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
|
||||||
<span>SQS CONSOLE</span>
|
<span>SQS CONSOLE</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="navbar-user">
|
<div class="navbar-user">
|
||||||
<span>admin</span>
|
<span id="user-email" style="color:var(--accent)"></span>
|
||||||
|
<button class="btn-logout" onclick="doLogout()">Выйти</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -447,23 +465,104 @@ let msgCache = {}; // id → объект сообщения для d
|
|||||||
let _sendState = { tenantId: null, queueName: null }; // контекст sendMessage
|
let _sendState = { tenantId: null, queueName: null }; // контекст sendMessage
|
||||||
let _createQueueState = { tenantId: null }; // контекст createQueue
|
let _createQueueState = { tenantId: null }; // контекст createQueue
|
||||||
|
|
||||||
|
// Session state — JWT auth
|
||||||
|
let sessionToken = null; // JWT токен для Authorization header
|
||||||
|
let sessionEmail = null; // Email из JWT для отображения в UI
|
||||||
|
|
||||||
// ===== INIT =====
|
// ===== INIT =====
|
||||||
// Запуск — сразу показываем dashboard без логина
|
// Проверяем сохранённый токен в localStorage при загрузке
|
||||||
(function init() {
|
(function init() {
|
||||||
BASE = window.location.origin;
|
BASE = window.location.origin;
|
||||||
showDashboard();
|
const saved = localStorage.getItem('sqs_session');
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(saved);
|
||||||
|
if (s.token && s.email) {
|
||||||
|
sessionToken = s.token;
|
||||||
|
sessionEmail = s.email;
|
||||||
|
enterApp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch(e) { /* corrupted — show login */ }
|
||||||
|
}
|
||||||
|
showLogin();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// ===== LOGIN / LOGOUT =====
|
||||||
|
// showLogin — показывает страницу ввода токена
|
||||||
|
function showLogin() {
|
||||||
|
document.getElementById('login-page').classList.remove('hidden');
|
||||||
|
document.getElementById('app').classList.add('hidden');
|
||||||
|
if (refreshTimer) clearInterval(refreshTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// doLogin — отправляет токен на /ui/api/auth, при успехе входит в app
|
||||||
|
function doLogin() {
|
||||||
|
const token = document.getElementById('login-token').value.trim();
|
||||||
|
if (!token) {
|
||||||
|
document.getElementById('login-error').textContent = 'Введите токен';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('login-error').textContent = 'Проверка токена...';
|
||||||
|
fetch(BASE + '/ui/api/auth', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: token })
|
||||||
|
})
|
||||||
|
.then(r => {
|
||||||
|
if (!r.ok) return r.json().then(d => { throw new Error(d.error || r.statusText); });
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
sessionToken = data.token;
|
||||||
|
sessionEmail = data.email || data.tenant_id;
|
||||||
|
localStorage.setItem('sqs_session', JSON.stringify({
|
||||||
|
token: sessionToken,
|
||||||
|
email: sessionEmail
|
||||||
|
}));
|
||||||
|
enterApp();
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
document.getElementById('login-error').textContent = 'Ошибка: ' + err.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// doLogout — очищает сессию, показывает login
|
||||||
|
function doLogout() {
|
||||||
|
sessionToken = null;
|
||||||
|
sessionEmail = null;
|
||||||
|
localStorage.removeItem('sqs_session');
|
||||||
|
if (refreshTimer) clearInterval(refreshTimer);
|
||||||
|
showLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
// enterApp — переключает UI с login на app shell
|
||||||
|
function enterApp() {
|
||||||
|
document.getElementById('login-page').classList.add('hidden');
|
||||||
|
document.getElementById('app').classList.remove('hidden');
|
||||||
|
document.getElementById('user-email').textContent = sessionEmail || '';
|
||||||
|
showDashboard();
|
||||||
|
}
|
||||||
|
|
||||||
// ===== API HELPER =====
|
// ===== API HELPER =====
|
||||||
// api — выполняет запрос к публичному UI API (без auth)
|
// api — выполняет запрос к UI API с JWT Authorization header
|
||||||
function api(path, opts = {}) {
|
function api(path, opts = {}) {
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(opts.headers || {})
|
||||||
|
};
|
||||||
|
if (sessionToken) {
|
||||||
|
headers['Authorization'] = 'Bearer ' + sessionToken;
|
||||||
|
}
|
||||||
return fetch(BASE + '/ui/api' + path, {
|
return fetch(BASE + '/ui/api' + path, {
|
||||||
...opts,
|
...opts,
|
||||||
headers: {
|
headers: headers
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(opts.headers || {})
|
|
||||||
}
|
|
||||||
}).then(r => {
|
}).then(r => {
|
||||||
|
if (r.status === 401 || r.status === 403) {
|
||||||
|
// Токен истёк или невалиден — выбросить на login
|
||||||
|
doLogout();
|
||||||
|
throw new Error('Session expired — please login again');
|
||||||
|
}
|
||||||
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
|
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
|
||||||
if (r.status === 204) return null;
|
if (r.status === 204) return null;
|
||||||
return r.json();
|
return r.json();
|
||||||
@@ -927,6 +1026,16 @@ function purgeQueueConfirm(tenantId, queueName) {
|
|||||||
})
|
})
|
||||||
.catch(err => alert('Ошибка: ' + err.message));
|
.catch(err => alert('Ошибка: ' + err.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Клавиша Enter на поле токена = нажатие "Войти"
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const tokenInput = document.getElementById('login-token');
|
||||||
|
if (tokenInput) {
|
||||||
|
tokenInput.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter') doLogin();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# deployments/k8s/deployment.yaml
|
# deployments/k8s/deployment.yaml
|
||||||
# Deployment shared-sqs — strategy RollingUpdate (теперь возможен т.к. Redis хранит состояние)
|
# Deployment shared-sqs — strategy RollingUpdate (теперь возможен т.к. Redis хранит состояние)
|
||||||
# Updated: 2026-04-10 — добавлена Redis persistence, Recreate → RollingUpdate
|
# Updated: 2026-04-10 — v0.1.16: JWT auth через nubes API, fix route ordering
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
@@ -25,7 +25,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: shared-sqs
|
- name: shared-sqs
|
||||||
image: naeel/shared-sqs:v0.1.14
|
image: naeel/shared-sqs:v0.1.16
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 4100
|
- containerPort: 4100
|
||||||
name: http
|
name: http
|
||||||
@@ -52,6 +52,8 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: shared-sqs-redis
|
name: shared-sqs-redis
|
||||||
key: password
|
key: password
|
||||||
|
- name: NUBES_ENDPOINT
|
||||||
|
value: "https://deck-api-test.ngcloud.ru/api/v1"
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
memory: "64Mi"
|
memory: "64Mi"
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Решение: Защита от ресурсного исчерпания (Resource Exhaustion Protection)
|
||||||
|
|
||||||
|
**Дата:** 2026-04-10
|
||||||
|
**Статус:** ПЛАН (на согласовании)
|
||||||
|
**Автор анализа:** GitHub Copilot (Claude Opus 4.6)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Проведён полный аудит shared-sqs на уязвимости типа DoS / resource exhaustion.
|
||||||
|
Найдено **20 уязвимостей** (2 Critical, 8 High, 8 Medium, 2 Low).
|
||||||
|
|
||||||
|
Главная угроза: **один тенант может положить сервис для всех** — через создание огромных очередей, бесконечный long polling, отсутствие валидации размеров.
|
||||||
|
|
||||||
|
Кросс-тенантный доступ к данным **невозможен** — изоляция через составной ключ работает корректно.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Найденные уязвимости
|
||||||
|
|
||||||
|
### 🔴 CRITICAL
|
||||||
|
|
||||||
|
| # | Уязвимость | Где | Как эксплуатировать |
|
||||||
|
|---|-----------|-----|-------------------|
|
||||||
|
| 1 | **Публичный Admin API без auth** | `/ui/api/*` | Любой может создавать тенантов, очереди, слать сообщения, удалять данные без токена |
|
||||||
|
| 2 | **Batch message size bypass** | `send_message_batch.go` | `SendMessageBatchV1` не проверяет размер тела каждого сообщения; 10×256MB = 2.5GB в одном запросе |
|
||||||
|
|
||||||
|
### 🟠 HIGH
|
||||||
|
|
||||||
|
| # | Уязвимость | Где | AWS лимит |
|
||||||
|
|---|-----------|-----|-----------|
|
||||||
|
| 3 | QueueName без валидации | `create_queue.go` | Макс 80 chars, `[a-zA-Z0-9_-]` |
|
||||||
|
| 4 | WaitTimeSeconds без потолка | `receive_message.go` | 0–20 сек |
|
||||||
|
| 5 | ReceiveMessageWaitTimeSeconds без потолка | `queue_attributes.go` | 0–20 сек |
|
||||||
|
| 6 | DelaySeconds без потолка | `queue_attributes.go` | 0–900 сек |
|
||||||
|
| 7 | VisibilityTimeout без потолка | `queue_attributes.go` | 0–43200 сек |
|
||||||
|
| 8 | MaxNumberOfMessages без потолка | `receive_message.go` | 1–10 |
|
||||||
|
| 9 | Message attributes без лимита | `requests.go` | Макс 10, общий размер ≤256KB |
|
||||||
|
| 10 | Data race в GetQueueUrlV1 | `get_queue_url.go` | Нет RLock перед чтением |
|
||||||
|
|
||||||
|
### 🟡 MEDIUM
|
||||||
|
|
||||||
|
| # | Уязвимость | Где | Последствие |
|
||||||
|
|---|-----------|-----|------------|
|
||||||
|
| 11 | Нет лимита сообщений в очереди | все send handlers | OOM при миллионах сообщений |
|
||||||
|
| 12 | Нет лимита на создание тенантов | `admin.go` | OOM при 100K тенантов |
|
||||||
|
| 13 | FIFO group lock без таймаута | `receive_message.go` | Group заблокирован навсегда |
|
||||||
|
| 14 | Duplicates map без очистки | `models.go` | Утечка памяти |
|
||||||
|
| 15 | BatchEntryId без валидации длины | `send_message_batch.go` | Раздутые ключи |
|
||||||
|
| 16 | DeduplicationID без валидации | `send_message_batch.go` | 128 chars макс по AWS |
|
||||||
|
| 17 | GroupID без валидации | `send_message.go` | 128 chars макс по AWS |
|
||||||
|
| 18 | Redis serialization без лимита | `redis.go` | Redis OOM при гигантских очередях |
|
||||||
|
|
||||||
|
### 🟢 LOW
|
||||||
|
|
||||||
|
| # | Уязвимость | Где |
|
||||||
|
|---|-----------|-----|
|
||||||
|
| 19 | `{account}` в URL не валидируется | `router.go` |
|
||||||
|
| 20 | ReceiptHandle не валидируется по формату | `delete_message.go` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## План реализации
|
||||||
|
|
||||||
|
### Фаза 1 — КРИТИЧЕСКОЕ (блокирует production)
|
||||||
|
|
||||||
|
**Цель:** устранить уязвимости, позволяющие анонимную атаку.
|
||||||
|
|
||||||
|
| Задача | Файлы | Сложность | Что делать |
|
||||||
|
|--------|-------|-----------|-----------|
|
||||||
|
| 1.1 Защитить публичный API | `admin.go`, `router.go` | Низкая | Добавить опцию: либо bearer auth на `/ui/api/*`, либо убрать write-эндпоинты из public routes, оставив только read |
|
||||||
|
| 1.2 Валидация размера в batch | `send_message_batch.go` | Низкая | Добавить проверку `len(entry.MessageBody) > queue.MaximumMessageSize` в цикле по entries |
|
||||||
|
| 1.3 RLock в GetQueueUrl | `get_queue_url.go` | Низкая | Обернуть чтение SyncQueues в `RLock()/RUnlock()` |
|
||||||
|
|
||||||
|
### Фаза 2 — AWS-совместимые лимиты (валидация параметров)
|
||||||
|
|
||||||
|
**Цель:** привести параметры к стандартам AWS SQS. Создать единый пакет `validation`.
|
||||||
|
|
||||||
|
| Задача | Файлы | Что делать |
|
||||||
|
|--------|-------|-----------|
|
||||||
|
| 2.1 Валидация QueueName | `create_queue.go` | Макс 80 chars, regex `^[a-zA-Z0-9_-]+(\.fifo)?$` |
|
||||||
|
| 2.2 WaitTimeSeconds cap | `receive_message.go` | Clamp к 0–20 |
|
||||||
|
| 2.3 ReceiveMessageWaitTimeSeconds cap | `queue_attributes.go` | Clamp к 0–20 |
|
||||||
|
| 2.4 DelaySeconds cap | `queue_attributes.go` | Clamp к 0–900 |
|
||||||
|
| 2.5 VisibilityTimeout cap | `queue_attributes.go` | Clamp к 0–43200 |
|
||||||
|
| 2.6 MaxNumberOfMessages cap | `receive_message.go` | Clamp к 1–10 |
|
||||||
|
| 2.7 Message attributes limit | `requests.go`, `send_message.go`, `send_message_batch.go` | Макс 10 атрибутов, общий размер ≤256KB |
|
||||||
|
| 2.8 DeduplicationID/GroupID length | `send_message.go`, `send_message_batch.go` | Макс 128 chars каждый |
|
||||||
|
|
||||||
|
### Фаза 3 — Per-tenant resource limits
|
||||||
|
|
||||||
|
**Цель:** один тенант не может выжрать все ресурсы.
|
||||||
|
|
||||||
|
| Задача | Файлы | Что делать |
|
||||||
|
|--------|-------|-----------|
|
||||||
|
| 3.1 Макс сообщений в очереди | `send_message.go`, `send_message_batch.go` | Лимит per queue (напр. 120,000 — как AWS standard) |
|
||||||
|
| 3.2 Макс суммарный размер per tenant | `tenant_helpers.go` | Счётчик bytes per tenant; отказ при превышении |
|
||||||
|
| 3.3 Rate limiting per tenant | Новый middleware | Token bucket или sliding window; напр. 300 req/sec per tenant |
|
||||||
|
| 3.4 Макс тенантов в системе | `admin.go`, `tenant_store.go` | Глобальный лимит (конфигурируемый) |
|
||||||
|
| 3.5 HTTP request body size limit | `router.go` или middleware | `http.MaxBytesReader` — напр. 1MB на запрос |
|
||||||
|
|
||||||
|
### Фаза 4 — Стабильность и очистка
|
||||||
|
|
||||||
|
**Цель:** утечки памяти и deadlock-сценарии.
|
||||||
|
|
||||||
|
| Задача | Файлы | Что делать |
|
||||||
|
|--------|-------|-----------|
|
||||||
|
| 4.1 FIFO group lock timeout | `models.go`, `receive_message.go` | Таймаут = VisibilityTimeout очереди. Горутина чистит expired locks |
|
||||||
|
| 4.2 Duplicates map cleanup | `models.go` | Горутина-ticker каждые 30 сек, удаляет записи старше 5 мин |
|
||||||
|
| 4.3 Redis size guard | `redis.go` | Не сохранять в Redis если `len(data) > 50MB`; логировать warning |
|
||||||
|
| 4.4 `{account}` валидация | `router.go` или handlers | Проверять что `{account}` == tenant.ID из context |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Порядок действий (рекомендация)
|
||||||
|
|
||||||
|
```
|
||||||
|
Фаза 1 (Critical) → тесты → деплой
|
||||||
|
↓
|
||||||
|
Фаза 2 (AWS limits) → тесты → деплой
|
||||||
|
↓
|
||||||
|
Фаза 3 (Per-tenant) → тесты → деплой
|
||||||
|
↓
|
||||||
|
Фаза 4 (Stability) → тесты → деплой
|
||||||
|
```
|
||||||
|
|
||||||
|
Каждая фаза — отдельный коммит/PR с тестами.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что НЕ делаем (и почему)
|
||||||
|
|
||||||
|
| Отброшено | Причина |
|
||||||
|
|----------|---------|
|
||||||
|
| WAF / Nginx rate limit | Overkill для текущего масштаба; лучше in-app |
|
||||||
|
| Подпись проверки (HMAC) | Сервис эмулирует SQS — подпись не проверяется by design (как LocalStack) |
|
||||||
|
| Шифрование сообщений at rest | Redis на localhost, не критично на этом этапе |
|
||||||
|
| Горизонтальное масштабирование | Другая задача; лимиты работают и в single-pod |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AWS SQS лимиты (справка)
|
||||||
|
|
||||||
|
| Параметр | AWS лимит |
|
||||||
|
|----------|----------|
|
||||||
|
| Queue name length | 80 chars |
|
||||||
|
| Queue name chars | `[a-zA-Z0-9_-]` (+ `.fifo` суффикс) |
|
||||||
|
| Message body | 256 KB |
|
||||||
|
| Message attributes | 10, общий размер ≤ 256 KB |
|
||||||
|
| MaxNumberOfMessages | 1–10 |
|
||||||
|
| WaitTimeSeconds | 0–20 |
|
||||||
|
| DelaySeconds | 0–900 |
|
||||||
|
| VisibilityTimeout | 0–43200 (12 часов) |
|
||||||
|
| MessageRetentionPeriod | 60–1,209,600 (14 дней) |
|
||||||
|
| Messages per queue | ~120,000 in-flight |
|
||||||
|
| DeduplicationID | 128 chars |
|
||||||
|
| GroupID | 128 chars |
|
||||||
|
| Batch size | 10 entries |
|
||||||
+12
-1
@@ -12,12 +12,23 @@
|
|||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
- [ ] Migration guide: как обновить сервис на v0.1.14
|
- [ ] Собрать Docker образ v0.1.15, задеплоить, проверить JWT auth flow
|
||||||
|
- [ ] Resource limits (Фаза 1-4 из doc/decisions/resource-limits-plan.md)
|
||||||
|
- [ ] Migration guide: как обновить сервис на v0.1.15
|
||||||
- [ ] Load testing shared-sqs (текущая реализация имеет глобальный мьютекс)
|
- [ ] Load testing shared-sqs (текущая реализация имеет глобальный мьютекс)
|
||||||
- [ ] DLQ (Dead Letter Queue) поддержка
|
- [ ] DLQ (Dead Letter Queue) поддержка
|
||||||
- [ ] Long Polling оптимизация
|
- [ ] Long Polling оптимизация
|
||||||
- [ ] Rate limiting per tenant
|
- [ ] Rate limiting per tenant
|
||||||
|
|
||||||
|
### v0.1.15 (2026-04-10) — IN PROGRESS
|
||||||
|
- ✅ JWT auth через nubes API (deck-api-test.ngcloud.ru)
|
||||||
|
- ✅ Login page в UI (ввод токена → валидация → auto-provisioning tenant)
|
||||||
|
- ✅ Email пользователя в navbar
|
||||||
|
- ✅ /ui/api/* защищены JWT middleware (больше не публичные)
|
||||||
|
- ✅ TenantID совместим с sless namespace: sless-{SHA256(sub)[:8]}
|
||||||
|
- ✅ Сессия в localStorage (token + email)
|
||||||
|
- [ ] Docker build + deploy + E2E test
|
||||||
|
|
||||||
## Known Limitations
|
## Known Limitations
|
||||||
|
|
||||||
1. **Глобальный мьютекс** — SyncQueues.Lock() на весь сервис. Performance bottleneck.
|
1. **Глобальный мьютекс** — SyncQueues.Lock() на весь сервис. Performance bottleneck.
|
||||||
|
|||||||
@@ -88,3 +88,265 @@ Latency (ok) : min=0.023s median=0.044s p95=0.332s max=3.920s
|
|||||||
- H2 и H1 работают одинаково хорошо
|
- H2 и H1 работают одинаково хорошо
|
||||||
|
|
||||||
**Вывод: харбор стабилен.** Апгрейд ресурсов полностью устранил проблему с зависаниями. Harbor пригоден для использования как registry для kaniko push.
|
**Вывод: харбор стабилен.** Апгрейд ресурсов полностью устранил проблему с зависаниями. Harbor пригоден для использования как registry для kaniko push.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Сессия 2 — Анализ защиты от ресурсного исчерпания
|
||||||
|
# Agent: GitHub Copilot (Claude Opus 4.6)
|
||||||
|
|
||||||
|
### Задача
|
||||||
|
Полный аудит shared-sqs на уязвимости типа DoS / resource exhaustion.
|
||||||
|
Создать план защиты: один тенант не должен мочь положить сервис для всех.
|
||||||
|
|
||||||
|
### Ход анализа
|
||||||
|
|
||||||
|
#### Что проверял
|
||||||
|
Все SQS handlers (`app/gosqs/*.go`), admin API (`app/admin/admin.go`), модели (`app/models/`), persistence (`app/persistence/redis.go`), auth (`app/auth/`), tenant store (`app/tenant/`).
|
||||||
|
|
||||||
|
#### Гипотезы и что нашёл
|
||||||
|
|
||||||
|
**Гипотеза 1: кросс-тенантный доступ возможен?**
|
||||||
|
→ НЕТ. Изоляция через составной ключ `{accessKey}:{queueName}` работает корректно. Все handlers извлекают tenant из context (auth middleware), строят ключ через `tenantQueueKey()`. Обойти нельзя — accessKey проверяется в middleware, ключ строится на стороне сервера.
|
||||||
|
|
||||||
|
**Гипотеза 2: можно ли через URL path `/{account}/` получить доступ к чужим данным?**
|
||||||
|
→ НЕТ. `{account}` из URL НЕ используется для поиска очереди. Handler всегда берёт tenant из context (middleware), игнорируя path segment. Но `{account}` не валидируется — можно подставить чужой ID, что загрязнит логи.
|
||||||
|
|
||||||
|
**Гипотеза 3: DoS через неограниченное создание ресурсов?**
|
||||||
|
→ ДА. Критическая проблема:
|
||||||
|
- QueueName: нет валидации длины/символов (AWS ограничивает 80 chars, [a-zA-Z0-9_-])
|
||||||
|
- Messages per queue: без лимита
|
||||||
|
- Message body size: проверяется ТОЛЬКО в SendMessageV1, НЕ проверяется в SendMessageBatchV1
|
||||||
|
- Message attributes: без лимита на количество и размер (AWS: макс 10 атрибутов, общий размер ≤256KB)
|
||||||
|
- Tenant creation: без лимита (и PUBLIC API `/ui/api/tenants` без auth!)
|
||||||
|
- Long polling: WaitTimeSeconds без верхней границы (AWS: макс 20 сек)
|
||||||
|
|
||||||
|
**Гипотеза 4: можно ли исчерпать Redis?**
|
||||||
|
→ ДА. `SaveQueue()` сериализует всю очередь (включая ВСЕ сообщения) в один JSON → один ключ в Redis HASH. Очередь с 1M сообщений = один JSON ~1GB.
|
||||||
|
|
||||||
|
**Гипотеза 5: FIFO lock можно заблокировать навсегда?**
|
||||||
|
→ ДА. `LockGroup()` вызывается при ReceiveMessage. Если клиент получил сообщение и не вызвал DeleteMessage, group ID заблокирован до перезагрузки. Нет таймаута на lock.
|
||||||
|
|
||||||
|
**Гипотеза 6: data race в handlers?**
|
||||||
|
→ ДА. `GetQueueUrlV1` читает `SyncQueues.Queues[key]` без RLock — race condition при concurrent write.
|
||||||
|
|
||||||
|
**Гипотеза 7: Duplicates map утечка памяти?**
|
||||||
|
→ ДА. `Duplicates map[string]time.Time` в FIFO очередях растёт без ограничений. AWS очищает через 5 минут.
|
||||||
|
|
||||||
|
#### Что отбросил
|
||||||
|
- Атака через ReceiptHandle — формат `uuid#uuid`, перебор нереален (2^244)
|
||||||
|
- Атака через Authorization header — парсится корректно, плохой формат = 403
|
||||||
|
- Redis injection — go-redis использует протокол RESP, не строки; инъекция невозможна
|
||||||
|
|
||||||
|
### Найденные уязвимости (20 штук)
|
||||||
|
|
||||||
|
#### CRITICAL (2)
|
||||||
|
1. **Публичный Admin API** — `/ui/api/*` без auth, полный доступ к CRUD тенантов/очередей/сообщений
|
||||||
|
2. **Batch message size bypass** — SendMessageBatchV1 не проверяет размер тела каждого сообщения
|
||||||
|
|
||||||
|
#### HIGH (8)
|
||||||
|
3. QueueName без валидации длины/символов
|
||||||
|
4. WaitTimeSeconds без верхней границы (должно быть ≤20)
|
||||||
|
5. ReceiveMessageWaitTimeSeconds атрибут без верхней границы
|
||||||
|
6. DelaySeconds без верхней границы (AWS макс 900)
|
||||||
|
7. VisibilityTimeout без верхней границы (AWS макс 43200)
|
||||||
|
8. MaxNumberOfMessages без верхней границы (AWS макс 10)
|
||||||
|
9. Message attributes: без лимита на количество и размер
|
||||||
|
10. Data race в GetQueueUrlV1 (нет RLock)
|
||||||
|
|
||||||
|
#### MEDIUM (8)
|
||||||
|
11. Нет лимита на количество сообщений в очереди
|
||||||
|
12. Нет лимита на создание тенантов
|
||||||
|
13. FIFO group lock без таймаута
|
||||||
|
14. Duplicates map без очистки
|
||||||
|
15. BatchEntryId без валидации длины
|
||||||
|
16. DeduplicationID без валидации длины (AWS макс 128)
|
||||||
|
17. GroupID без валидации длины (AWS макс 128)
|
||||||
|
18. Redis serialization без ограничения размера
|
||||||
|
|
||||||
|
#### LOW (2)
|
||||||
|
19. `{account}` в URL не валидируется
|
||||||
|
20. ReceiptHandle не валидируется по формату перед поиском
|
||||||
|
|
||||||
|
### План защиты — приоритизация
|
||||||
|
|
||||||
|
Принцип: начать с самого опасного и дешёвого в реализации.
|
||||||
|
|
||||||
|
**Фаза 1 — Критическое (блокирует production)**
|
||||||
|
1. Убрать или защитить `/ui/api/*` маршруты
|
||||||
|
2. Добавить валидацию размера тела в SendMessageBatchV1
|
||||||
|
3. Добавить RLock в GetQueueUrlV1
|
||||||
|
|
||||||
|
**Фаза 2 — AWS-совместимые лимиты (валидация параметров)**
|
||||||
|
4. QueueName: макс 80 chars, regex `^[a-zA-Z0-9_-]+(.fifo)?$`
|
||||||
|
5. WaitTimeSeconds: 0-20
|
||||||
|
6. ReceiveMessageWaitTimeSeconds: 0-20
|
||||||
|
7. DelaySeconds: 0-900
|
||||||
|
8. VisibilityTimeout: 0-43200
|
||||||
|
9. MaxNumberOfMessages: 1-10
|
||||||
|
10. Message attributes: макс 10, общий размер ≤256KB
|
||||||
|
11. DeduplicationID: макс 128 chars
|
||||||
|
12. GroupID: макс 128 chars
|
||||||
|
|
||||||
|
**Фаза 3 — Per-tenant resource limits**
|
||||||
|
13. Макс сообщений в очереди (per queue, напр. 100K)
|
||||||
|
14. Макс общий размер сообщений per tenant (напр. 1GB)
|
||||||
|
15. Rate limiting per tenant (напр. 100 req/sec)
|
||||||
|
16. Макс тенантов в системе (глобальный лимит)
|
||||||
|
|
||||||
|
**Фаза 4 — Стабильность**
|
||||||
|
17. FIFO group lock timeout (= VisibilityTimeout)
|
||||||
|
18. Duplicates map cleanup (goroutine, TTL 5 мин)
|
||||||
|
19. Redis: ограничить размер сериализации / разбить на chunks
|
||||||
|
20. `{account}` в URL: валидировать = tenant ID из context
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Сессия 3 — Контроль доступа через nubes JWT
|
||||||
|
# Agent: GitHub Copilot (Claude Opus 4.6)
|
||||||
|
|
||||||
|
### Задача
|
||||||
|
Заменить текущий auth (AccessKey/SecretKey per tenant → in-memory TenantStore) на JWT-токен nubes.
|
||||||
|
Пользователь вводит токен в UI → токен валидируется через `https://deck-api-test.ngcloud.ru/api/v1`.
|
||||||
|
Email из токена показывается в UI справа вверху.
|
||||||
|
|
||||||
|
### Разведка sless проекта
|
||||||
|
|
||||||
|
Изучил `~/terra/sless/` — соседний проект, где эта схема уже работает.
|
||||||
|
|
||||||
|
#### Структура JWT токена nubes (реальный пример):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"iss": "auth-api",
|
||||||
|
"sub": "0199e325-1cdf-7cda-9319-e5302a85e291", // UUID пользователя
|
||||||
|
"exp": 1786932675,
|
||||||
|
"email": "tazet@narod.ru", // Email — показывать в UI
|
||||||
|
"email_verified": false,
|
||||||
|
"name": "",
|
||||||
|
"preferred_username": "",
|
||||||
|
"realm_access": {"roles": null},
|
||||||
|
"resource_access": {"account": {"roles": null}}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Как sless это делает:
|
||||||
|
1. **JWT parsing** (`client.go`): `SubFromJWT(token)` → декодирует JWT payload → возвращает `sub` (UUID)
|
||||||
|
2. **Namespace** (`client.go`): `NamespaceFromSub(sub)` → `SHA256(sub)[:8]` → `"sless-{16hex}"`
|
||||||
|
3. **Валидация** (`client.go`): `PingNubesAPI(endpoint, token)` → GET к `deck-api-test.ngcloud.ru/api/v1` с Bearer → 401/403 = отклонён
|
||||||
|
4. **Auth middleware** (`middleware/auth.go`): проверяет `Authorization: Bearer <token>` → в тестовом режиме принимает любую строку
|
||||||
|
|
||||||
|
#### Ключевые решения sless:
|
||||||
|
- Подпись JWT НЕ проверяется (нет JWKS endpoint nubes) — "trusted perimeter"
|
||||||
|
- Валидация токена = запрос к nubes API (PingNubesAPI) — если API вернул не 401/403, значит токен живой
|
||||||
|
- sub пользователя (UUID) хешируется для namespace — чтобы не показывать реальный ID наружу
|
||||||
|
|
||||||
|
### Размышления для SQS-service
|
||||||
|
|
||||||
|
**Вопрос 1: нужен ли namespace из хеша для SQS?**
|
||||||
|
Пользователь сказал "для SQS самого как очереди может и не надо". И правда:
|
||||||
|
- В sless namespace нужен для k8s: каждый пользователь = свой namespace с функциями/подами
|
||||||
|
- В SQS очереди живут в in-memory map, изоляция через составной ключ `{accessKey}:{queueName}`
|
||||||
|
- НО в общей конфигурации IoT + sless + funcs + SQS — единый namespace пользователя нужен
|
||||||
|
|
||||||
|
**Решение**: вычислять namespace НО использовать его как tenantID (а не k8s namespace).
|
||||||
|
Формула та же: `SHA256(sub)[:8]` → `"sless-{16hex}"` — совместимость с sless.
|
||||||
|
|
||||||
|
**Вопрос 2: что делать с текущим TenantStore (AccessKey/SecretKey)?**
|
||||||
|
Текущая система: admin создаёт тенанта → получает credentials → вводит в AWS CLI.
|
||||||
|
Новая система: пользователь вводит JWT → auto-provisioning тенанта.
|
||||||
|
|
||||||
|
Варианты:
|
||||||
|
- A) Полностью заменить → ломает существующих тестовых пользователей
|
||||||
|
- B) Добавить JWT как второй путь auth → оба работают
|
||||||
|
- C) JWT через UI → auto-create tenant с AccessKey → AWS CLI использует AccessKey
|
||||||
|
|
||||||
|
Вариант C самый логичный: JWT auth в UI/admin, AccessKey auth в SQS API (AWS SDK совместимость).
|
||||||
|
|
||||||
|
**Вопрос 3: Email в UI?**
|
||||||
|
Из JWT: `claims.email` → показать в правом верхнем углу UI.
|
||||||
|
|
||||||
|
### План (предварительный, ждём подтверждения)
|
||||||
|
1. Добавить JWT-парсинг (аналог sless SubFromJWT + EmailFromJWT)
|
||||||
|
2. Добавить PingNubesAPI для валидации токена
|
||||||
|
3. UI: окно ввода токена → при вводе → автоматически создаётся tenant
|
||||||
|
4. UI: показать email в правом верхнем углу
|
||||||
|
5. Совместимость: SQS API по-прежнему через AccessKey (AWS SDK), JWT — только для UI/admin
|
||||||
|
|
||||||
|
### Реализация (выполнено)
|
||||||
|
|
||||||
|
#### Новые файлы:
|
||||||
|
- `app/auth/jwt.go` — ParseJWTClaims, TenantIDFromSub (SHA256 совместимый с sless), PingNubesAPI
|
||||||
|
|
||||||
|
#### Изменённые файлы:
|
||||||
|
- `app/tenant/tenant_store.go`:
|
||||||
|
- Добавлены поля `NubesSub`, `Email` в Tenant struct
|
||||||
|
- Третий индекс `bySub` в TenantStore
|
||||||
|
- Метод `GetBySub(sub)` для поиска по JWT sub
|
||||||
|
- Метод `CreateFromJWT(tenantID, sub, email, maxQueues)` — идемпотентный auto-provisioning
|
||||||
|
- Все операции (Create, Delete, LoadTenant) обновлены для bySub индекса
|
||||||
|
|
||||||
|
- `app/admin/admin.go`:
|
||||||
|
- Добавлен `nubesEndpoint` в Handler (из env `NUBES_ENDPOINT`, default `https://deck-api-test.ngcloud.ru/api/v1`)
|
||||||
|
- `POST /ui/api/auth` — публичный endpoint: принимает JWT → валидирует через nubes → auto-provision tenant → ответ с email/credentials
|
||||||
|
- `jwtMiddleware` — middleware для защиты остальных /ui/api/* endpoints
|
||||||
|
- RegisterPublicRoutes теперь использует jwtMiddleware (кроме /ui/api/auth)
|
||||||
|
|
||||||
|
- `app/ui/index.html`:
|
||||||
|
- Добавлена login page с вводом JWT токена
|
||||||
|
- Email отображается в navbar справа вверху
|
||||||
|
- Сессия сохраняется в localStorage (token + email)
|
||||||
|
- Все API запросы теперь с `Authorization: Bearer <jwt>` header
|
||||||
|
- При 401/403 → автоматический выход на login page
|
||||||
|
- Кнопка "Выйти" очищает сессию
|
||||||
|
|
||||||
|
#### Результат компиляции:
|
||||||
|
`go build ./...` — PASS (без ошибок).
|
||||||
|
`go test ./...` — pre-existing failures (missing fixtures, Topics) — не связаны с моими изменениями.
|
||||||
|
|
||||||
|
#### Архитектурное решение:
|
||||||
|
- JWT auth → ТОЛЬКО для UI console (/ui/api/*)
|
||||||
|
- SQS API → по-прежнему через AccessKey в AWS Authorization header (совместимость с AWS SDK)
|
||||||
|
- TenantID из JWT = `sless-{SHA256(sub)[:8]}` — идентичен sless namespace → единый пользователь во всех сервисах
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Сессия 4
|
||||||
|
# Agent: GitHub Copilot (Claude Opus 4.6)
|
||||||
|
|
||||||
|
### Задача
|
||||||
|
Дебаг 404 на POST /ui/api/auth после деплоя v0.1.15
|
||||||
|
|
||||||
|
### Диагностика
|
||||||
|
|
||||||
|
1. **Первая гипотеза**: gorilla/mux route ordering — `r.PathPrefix("/ui")` static handler перехватывает `/ui/api/auth`.
|
||||||
|
- Перенёс `/ui/api/auth` на subrouter вместо root router HandleFunc.
|
||||||
|
- Собрал v0.1.16, задеплоил → **всё ещё 404**
|
||||||
|
|
||||||
|
2. **Тест изнутри пода**: `kubectl exec ... wget POST /ui/api/auth` → **401 Unauthorized** (маршрут работает!).
|
||||||
|
- Значит проблема НЕ в коде, а в прохождении через ingress.
|
||||||
|
|
||||||
|
3. **Тест через port-forward**: `curl POST http://localhost:14100/ui/api/auth` → **JSON ответ** (работает!).
|
||||||
|
- Подтверждение: код верный, ingress ломает.
|
||||||
|
|
||||||
|
4. **Ключевое открытие — два IP**:
|
||||||
|
- DNS `qu.kube5s.ru` → `185.247.187.151`
|
||||||
|
- Ingress в kubectl → `185.247.187.147`
|
||||||
|
- Тест напрямую на `.147`: POST auth → **работает** (JSON)
|
||||||
|
- Тест напрямую на `.151`: POST auth → **404**
|
||||||
|
|
||||||
|
5. **Причина**: kubectl был подключён к СТАРОМУ кластеру (`.147`), а DNS `qu.kube5s.ru` указывал на НОВЫЙ кластер `iot-naeel` (`.151`). Все деплои шли не туда.
|
||||||
|
|
||||||
|
### Решение
|
||||||
|
- Обновил kubeconfig на VM → новый кластер `iot-naeel` (API: `185.247.187.149:6443`, ingress: `185.247.187.151`)
|
||||||
|
- Задеплоил v0.1.16 в правильный кластер
|
||||||
|
- **Результат**: POST `/ui/api/auth` → 401 (корректный ответ), GET `/ui/api/health` → 401 (middleware работает), `/health` → OK
|
||||||
|
|
||||||
|
### Изменения в коде (v0.1.16)
|
||||||
|
- `app/admin/admin.go`: `/ui/api/auth` перенесён на subrouter (вместо root router HandleFunc) — исключает конфликт с PathPrefix в gorilla/mux. jwtMiddleware пропускает `/auth` path.
|
||||||
|
- `app/router/router.go`: комментарий о порядке регистрации routes.
|
||||||
|
|
||||||
|
### Redis
|
||||||
|
В новом кластере Redis подключается корректно: `rfrm-redisk8s.UUID.svc.cluster.local:6379`.
|
||||||
|
Загружено 6 тенантов, 31 очередь из Redis — данные мигрированы.
|
||||||
|
|
||||||
|
### Вывод
|
||||||
|
Проблема была инфраструктурная (два кластера), не программная. Код JWT auth работал с первой попытки.
|
||||||
|
|||||||
Reference in New Issue
Block a user