16 KiB
Отчёт Opus — КРАКО и план доработок shared-sqs (2026-10-05)
Полный текст ответа Opus (без сокращений) на промпт
opus-review-prompt-2026-10-05.md. Сохранён для истории и критического анализа.
1. КРАКО — критический разбор архитектуры
Что сделано хорошо
- Изоляция тенантов проста и надёжна в ядре. Ключ очереди
{AccessKey}:{queueName}(tenant_helpers.go) делает кросс-тенантный доступ через SQS физически невозможным: тенант берётся из контекста, ключ строится из его AccessKey. Простое и верное решение. - SigV4 реально проверяется (вопреки формулировке «подпись не проверяется» — это про JWT, не про SQS). В
auth_middleware.goполноценный canonical request,subtle.ConstantTimeCompare, проверка clock skew, поддержка presigned. Это сильная сторона. - Аккуратно закрыты классические гонки. Атомарный receive под одним
Lock(устранён TOCTOU «потери» сообщений,receive_message.go), double-check под Lock вsend_message.go, пер-очередной Lock вPeriodicTasksвместо глобального на весь обход (gosqs.go). - OOM-защита по нескольким фронтам: лимит тела 3 MB,
MaxHeaderBytes, пер-очередной лимит сообщений, глобальный лимит тенантов. - Продуманный graceful shutdown и HTTP-таймауты с обоснованием под long-poll (
goaws.go). - IDOR-защита в UI и урезанные права UI-тенанта (не может создавать/удалять тенантов, видит только себя) —
admin.go. - Персистентность с write-sequence guard (last-write-wins по ключу) и синхронным удалением, чтобы удалённые очереди/сообщения не «воскресали» из Redis.
Главные слабости и риски (по коду)
🔴 Критичные (безопасность)
-
SecretKey детерминированно выводится из email + захардкоженная соль в исходнике.
tenant_store.go:secretKey = SHA256(lower(email) + ":" + "shared-sqs:secret-key:v1"),accessKey = SHA256(email)[:12]. Email — не секрет. Кто знает email уже залогинившегося пользователя (тенант существует) и соль (она в репозитории форка), тот вычисляет AccessKey+SecretKey и получает полный доступ к чужим очередям напрямую через SQS-эндпоинт (JWT там не нужен — только SigV4). Это нарушает базовый принцип: секрет обязан быть случайным, а не функцией публичного идентификатора. -
Идентичность тенанта берётся из НЕподписанного JWT (claim
email), а из email выводятся креды.jwt.goподпись не проверяет; доверие целиком наPingNubesAPI, который принимает токен, если хоть один стенд вернул не 401/403 (jwt.go). Если шлюз при GET/svcне связывает жёстко токен ↔ email, появляется вектор подмены email → провиженинг/доступ под чужой личностью. Плюс кэш валидации даёт отозванному токену жить до 5–7 мин (cache.go).
🟠 Высокие (production-readiness)
-
Один глобальный мьютекс
SyncQueuesна весь сервис. Любой send/receive/tick берёт единственныйLock(globals.go).receiveMessagesUnderLockдержит глобальный Lock на время O(N)-скана сообщений, а long-poll переобходит его каждые 100 мс. Это главный архитектурный боттлнек — ровно он давал всплески latency, упомянутые в комментариях. Не масштабируется по числу очередей/тенантов. -
Невозможна горизонтальная масштабируемость → один под = SPOF. Память — источник правды, Redis — write-through-кэш одной реплики. Две реплики разъедутся (каждая со своей
map, last-write-wins затирает). Значит сервис принципиально single-instance: нет HA, рестарт = простой + окно потери данных. -
Асинхронная запись в Redis = окно потери подтверждённых сообщений.
SendMessageотвечает клиенту 200, аHSetуходит в горутину позже (redis.go). Краш пода между append-в-память и async-записью → подтверждённое сообщение потеряно. Для очереди это нарушение durability (AWS SQS персистит до ack). -
MessageRetentionPeriodне реализован (// TODO - not used,models.go). Сообщения не истекают по времени, растут до пер-очередного лимита, после чего новыеSendMessageотклоняютсяOverLimit. Нет TTL-эвикции — скрытая деградация. -
Сообщения хранятся плоским слайсом
[]SqsMessage. Receive/Delete/visibility-tick — O(N) на операцию; при лимите 120k сообщений и глобальном локе это дорого и усугубляет п.3. Нет индексов по ReceiptHandle/состоянию.
🟡 Средние
-
Пересчёт legacy-тенанта в
CreateFromJWTсиротит очереди (tenant_store.go): очереди остаются под старым AccessKey, новый тенант видит 0 очередей, данные зависают в памяти+Redis навсегда (утечка + «пропажа» данных для пользователя). -
Повсеместные O(total) скан-по-префиксу под глобальным локом:
countTenantQueues,listTenantQueues,createTenantQueue(лимит),deleteTenant, gauge-updater. Квадратичная тенденция при росте тенант×очередь. -
Admin-токен сравнивается небезопасно по времени —
authHeader != expected(admin.go), в отличие от UI-demo-токена (тамConstantTimeCompare). Timing-side-channel. -
Хардкод demo-кредов и публичного UI-bypass-токена в исходнике (
seed.go,admin.go). Сами же комментарии требуют удалить это перед продом. Включается env-флагами, но риск реальный. -
Два расходящихся пути записи/создания.
admin.createTenantQueue/sendMessageToQueue(admin.go) создают очередь и кладут сообщение в обход gosqs-логики: без FIFO-детекта, без проверки размера, без MD5/дедупликации, с другими дефолтами. Поведение UI ≠ поведение SQS API. -
Биллинг: горутина на каждую операцию + пул 5 коннектов (
billing.go). Под нагрузкой — навал горутин, ждущих коннект. Плюсmsg_countвсегда 1, даже дляSendMessageBatch→ неточный учёт. -
/metricsпубличен и отдаёт per-tenant данные (ID тенантов, число очередей/сообщений) — утечка бизнес-метрик наружу. -
Хрупкое определение протокола — строго по
Content-Type == application/x-amz-json-1.0(router.go); иные версии JSON-протокола не распознаются. -
Легаси-остатки GoAWS/SNS: Topics/Subscriptions в
goaws.yaml, SNS-поля в моделях,config.goкладёт yaml-очереди под голым именемqueue.Name(без tenant-префикса → такие очереди недостижимы tenant-хендлерами; сейчас спасаетQueues: []).
2. Приоритизированный план доработок
P0 — блокеры продакшна (безопасность и durability)
- P0.1. Убрать вывод SecretKey из email. Файлы:
tenant_store.go(secretKeyFromEmail/accessKeyFromEmail). Генерировать AccessKey/SecretKey черезcrypto/randпри первом провиженинге и хранить в Redis; идентичность по email держать отдельно от материала ключей. Детерминированным оставить максимум tenantID (не секрет). - P0.2. Усилить доверие к JWT. Файлы:
jwt.go. Либо проверять подпись по JWKS Nubes, либо брать email/ sub из ответа валидирующего API (а не из незаверенного payload), чтобы claim нельзя было подменить. Сузить «принять токен, если любой стенд не вернул 401/403» до явного 2xx от доверенного эндпоинта. - P0.3. Durability для
SendMessage. Файлы:redis.go,send_message.go. Сделать персист сообщения синхронным до ответа 200 (как уже сделано для delete), либо явно задокументировать «best-effort» семантику и снять претензию на SQS-совместимость по надёжности.
P1 — архитектурная зрелость
- P1.1. Разнести блокировку. Файлы:
globals.go, все gosqs-хендлеры. Перейти на per-queue mutex (map под коротким RLock + своя блокировка вQueue). Снимает главный боттлнек и O(N)-скан под глобальным локом. - P1.2. Индексировать сообщения. Файлы:
models.go. Хранитьmap[receiptHandle]/ структуры готовности, чтобы receive/delete/visibility были не O(N). - P1.3. Реализовать
MessageRetentionPeriod. Файлы:gosqs.go(processQueueTick). Эвиктить просроченные сообщения по retention в периодической задаче + чистить Redis. - P1.4. Индекс тенанта → его очереди. Файлы:
tenant_helpers.go,admin.go. Держатьmap[accessKey]set(queueName)— убрать O(total) скан-по-префиксу в create/list/delete/gauge. - P1.5. Убрать demo-bypass из прод-сборки. Файлы:
seed.go,admin.go. Вынести за build-tag или удалить веткуauthenticateUIDemoTokenи фиксированные креды.
P2 — надёжность, корректность, чистка
- P2.1. BYOC Admin API (уже TODO в
byoc-credentials.md). Добавитьaccess_key/secret_keyвcreateTenantRequest+ ветку наCreateFixedвadmin.go.generateTenantIDэкспортировать из tenant-пакета. - P2.2. Чинить сиротство очередей при пересчёте legacy-тенанта —
tenant_store.go: либо мигрировать ключи очередей на новый AccessKey, либо запретить смену ключей. - P2.3. Constant-time сравнение admin-токена —
admin.go. - P2.4. Единый путь создания/отправки — admin-хендлеры должны переиспользовать gosqs-логику (FIFO/размер/MD5/дедуп), а не дублировать с иными дефолтами.
- P2.5. Биллинг: батч-вставки через воркер/буфер вместо горутины-на-операцию; корректный
msg_countдля batch (billing.go,router.goactionHandler). - P2.6. Защитить/урезать
/metrics(auth или internal-only), чтобы не светить per-tenant данные. - P2.7. Чистка легаси: убрать SNS Topics/Subscriptions из
goaws.yaml, мёртвый путь загрузки yaml-очередей без tenant-префикса вconfig.go, SNS-поля в моделях,deployments/**. - P2.8. Redis reconnect/health — сейчас после обрыва
Clientостаётся не-nil, записи молча падают в лог (redis.go).
3. Противоречия док ↔ код
- Идентичность JWT-тенанта.
tenant-isolation.mdутверждает: тенант создаётся из claimsub, TenantID =SHA256(sub) → "sless-{16hex}", индексbySub/GetBySub. Код: идентичность поemail, TenantID =t-{16hex}из email, индексbyEmail/GetByEmail(tenant_store.go). Доковое описание полностью устарело. - Генерация SecretKey. Док: «SecretKey = 32 random bytes». Код: для email-тенантов SecretKey детерминированный
SHA256(email+соль)— не случайный. - Версии (дрейф). README deploy-примеры:
v0.1.25; раздел «История» в README — доv0.1.14;HANDOFF— продv0.1.35. Три разных значения. NUBES_ENDPOINT. README: дефолтhttps://deck-api.ngcloud.ru/api/v1. Код: стендыhttps://lk-api-gateway*.ngcloud.ru/api/v1/svc(jwt.go). Разные хосты/пути.- BYOC статус.
byoc-credentials.mdчестно помечает Admin API/UI как TODO — совпадает с кодом (вcreateTenantRequestтолькоName,MaxQueues). Это не противоречие, а подтверждение открытого вопроса №1. - Метрики.
billing-and-metrics.md: примерhttp://localhost:9090/metrics, тогда как сервис слушает4100и отдаёт/metricsна основном порту (router.go). Также ссылка наdeployments/k8s/vmservicescrape.yaml, помеченный в README как легаси «не использовать». - Дерево в README перечисляет
auth/ — AWS Signature V4 верификация, но не упоминает JWT-путь,billing/,metrics/. Неполно относительно фактической структуры. /metrics → PUBLICв tenant-isolation.md соответствует коду, но сам факт публичности per-tenant метрик в доке не отмечен как риск.
Заключительная реплика Opus: «Если нужно — могу превратить это в файл-отчёт в doc/thinking/ или углубиться в любой из пунктов (например, расписать PoC-эксплуатацию P0.1/P0.2 или конкретный дизайн per-queue-локов). Код я не трогал, как и требовалось.»