diff --git a/doc/thinking/plan-for-flash-2026-10-05.md b/doc/thinking/plan-for-flash-2026-10-05.md new file mode 100644 index 0000000..a99b5c8 --- /dev/null +++ b/doc/thinking/plan-for-flash-2026-10-05.md @@ -0,0 +1,264 @@ +# План доработок 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 ./...`.