// app/router/router.go // HTTP router для shared-sqs // Updated: 2026-04-09 — добавлены TenantStore, admin API, auth middleware package router import ( "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 r.Handle("/metrics", 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#", // "message": "..."}. Раньше для ErrorResponse писался голый ErrorResult // ({"Type":...,"Code":...,"Message":...}) — SDK не распознавал это как ошибку // и возвращал Code=None. // - AwsQueryProtocol: ответ всегда XML (в т.ч. для ошибок). 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) } 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 выключен) billing.RecordUsage(t.ID, action, queueName, 1, 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 }