# План доработок shared-sqs — для DeepSeek Flash (2026-10-05) > Исполнитель: кодер (DeepSeek Flash). Источник правил: память + отчёт Opus > (`opus-report-2026-10-05.md`) + мой анализ (`my-analysis-of-opus-report-2026-10-05.md`). > > ⛔ ВАЖНО: прод НЕ используется, никто сервис не юзает. **Обратная > совместимость и миграции НЕ нужны.** Всё делается корректно «на будущее». > Кодер должен работать в репозитории `/home/naeel/nubes/SQS_und_IoT/SQS-service`. --- ## Правила работы (обязательные для кодера) 1. **Коммитить почаще** — после каждой логически завершённой правки, отдельным коммитом с понятным сообщением вида `sqs: ...`. Не копить правки. 2. **Один трек-пункт = один/несколько коммитов.** Не смешивать P0 и P2 в одном коммите. 3. После каждой правки — `go build ./...` и `go test ./...` (зелёные до/после). 4. Не трогать файлы вне перечисленных ниже. Не менять публичные HTTP-контракты SQS API без явного указания (совместимость с AWS SDK должна сохраняться). 5. Правила платформы (сетевые таймауты/MSS) в этот план НЕ входят — это не код. --- # ЧАСТЬ 1 — P0: блокеры продакшна (безопасность + durability) ## P0.1 — Убрать детерминированный SecretKey (случайные креды) **Проблема:** `tenant_store.go` выводит AccessKey/SecretKey из email через SHA256 с захардкоженной солью. Любой, кто знает email и соль (соль в исходнике), вычисляет чужие креды и получает доступ к очередям через SQS-эндпоинт (JWT не нужен — только SigV4). **Файлы:** `app/tenant/tenant_store.go`. **Что сделать:** - Удалить `tenantIDFromEmail`, `accessKeyFromEmail`, `secretKeyFromEmail`, константу `secretKeySalt`, и полностью переписать `CreateFromJWT`: - `tenantID` — `generateTenantID()` (случайный, уже есть); - `accessKey` — `generateAccessKey()` (случайный, уже есть); - `secretKey` — `generateSecretKey()` (случайный, уже есть). - Сохранять связь `email → tenant` в индексе `byEmail` (как сейчас), НО ключи больше не зависят от email. - При повторном логине с тем же email — возвращать существующего тенанта (идемпотентность по `byEmail` сохранить), НЕ пересоздавать и НЕ пересчитывать ключи. - Убрать логику «legacy-пересчёта» (ветку удаления старой записи): с нуля её не нужно — если тенант с email найден, просто вернуть его как есть. - Случайные ключи и так генерируются через `crypto/rand` (`generateAccessKey`, `generateSecretKey`) — их уже существует, использовать их. **Критерий готовности:** после логина новый тенант получает AccessKey/SecretKey, не совпадающие с `SHA256(email)`; повторный логин с тем же email возвращает те же креды (из Redis/индекса), а не пересоздаёт. ## P0.2 — Усилить доверие к JWT (не доверять неподписанному payload) **Проблема:** `jwt.go` не проверяет подпись; `PingNubesAPI` принимает токен, если хоть один стенд вернул не-401/403. Идентичность берётся из незаверенного claim `email`. **Файлы:** `app/auth/jwt.go`, `app/auth/cache.go`. **Что сделать (минимально безопасно, без внешнего JWKS):** - `PingNubesAPI`: принимать токен ТОЛЬКО при явном HTTP 2xx от доверенного эндпоинта (сейчас логика «любой ответ, кроме 401/403 → OK»). 401/403 → `ErrTokenRejected`; прочие коды → тоже считаем ошибкой (не принимать). - Порядок стендов оставить как есть (prod/test/dev), но успех — только по 2xx. - Дополнительно: если в ответе валидирующего API доступна расшифровка токена (email/sub), использовать её вместо доверия к payload. Если API возвращает только статус — оставить claim `email` как есть, но после P0.1 это уже не даёт чужих кредов. - `cache.go` — оставить TTL 5м/grace 2м (риск отозванного токена приемлем и задокументирован). **Критерий готовности:** токен, на который все стенды отвечают 500/503/иным не-401-кодом, больше НЕ принимается; принимается только явный 2xx. ## P0.3 — Durability SendMessage (синхронный персист до 200) **Проблема:** `SendMessage` отвечает 200, а Redis-запись уходит асинхронно в горутину (`redis.go` `asyncWrite`). Краш пода в этом окне = потеря подтверждённого сообщения. **Файлы:** `app/persistence/redis.go`, `app/gosqs/send_message.go`. **Что сделать:** - Ввести синхронный вариант записи сообщения (по аналогии с уже существующим синхронным `DeleteMessagePersist`): `SaveMessageSync(key, msg)` — выполняет `HSet` с коротким таймаутом и ждёт результат. - В `SendMessageV1`: после добавления в `queue.Messages` вызывать `SaveMessageSync` ДО возврата 200. При ошибке Redis — вернуть клиенту ошибку (InternalError) и откатить append сообщения (не оставлять сообщение в памяти без персиста), либо залогировать и вернуть ошибку — выбрать стратегию и зафиксировать её в комментарии. - Оставить `SaveMessage` (async) для некритичных случаев, но SendMessage должен персистить синхронно. **Критерий готовности:** краш пода сразу после 200 на SendMessage НЕ приводит к потере сообщения — оно уже в Redis. --- # ЧАСТЬ 2 — P1: архитектурная зрелость ## P1.1 — Per-queue мьютекс вместо глобального **Проблема:** один `SyncQueues.RWMutex` на весь сервис — любой send/receive/tick берёт единственный Lock; long-poll переобходит его каждые 100 мс; O(N)-скан под локом. **Файлы:** `app/models/globals.go`, `app/models/models.go`, все gosqs-хендлеры, `app/gosqs/gosqs.go` (`PeriodicTasks`/`processQueueTick`), `app/persistence/redis.go`. **Что сделать:** - Добавить в `Queue` поле `mu sync.RWMutex` (per-queue блокировка). - `SyncQueues` оставить как `map[string]*Queue` под отдельным коротким `sync.RWMutex` ТОЛЬКО для доступа к самой map (вставка/удаление/чтение указателя), а состояние ОЧЕРЕДИ защищать её собственным `mu`. - Перевести все операции на схему: короткий `SyncQueues.RLock` → взять указатель `queue` → `SyncQueues.RUnlock` → `queue.mu.Lock()` на время работы с содержимым. - В `processQueueTick` снять глобальный Lock с обхода: пройти по снапшоту очередей, каждую блокировать её `mu` отдельно. - Обновить `persistence`-вызовы: они должны вызываться под `queue.mu`, как сейчас под глобальным (см. комментарии «ВЫЗЫВАТЬ под Lock»). **Критерий готовности:** нет ни одной операции, блокирующей все очереди сервиса на время O(N)-работы; тесты на конкурентный доступ проходят. ## P1.2 — Индексация сообщений (убрать O(N) по слайсу) **Проблема:** `Messages []SqsMessage` — receive/delete/visibility — O(N); при лимите ~120k сообщений и текущих локах это основной источник latency. **Файлы:** `app/models/models.go`, `app/gosqs/receive_message.go`, `app/gosqs/delete_message.go`, `app/gosqs/gosqs.go`. **Что сделать (инкрементально, без ломки схемы):** - Добавить в `Queue` индекс `receiptIndex map[string]int` (ReceiptHandle → индекс в `Messages`) и/или `readyIndex []int`; поддерживать его при каждом receive/delete/таймауте. - DeleteMessage: по ReceiptHandle — O(1) вместо скана слайса. - Visibility-tick: по `receiptIndex` определять истёкшие, а не сканировать весь слайс. - Если полный рефакторинг со слайса на map+heap сложен — сначала версию с индексом, сохранив слайс, чтобы не ломать Redis-сериализацию (`marshalMessage`). **Критерий готовности:** DeleteMessage и visibility-перерасчёт не зависят от полного размера очереди. ## P1.3 — Реализовать MessageRetentionPeriod **Проблема:** `MessageRetentionPeriod` помечен `// TODO - not used`; сообщения не истекают, растут до лимита, потом `OverLimit`. **Файлы:** `app/models/models.go`, `app/gosqs/gosqs.go` (`processQueueTick`), `app/persistence/redis.go`. **Что сделать:** - В `processQueueTick` добавить эвикцию сообщений старше retention: если `SentTime + MessageRetentionPeriod < now` — удалить сообщение и его Redis-запись (`HDel`). - Учитывать retention при `IsReadyForReceipt`/выдаче: не выдавать просроченные. - Дефолт retention уже задан (345600 = 4 дня в `config.go` `applyEnvironmentDefaults`). **Критерий готовности:** сообщение автоматически исчезает из очереди и Redis по истечении retention, очереди не забиваются до `OverLimit`. ## P1.4 — Индекс тенант → его очереди **Проблема:** `countTenantQueues`, `listTenantQueues`, `createTenantQueue` (лимит), `deleteTenant`, gauge-updater — O(total) скан-по-префиксу по всем очередям. **Файлы:** `app/gosqs/tenant_helpers.go`, `app/admin/admin.go`. **Что сделать:** - Вести индекс `map[accessKey]map[queueName]struct{}` (или в `TenantStore`) для O(1) подсчёта/списка очередей тенанта. - `countTenantQueues` → `len(index[accessKey])`. - Обновлять индекс в `CreateQueue`/`DeleteQueue`/`PurgeQueue`/`DeleteTenant` и при восстановлении из Redis на старте. **Критерий готовности:** лимиты/списки очередей не сканируют все очереди сервиса. ## P1.5 — Убрать demo-bypass из прод-сборки **Проблема:** хардкод demo-кредов (`seed.go`) и публичного UI-bypass-токена (`authenticateUIDemoToken` в `admin.go`). **Файлы:** `app/cmd/seed.go`, `app/admin/admin.go`. **Что сделать:** - Убрать `authenticateUIDemoToken` и фиксированный UI-токен; UI-доступ — только через реальную JWT-валидацию. - Демо-тенант (`SHARED_SQS_SEED_DEMO`) оставить за env-флагом, но креды для демо генерировать через `CreateFixed` только при `SHARED_SQS_SEED_DEMO=true` (как сейчас), НЕ хранить захардкоженный `demo-secret-key-shared-sqs-ngcloud-2026` в коде — генерировать случайным образом и выводить в лог при старте. **Критерий готовности:** без env-флага `SHARED_SQS_SEED_DEMO` и без явно заданного admin UI-токена в прод-конфиге бэкдор-путей нет. --- # ЧАСТЬ 3 — P2: надёжность, корректность, чистка ## P2.1 — BYOC Admin API **Файлы:** `app/admin/admin.go`, `app/tenant/tenant_store.go`. - В `createTenantRequest` добавить опц. поля `access_key`, `secret_key`. - Если оба заданы → `CreateFixed` (валидация мин. длины: access_key ≥8, secret_key ≥16). - Если одно без другого → 400. Экспортировать/использовать `generateTenantID`. - Детали в `doc/byoc-credentials.md` (раздел с TODO). ## P2.2 — Чинить сиротство очередей (после отказа от legacy-пересчёта в P0.1 — уже неактуально) После P0.1 ветка «legacy-пересчёта» удалена — сиротства больше не возникает. **Действие:** убедиться, что после P0.1 в `CreateFromJWT` не осталось пути, где тенант меняет AccessKey без переноса очередей. Если креды теперь неизменны на весь жизненный цикл тенанта — пункт закрыт. ## P2.3 — Constant-time сравнение admin-токена **Файлы:** `app/admin/admin.go`. - Заменить `authHeader != expected` на `subtle.ConstantTimeCompare`. ## P2.4 — Единый путь создания/отправки (admin ≠ gosqs) **Файлы:** `app/admin/admin.go`. - `createTenantQueue`/`sendMessageToQueue` должны переиспользовать логику gosqs (создание через общий код, FIFO-детект, проверка размера, MD5, дедуп), а не дублировать с иными дефолтами. ## P2.5 — Биллинг: воркер/батч вместо горутины на операцию; корректный msg_count **Файлы:** `app/billing/billing.go`, `app/router/router.go` (`actionHandler`). - Заменить `go func(){ Insert }` на буфер+воркер (батч-инсерты). - `RecordUsage` принимать корректный `msg_count` (для SendMessageBatch — число сообщений в батче), а не всегда 1. ## P2.6 — Защитить/урезать `/metrics` **Файлы:** `app/router/router.go`. - Убрать per-tenant данные из публичных метрик ИЛИ закрыть `/metrics` аутентификацией. - Минимально: убрать лейбл `tenant` с идентификаторами из публичного `/metrics`, оставить агрегаты без идентификации тенантов. ## P2.7 — Чистка легаси **Файлы:** `app/conf/goaws.yaml`, `app/conf/config.go`, `app/models/*`. - Убрать SNS Topics/Subscriptions из `goaws.yaml`. - Убрать мёртвый путь загрузки yaml-очередей без tenant-префикса в `config.go`. - Убрать SNS-поля из моделей. - Не трогать `deployments/**` (вне scope кода; при желании — отдельно). ## P2.8 — Redis reconnect/health **Файлы:** `app/persistence/redis.go`. - После обрыва соединения — попытка переподключения / пометка нездоровья, а не молчаливые ошибки в лог. Мин.: пересоздание `Client` при повторном ping-фейле. --- # Порядок выполнения (коммитить почаще, по пунктам) 1. P0.1 → коммит 2. P0.3 → коммит (durability, пока не зависит от P1.1) 3. P0.2 → коммит 4. P1.1 → коммит (самый крупный, тестировать пристально) 5. P1.2 → коммит 6. P1.3 → коммит 7. P1.4 → коммит 8. P1.5 → коммит 9. P2.1 … P2.8 → по одному коммиту каждый После каждого коммита — `go build ./... && go test ./...`.