Files
SQS-service/app/router/router.go
T
Repinoid 31840b9756 fix: DLQ-релинк после загрузки из Redis + безопасная эвикция retention + ParseForm
1. DLQ больше не сериализуется вложенным объектом (была detached-копия после
   загрузки + неограниченная рекурсия при цикле): в метаданных хранится только
   имя (dead_letter_queue_name), после LoadAllQueues указатель релинкуется на
   объект из map. Закрывает потерянные переносы в DLQ и чужой мьютекс.
2. Retention-эвикция: компакция НА МЕСТЕ (чтение value в i, запись в n<=i)
   вместо kept := Messages[:0] — устранён алиасинг на переиспользуемом срезе.
3. countBatchEntries: явный req.ParseForm() — корректный msg_count для батчей.
Тесты: +DLQ-перенос, +эвикция с сохранением порядка; go test -race зелёный.
2026-10-07 07:18:31 +03:00

341 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// app/router/router.go
// HTTP router для shared-sqs
// Updated: 2026-04-09 — добавлены TenantStore, admin API, auth middleware
package router
import (
"crypto/subtle"
"encoding/json"
"encoding/xml"
"fmt"
"net/http"
"strings"
"time"
"shared-sqs/app/admin"
"shared-sqs/app/auth"
"shared-sqs/app/billing"
sqs "shared-sqs/app/gosqs"
"shared-sqs/app/interfaces"
"shared-sqs/app/metrics"
"shared-sqs/app/models"
"shared-sqs/app/tenant"
"shared-sqs/app/ui"
"github.com/gorilla/mux"
"github.com/prometheus/client_golang/prometheus/promhttp"
log "github.com/sirupsen/logrus"
)
// New — создаёт HTTP router с tenant auth и admin API
func New(tenantStore *tenant.TenantStore, adminToken string) http.Handler {
r := mux.NewRouter()
// Fix #3.5: глобальный лимит размера тела HTTP запроса — 3MB
// Защита от OOM при отправке гигантских batch/message requests
r.Use(requestBodyLimitMiddleware)
// /health — публичный, без auth
r.HandleFunc("/health", health).Methods("GET")
// /static — логотип, favicon для публичных страниц
r.PathPrefix("/static").Handler(ui.StaticHandler()).Methods("GET")
// /metrics — Prometheus endpoint для Victoria Metrics scrape.
// Защищён Bearer admin-токеном: метрики содержат per-tenant данные
// (accessKey/число очередей и сообщений), которые нельзя светить наружу.
r.Handle("/metrics", metricsAuth(adminToken, promhttp.Handler())).Methods("GET")
// Страница примеров команд — публично
r.HandleFunc("/examples", func(w http.ResponseWriter, req *http.Request) {
ui.ExamplesHandler(models.Version).ServeHTTP(w, req)
}).Methods("GET")
// Админ-консоль — статика. Регистрируем ДО admin subrouter,
// иначе PathPrefix("/admin") с bearer-auth перехватит GET /admin.
r.HandleFunc("/admin", func(w http.ResponseWriter, req *http.Request) {
ui.AdminHandler(models.Version).ServeHTTP(w, req)
}).Methods("GET")
r.HandleFunc("/admin/", func(w http.ResponseWriter, req *http.Request) {
ui.AdminHandler(models.Version).ServeHTTP(w, req)
}).Methods("GET")
// Admin API — Bearer token auth, регистрируется через AdminHandler
adminHandler := admin.NewHandler(tenantStore, adminToken)
adminHandler.RegisterRoutes(r)
// UI public API — JWT auth, для встроенной console
// ВАЖНО: RegisterPublicRoutes ПЕРЕД static handler — иначе PathPrefix("/ui") перехватит /ui/api/*
adminHandler.RegisterPublicRoutes(r)
// UI console — встроенный SPA, публичный доступ
// НЕ ловит /ui/api/* — mux сначала проверит более специфичные маршруты выше
r.PathPrefix("/ui").Handler(http.StripPrefix("/ui", ui.Handler()))
// SQS API — tenant auth middleware оборачивает каждый handler отдельно.
// r.NewRoute().Subrouter() с Use() некорректно работает в gorilla/mux v1.8.0
// при пустом prefix — ответы теряются. Поэтому используем явную обёртку.
sqsAuth := auth.AuthMiddleware(tenantStore)
// Корень: GET без Action — публичная страница-описание сервиса;
// всё остальное (POST, или GET с Action/X-Amz-Target) — SQS API за tenant auth.
rootHandler := func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodGet && extractAction(req) == "" {
ui.InfoHandler(models.Version).ServeHTTP(w, req)
return
}
sqsAuth(http.HandlerFunc(actionHandler)).ServeHTTP(w, req)
}
r.HandleFunc("/", rootHandler).Methods("GET", "POST")
r.Handle("/{account}", sqsAuth(http.HandlerFunc(actionHandler))).Methods("GET", "POST")
r.Handle("/queue/{queueName}", sqsAuth(http.HandlerFunc(actionHandler))).Methods("GET", "POST")
r.Handle("/{account}/{queueName}", sqsAuth(http.HandlerFunc(actionHandler))).Methods("GET", "POST")
return r
}
// requestBodyLimitMiddleware — ограничивает размер тела HTTP запроса до 3MB.
// Защита от OOM при отправке гигантских messages/batch (Fix #3.5).
func requestBodyLimitMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, 3*1024*1024) // 3MB
}
next.ServeHTTP(w, r)
})
}
// encodeResponse — сериализует ответ в формате протокола клиента.
//
// Логическая схема:
// - AwsJsonProtocol (чистый JSON-протокол): успешный ответ — JSON тела;
// ОШИБКА — спец-формат AWS-JSON {"__type": "com.amazonaws.sqs#<Code>",
// "message": "..."}. Раньше для ErrorResponse писался голый ErrorResult
// ({"Type":...,"Code":...,"Message":...}) — SDK не распознавал это как ошибку
// и возвращал Code=None.
// - AwsQueryProtocol: ответ всегда XML (в т.ч. <ErrorResponse> для ошибок).
func encodeResponse(w http.ResponseWriter, req *http.Request, statusCode int, body interfaces.AbstractResponseBody) {
protocol := resolveProtocol(req)
switch protocol {
case AwsJsonProtocol:
// Ошибка в JSON-протоколе: __type + message — единственный формат,
// который botocore распознаёт как исключение с кодом.
if errResp, ok := body.(models.ErrorResponse); ok {
w.Header().Set("x-amzn-RequestId", errResp.RequestId)
w.Header().Set("Content-Type", "application/x-amz-json-1.0")
w.WriteHeader(statusCode)
err := json.NewEncoder(w).Encode(map[string]string{
"__type": "com.amazonaws.sqs#" + errResp.Result.Code,
"message": errResp.Result.Message,
})
if err != nil {
log.Errorf("Response Encoding Error: %v\nResponse: %+v", err, body)
}
return
}
// Успешный ответ: JSON тела (Result).
w.Header().Set("x-amzn-RequestId", body.GetRequestId())
w.Header().Set("Content-Type", "application/x-amz-json-1.0")
w.WriteHeader(statusCode)
if body.GetResult() == nil {
return
}
err := json.NewEncoder(w).Encode(body.GetResult())
if err != nil {
log.Errorf("Response Encoding Error: %v\nResponse: %+v", err, body)
http.Error(w, "General Error", http.StatusInternalServerError)
}
case AwsQueryProtocol:
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(statusCode)
result, err := xml.Marshal(body)
if err != nil {
log.Errorf("Response Encoding Error: %v\nResponse: %+v", err, body)
http.Error(w, "General Error", http.StatusInternalServerError)
}
_, _ = w.Write(result)
}
}
// routingTableV1 — только SQS actions (SNS удалён)
var routingTableV1 = map[string]func(r *http.Request) (int, interfaces.AbstractResponseBody){
"CreateQueue": sqs.CreateQueueV1,
"ListQueues": sqs.ListQueuesV1,
"GetQueueAttributes": sqs.GetQueueAttributesV1,
"SetQueueAttributes": sqs.SetQueueAttributesV1,
"SendMessage": sqs.SendMessageV1,
"ReceiveMessage": sqs.ReceiveMessageV1,
"ChangeMessageVisibility": sqs.ChangeMessageVisibilityV1,
"DeleteMessage": sqs.DeleteMessageV1,
"GetQueueUrl": sqs.GetQueueUrlV1,
"PurgeQueue": sqs.PurgeQueueV1,
"DeleteQueue": sqs.DeleteQueueV1,
"SendMessageBatch": sqs.SendMessageBatchV1,
"DeleteMessageBatch": sqs.DeleteMessageBatchV1,
"ChangeMessageVisibilityBatch": sqs.ChangeMessageVisibilityBatchV1,
"TagQueue": sqs.TagQueueV1,
"UntagQueue": sqs.UntagQueueV1,
"ListQueueTags": sqs.ListQueueTagsV1,
}
func health(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
fmt.Fprintf(w, `{"status":"ok","version":%q}`, models.Version)
}
// metricsAuth — оборачивает /metrics в проверку Bearer admin-токена.
// В отличие от публичного /health, метрики содержат per-tenant данные
// (accessKey, число очередей/сообщений) и не должны быть доступны без auth.
func metricsAuth(adminToken string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
expected := "Bearer " + adminToken
if len(authHeader) != len(expected) ||
subtle.ConstantTimeCompare([]byte(authHeader), []byte(expected)) != 1 {
w.WriteHeader(http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// countBatchEntries — подсчитывает число записей в batch-запросе SQS.
// Для query-протокола записи идут как SendMessageBatchRequestEntry.N.Id (N=1..).
// Для JSON-протокола считаем по количеству записей в массиве Entries (requestBody).
// Возвращает 0 если определить не удалось (тогда биллинг запишет msg_count=1).
func countBatchEntries(req *http.Request, action string) int {
// Query-формат: считаем уникальные N в SendMessageBatchRequestEntry.N.Id и т.п.
prefix := "SendMessageBatchRequestEntry."
if action == "DeleteMessageBatch" {
prefix = "DeleteMessageBatchRequestEntry."
} else if action == "ChangeMessageVisibilityBatch" {
prefix = "ChangeMessageVisibilityBatchRequestEntry."
}
// Разбираем form, если ещё не разобран (иначе PostForm пуст для lazy-запросов).
_ = req.ParseForm()
if req.PostForm != nil || req.Form != nil {
form := req.Form
if form == nil {
form = req.PostForm
}
count := 0
for key := range form {
if strings.HasPrefix(key, prefix) && strings.HasSuffix(key, ".Id") {
count++
}
}
if count > 0 {
return count
}
}
return 0
}
func actionHandler(w http.ResponseWriter, req *http.Request) {
action := extractAction(req)
start := time.Now()
log.WithFields(log.Fields{
"action": action,
"url": req.URL,
}).Debug("Handling URL request")
jsonFn, ok := routingTableV1[action]
if ok {
statusCode, responseBody := jsonFn(req)
encodeResponse(w, req, statusCode, responseBody)
// Metrics: latency гистограмма для каждой операции
duration := time.Since(start).Seconds()
metrics.RequestDuration.WithLabelValues(action).Observe(duration)
if statusCode < 400 {
if t, _ := req.Context().Value(auth.TenantContextKey).(*tenant.Tenant); t != nil {
msgBytes := req.ContentLength
if msgBytes < 0 {
msgBytes = 0
}
// Извлекаем имя очереди: из URL path vars или из form-параметра QueueName (CreateQueue)
queueName := mux.Vars(req)["queueName"]
if queueName == "" {
queueName = req.FormValue("QueueName")
}
// Billing: запись в PostgreSQL (async, no-op если billing выключен).
// msgCount для batch-операций — число сообщений в батче; для прочих — 1.
msgCount := 1
if action == "SendMessageBatch" || action == "DeleteMessageBatch" ||
action == "ChangeMessageVisibilityBatch" {
if n := countBatchEntries(req, action); n > 0 {
msgCount = n
}
}
billing.RecordUsage(t.ID, action, queueName, msgCount, msgBytes)
// Prometheus counters: requests + bytes
metrics.RequestsTotal.WithLabelValues(t.ID, action).Inc()
metrics.RequestBytesTotal.WithLabelValues(t.ID, action).Add(float64(msgBytes))
}
} else {
// Prometheus: счётчик ошибок
metrics.ErrorsTotal.WithLabelValues(action).Inc()
}
return
}
// Неизвестный Action — отвечаем ошибкой в ФОРМАТЕ ПРОТОКОЛА клиента
// (XML ErrorResponse для Query, AWS-JSON для JSON-протокола).
// Раньше возвращался голый текст "Bad Request" (200/400 без AWS-XML) —
// SDK получал ClientError с Code=None и не мог понять причину.
log.Warnf("Bad Request - Action: %s", action)
errType := models.SqsErrors["GeneralError"]
encodeResponse(w, req, errType.StatusCode(), models.ErrorResponse{
Result: errType.Response(),
RequestId: "00000000-0000-0000-0000-000000000000",
})
}
type AwsProtocol int
const (
AwsJsonProtocol AwsProtocol = iota
AwsQueryProtocol AwsProtocol = iota
)
// extractAction — извлекает имя операции SQS из запроса.
//
// Логическая схема (не зависит от resolveProtocol):
// 1. Заголовок X-Amz-Target ("AmazonSQS.SendMessage") — используют клиенты
// JSON-протокола И AWS CLI v2 в query-mode. Извлекаем часть после точки.
// 2. Иначе — form-параметр Action (классический query-протокол).
//
// ВАЖНО: определение по X-Amz-Target в первую очередь обязательно — AWS CLI v2
// шлёт Content-Type: application/x-amz-json-1.0 + x-amzn-query-mode: true, но
// Action у него в заголовке, а НЕ в form-параметрах.
func extractAction(req *http.Request) string {
if action := req.Header.Get("X-Amz-Target"); action != "" {
parts := strings.SplitN(action, ".", 2)
if len(parts) == 2 && parts[1] != "" {
return parts[1]
}
}
return req.FormValue("Action")
}
// resolveProtocol — определяет протокол ОТВЕТА по заголовкам запроса.
//
// Логическая схема:
// 1. Content-Type: application/x-amz-json-1.0 → JSON-протокол (ответ JSON).
// Сюда входят: чистые JSON-клиенты (SDK) И AWS CLI v2/boto3 в режиме
// «query-compatible JSON» (x-amzn-query-mode: true). ВАЖНО (эмпирика):
// query-mode клиент парсит именно JSON-ответы — при XML-ответе список
// очередей распознаётся как пустой (None), при JSON — корректно.
// 2. Всё остальное (form-urlencoded от классических клиентов) — query/XML.
//
// Ошибки в JSON-протоколе оформляются как {"__type":"...","message":"..."}
// (см. encodeResponse) — только такой формат botocore распознаёт как ошибку
// с кодом (раньше отдавался голый ErrorResult → Code=None).
func resolveProtocol(req *http.Request) AwsProtocol {
if req.Header.Get("Content-Type") == "application/x-amz-json-1.0" {
return AwsJsonProtocol
}
return AwsQueryProtocol
}