- app/leader: Elector (SETNX lock + PEXPIRE heartbeat via Lua, IsLeader) - app/persistence: TryAcquireLock/RefreshLock/ReleaseLock (no-op when Redis absent) - app/router: /ready -> 200 if ReadyFunc nil/true, else 503 (both planes) - app/cmd: --leader-election / SHARED_SQS_LEADER_ELECTION, --leader-ttl, --leader-interval; ReadyFunc wired only when enabled, lock released on graceful shutdown - default OFF: single-instance behaviour unchanged
384 lines
17 KiB
Go
384 lines
17 KiB
Go
// 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"
|
||
)
|
||
|
||
// NewDataPlane — публичный SQS API (data-plane): SQS actions, /health, статика, описание.
|
||
// НЕ содержит админ-консоль, /metrics и /ui — они в control-plane (NewControlPlane).
|
||
//
|
||
// Разбиение data-plane/control-plane (Шаг 2): публично доступен только этот роутер.
|
||
// Пока — два листенера в одном процессе (общее in-memory состояние); отдельные процессы
|
||
// станут возможны после выноса состояния в Redis (Шаг 3).
|
||
func NewDataPlane(tenantStore *tenant.TenantStore) 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")
|
||
|
||
// /ready — готовность инстанса (для failover/балансировщика)
|
||
r.HandleFunc("/ready", readyHandler).Methods("GET")
|
||
|
||
// /static — логотип, favicon для публичных страниц
|
||
r.PathPrefix("/static").Handler(ui.StaticHandler()).Methods("GET")
|
||
|
||
// Страница примеров команд — публично
|
||
r.HandleFunc("/examples", func(w http.ResponseWriter, req *http.Request) {
|
||
ui.ExamplesHandler(models.Version).ServeHTTP(w, req)
|
||
}).Methods("GET")
|
||
|
||
// 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
|
||
}
|
||
|
||
// NewControlPlane — админка, метрики и UI (control-plane).
|
||
// ВАЖНО: этот роутер должен слушать ТОЛЬКО localhost (не проксируется наружу).
|
||
func NewControlPlane(tenantStore *tenant.TenantStore, adminToken string) http.Handler {
|
||
r := mux.NewRouter()
|
||
|
||
r.Use(requestBodyLimitMiddleware)
|
||
|
||
// /health — для проверки control-plane локально
|
||
r.HandleFunc("/health", health).Methods("GET")
|
||
|
||
// /ready — готовность (лидер ли)
|
||
r.HandleFunc("/ready", readyHandler).Methods("GET")
|
||
|
||
// /static — нужен UI-консоли (логотип/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")
|
||
|
||
// Админ-консоль — статика. Регистрируем ДО 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()))
|
||
|
||
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)
|
||
}
|
||
|
||
// ReadyFunc — если задана, /ready отдаёт 200 при true и 503 иначе (для failover/LB).
|
||
// Задаётся в main при включённом leader-election. nil → всегда готовы.
|
||
var ReadyFunc func() bool
|
||
|
||
// readyHandler — готовность инстанса обслуживать трафик (для балансировщика/failover).
|
||
// Пассивный инстанс отдаёт 503, чтобы LB не направлял на него трафик.
|
||
func readyHandler(w http.ResponseWriter, req *http.Request) {
|
||
if ReadyFunc != nil && !ReadyFunc() {
|
||
w.WriteHeader(http.StatusServiceUnavailable)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte(`{"status":"ready"}`))
|
||
}
|
||
|
||
// 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
|
||
}
|