Author SHA1 Message Date
cairnr01probe 32b6cd3bf2 chore: update ignore rules 2026-10-27 14:15:14 +03:00
cairnr01probe 78646cbf24 chore: update ignore rules 2026-10-27 14:08:52 +03:00
69 changed files with 6137 additions and 4614 deletions
+4 -1
View File
@@ -1,4 +1,8 @@
# Бинарники (только в корне проекта, не директории cmd/) # Бинарники (только в корне проекта, не директории cmd/)
/iot-operator
/mqtt-bridge
/kafka-consumer
/sqs-consumer
bin/ bin/
ws-probe/ws-probe ws-probe/ws-probe
/iot-service /iot-service
@@ -9,7 +13,6 @@ ws-probe/ws-probe
*.out *.out
*.prof *.prof
*.cover *.cover
*.key
# Python — кэш и генерируемые файлы # Python — кэш и генерируемые файлы
__pycache__/ __pycache__/
+2 -2
View File
@@ -1,5 +1,5 @@
# Dockerfile — монолит iot-service (один бинарник, один контейнер). # Dockerfile — монолит iot-service (один бинарник, один контейнер).
# Образ: gitea.services.ngcloud.ru/nail/iot-service (Gitea registry платформы). # Образ: naeel/iot-service (Docker Hub, публичный — требование платформы).
# #
# Env-дефолты (не секреты) вшиты сюда — deck-UI задаёт только секреты. # Env-дефолты (не секреты) вшиты сюда — deck-UI задаёт только секреты.
@@ -24,7 +24,7 @@ USER 65532:65532
EXPOSE 9090 EXPOSE 9090
ENV API_PORT=9090 \ ENV API_PORT=9090 \
SQS_ENDPOINT=https://sqs.sinefo.ru \ SQS_ENDPOINT=http://containerk8s.f1ffb134-7d16-45bd-8bef-69f6ec8ab33c.svc.cluster.local:4100 \
SQS_QUEUE_NAME=iot-telemetry \ SQS_QUEUE_NAME=iot-telemetry \
SQS_REGION=us-east-1 \ SQS_REGION=us-east-1 \
SQS_LONG_POLL_SECONDS=20 \ SQS_LONG_POLL_SECONDS=20 \
-102
View File
@@ -1,102 +0,0 @@
# Журнал сессии — 2026-10-10 (IoT: чистка легаси → ветка master → ревью → фиксы)
**Рабочая папка:** /home/naeel/nubes/SQS_und_IoT/IoT
**Ветка:** `master` (переименована из `feature/replace-kafka-with-sqs`)
**Состояние на конец сессии:** 28 коммитов сверх `origin/master`, **не запушено** (пуш — только по команде).
---
## 1. Чистка легаси
Цель владельца: «старое ГАВНО не всплывало».
- 🗑 Удалён код-кластер старого IoT (не импортировался монолитом, не собирался):
`cmd/iot-operator/`, `cmd/mqtt-bridge/`, `controllers/`, `api/v1alpha1/`,
`internal/api/` (старый REST + UI-embeds admin/console). Коммит `f152f54`.
- `go mod tidy` убрал k8s-зависимости (`k8s.io/*`, `controller-runtime`) — −57 строк `go.mod`, −614 `go.sum`.
- 🗑 `bin/` со старыми бинарниками (operator/kafka-consumer/mqtt-bridge).
- `iot-admin.html` (живой `/iot-admin`): секции Kafka/K8s Pods → «Очередь shared-SQS» + «Рантайм»; удалён CSS `.pod-*/.badge-*/.dot-*/.lag-bar*`. Коммит `42f0d6a`.
- `.gitignore`: убраны сталые пути бинарников.
- `LEGACY.md` обновлён (помечено `УДАЛЁН 2026-10-10`).
Осталось на диске вне git: `iot-console.html` со **старыми доменами** `sless.kube5s.ru` / `iot.kube5s.ru` (см. §6, требует решения владельца — какой домен).
## 2. Ветка → master, коммит/пуш
- SQS-service: `main` → `master`; IoT: `feature/replace-kafka-with-sqs` → `master` (старая `master` перезаписана, её 3 коммита влиты merge'ом, конфликт только в `.gitignore`).
- Пуш в Gitea (`gitea.services.ngcloud.ru/Nail/{SQS-service,IoT}`): `origin/master` создан/обновлён. Force не использовался.
## 3. Правки кода по ревью Opus
### Блокеры Terraform (Этап 2)
- `632e551` MQTT-пароль устройства отдаётся один раз в 201 + `phase` пишется в БД (Create).
- `a6ef9f9` `created_at` → RFC3339 + перечитывание после Create.
- `7e3828f` `telemetry_topic` + нормализация `metadata` (без drift `{}`/null).
- `7ea4923` валидация `namespace/name/device_id` (`^[a-zA-Z0-9][a-zA-Z0-9-]*$`, запрет `_ + # /`).
### Потери данных (Этап 1)
- `046db68` 🔴1 сериализация flush (`flushMu`) — Flush дожидается in-flight записи.
- `e0cd1ca` 🔴2 построчный fallback (ядовитая строка не роняет весь батч).
- `006dbfa` 🔴2 семантическая валидация envelope (пустые поля → DLQ).
- `01d21f6` 🔴3 слив буфера bridge при shutdown + безопасное закрытие канала.
- `241c98a` 🔴4 ретраи SQS до shutdown + таймаут на попытку.
- `69ae2ff` 🔴6 не рвать автореконнект (ретрай подписки, без `Disconnect`).
- `530ee7b` 🔴7 ретрай резолва DLQ + не терять битые при недоступной DLQ.
- `47d9a04` 🔴5 ретрай GetQueueUrl на старте + выход ≠0 при сбое подсистемы.
## 4. PRO-ревью (моё) — находки и фиксы
Разобрал работу предыдущей модели, нашёл 3 🔴 + 🟡/🟢:
- `98e6def` **#1** `os.Exit` обходил `defer iotStore.Close()` → финальный flush терялся. Вынес логику в `run() int`, `main = os.Exit(run())`.
- `6e4a844` **#2** «не подтверждать битое при недоступной DLQ» = ядовитое зацикливается. Добавлено чтение `ApproximateReceiveCount` (после `maxReceiveCount=5` — дроп).
- `c1733c5` **#3** `/ready` = «горутина запущена», а не «подписан». Bridge сам управляет флагом через `onSubscribed`.
### Этап 4 (изоляция/JWT)
- `ea569da` JWT: обязательный `exp`, 401 вместо 403, guard тест-режима, JSON-ошибки.
- `2bea1de` `replaceDSNDatabase` (устранено смешение тенантов: ошибка для key=value DSN, корректный разбор `sslrootcert=/path`) + guard namespace (`safeNamespaceRe`, `ErrInvalidNamespace`).
### Этап 3 (жизненный цикл)
- `75c30e5` readiness consumer по SQS (`onReady`), ограничение `wg.Wait` (`waitWithTimeout`, 15с), идемпотентный `Close` (`sync.Once`).
### Этап 5 (мелочи)
- `f37f216` удалён мёртвый `mqtt_auth.go` + constant-time админ-токен.
- `fa98020` `decodeJSON` (MaxBytesReader 64КиБ + DisallowUnknownFields) + таймауты сервера.
- `d7cb850` `DeleteMessageBatch` (до 10 за вызов).
- `1510efe` пагинация: tiebreaker `ts DESC, id DESC` + потолок `limit` + точный лог.
- `11bb38e` `MaxReconnectInterval` 30с + Debug для health-проб/телеметрии.
### Terraform / безопасность
- `eacc740` PATCH обновляет `metadata`.
- `0a51d70` 409 различает конфликт `name` vs `device_id` (`ErrDeviceIDExists`).
- `c1adb26` **P0-3** bcrypt-хэш паролей устройств и бриджа + EMQX `name=bcrypt, salt_rounds=10` (ключ `salt_rounds` подтверждён по исходникам emqx/emqx; Go `DefaultCost=10`).
### Тесты / доки
- `46744ac` юнит-тесты: `validateIdentifier` (handler), `replaceDSNDatabase` + `safeNamespaceRe` (iotpg).
- `1600339` деплой-дока: пароль отдаётся один раз, в БД bcrypt.
## 5. Повторное PRO-ревью — открытые находки
- 🟠 **ReceivedAt — точность до секунды.** `bridge/handler.go` пишет `time.RFC3339`, `consumer/processor.go` парсит `time.RFC3339`; субсекунды теряются. Очередь standard (не FIFO) → два показания в одну секунду могут встать не в том порядке; tiebreaker `id DESC` не чинит (id = порядок SQS-доставки). **Фикс: RFC3339Nano на обеих сторонах.**
- 🟡 Пустое тело (`msg.Body == ""`) подтверждается без DLQ — для консистентности тоже в DLQ.
- 🟡 `processBackoff`-sleep внутри цикла батча задерживает подтверждение соседей → redelivery (спасают дедуп + фоновый flusher). Пре-существующий дизайн.
- ⚠️ Валидация `namespace`/`exp` зависят от формата `sub`/токена платформы — не проверено.
- ⚠️ `flushMu` — глобальная сериализация (осознанный компромисс 🔴1 vs throughput).
- ⚠️ Гонки не прогнаны под `go test -race`.
## 6. Осталось (не сделано)
**Хвосты доделаны 2026-10-11:** `18cdea7` RFC3339Nano; `250bbdf` пустое тело → DLQ;
`49422ef` домены консоли (apiBase → origin, mqttBase → exqx…); `d195629` race-тест dispatcher.
Осталось:
- Проверить формат `sub` платформы (валидация namespace + JWT `exp`) — **проверка**, не код.
- Тесты на `flushAll`/`Close` (требуют мока PG/SQS; `go test -race` прогнан, dispatcher покрыт).
- Terraform-провайдер (следующая большая задача после IoT).
- SQS-хвосты (`CreateQueue`→`QueueNameExists`, `ListQueues`, JWT по `sub`, лимит по байтам).
## Правила (напомню себе)
- Коммитить после каждой правки; пуш — только по команде.
- Документировать всё, включая ошибки (HISTORY/ + doc/thinking/).
- Не задавать вопросов, ответ на которые есть в коде/доках.
- «Рекомендация без замера — не рекомендация».
-288
View File
@@ -1,288 +0,0 @@
# Журнал сессии — 2026-10-11 (IoT: внешнее код-ревью и фиксы)
> Запись по требованию «документировать всё, включая собственные ошибки» (приказ 2026-09-24).
> Предыдущие: `HISTORY/2026-10-07-session-log.md`, `HISTORY/2026-10-10-session-log.md`.
> Основание: внешнее ревью Claude Sonnet по промпту `../CLAUDE-REVIEW-PROMPT.md`
> (40 пунктов на два сервиса; SQS-часть — в `SQS-service/HISTORY/2026-10-11-session-log.md`).
## 1. Задача
Разобрать находки ревью по монолиту `iot-service` и исправить их.
## 2. Что исправлено
### 🔴 Критичные
- **🔴8/🔴9 — poison-строка в `flushAll`** (`internal/storage/iotpg/iot_telemetry_store.go:166,177`).
При постоянной (не retryable) ошибке `flushAll` возвращал ошибку → consumer
(`consumer.go:159`) не удалял батч из SQS → бесконечный redelivery всего батча.
Теперь ошибку ставит только retryable-ветка; постоянная ошибка означает «валидные
строки уже вставлены построчно, битые отброшены» → `Flush` возвращает nil, батч
подтверждается. Коммит `1fe543f`.
### 🟡 Важные
| Пункт | Файл | Суть | Коммит |
|-------|------|------|--------|
| 🟡13 | `bridge/sender.go` | Ретраил бесконечно любые ошибки SQS; permanent (4xx/размер/нет очереди) занимали воркеры → классификация `isPermanentSQSErr` (smithy APIError) | `657850d` |
| 🟡14 | `bridge/handler.go` | `MessageID = uuid` на каждый колбэк → повторная QoS1-доставка не дедуплицировалась. Теперь `SHA256(ns\|device\|topic\|payload)` | `657850d` |
| 🟡15 | `bridge/bridge.go` | После 3 неудач подписки ждали следующего реконнекта (которого не будет при живом соединении) → бесконечный ретрай подписки | `657850d` |
| (new) | `bridge/handler.go` | Defense-in-depth: валидация формата `ns`/`deviceID` из топика (барьер — EMQX authz) | `657850d` |
| 🟡17 | `consumer/consumer.go` | `receiveCount` проверялся только для битого envelope → применяем `maxReceiveCount` ко всем ошибкам | `5114320` |
| 🟡18 | `cmd/iot-service/main.go` | `run()` выходил до завершения `server.Shutdown` → deferred Close срабатывали до дренажа in-flight. Добавлен `shutdownDone` | `5114320` |
| 🟡19 | `api/handler/devices.go` | Ошибка `GetByName` после успешного INSERT давала 500 (пароль терялся, повтор → 409) → отдаём 201 с in-memory объектом | `5114320` |
| 🟡11 | `iotpg/iot_telemetry_store.go` | `getTenantDB`/`QueryTelemetry` не проверяли `safeNamespaceRe` (`a_b` ↔ `a-b` → одна БД) → валидация на всех путях | `f41a9fe` |
### Осознанно НЕ менялось — РЕШЕНИЯ (2026-10-11)
- 🟡16 (QoS1 ack): **решение — оставить async-ack + метрика дропов.** Синхронный
ack (подтверждать только после отправки в SQS) блокировал бы MQTT-колбэк при
недоступности SQS — это уже чинили ранее (потеря ВСЕХ сообщений). Throughput
важнее; дроп при переполнении фиксируется счётчиком `dropped`.
- 🟡12 (`AUTH_TEST_MODE`): **решение — оставить как есть.** Уже fail-closed: при
отсутствии `JWT_HMAC_SECRET` и `AUTH_TEST_MODE=false` middleware отказывает в
обслуживании (500), а test-mode срабатывает только без секрета.
## 3. Прочее (перед ревью, в этой же сессии)
- `193509c` — консоль: namespace = `sub` (убрали `sless-{SHA256}`, расходившийся с
middleware) и удалили несуществующий `POST /v1/namespaces/{ns}/ensure`.
## 4. Верификация
- `go build ./...`, `go vet ./...`, `go test -race ./...` — зелёные.
- Push: `54fe1f5..f41a9fe` в `origin/master`.
## 5. Деплой на Nubes (Terraform) — образ EMQX, 2026-10-11
`terraform apply` упал на `nubes_http.iot_emqx` через ~70 с (стадия validation,
22.0 с): платформа требует РОВНО ОДИН `EXPOSE` в образе, иначе
«Не указан applicationUsePort…». У `emqx/emqx:5.5.1` — 7 EXPOSE
(18083/1883/4370/5369/8083/8084/8883). Параметра `applicationUsePort` у
провайдера nubes 3.1.0 нет (проверено: `generated/prod/resources_yaml/98_http.yaml`
+ `generated/prod/docs-rendered/http.md` + исходники провайдера — совпадений нет),
поэтому лечится только пересборкой образа.
Что сделано (образ `nail/iot-emqx:v0.2.5`):
1. `emqx/Dockerfile` — multi-stage: `FROM emqx/emqx:5.5.1 AS base` →
`FROM scratch` → `COPY --from=base / /`. EXPOSE/ENV/VOLUME наследуемого образа
НЕ переносятся в `scratch`, поэтому остаётся единственный `EXPOSE 8083`.
ENV `LANG`/`LC_ALL`/`PATH` объявлены заново. `USER emqx` + `WORKDIR /opt/emqx`
+ штатные `ENTRYPOINT`/`CMD` базы.
- ⛔ Попытка `FROM debian:bookworm-slim` + `COPY --from=base / /` НЕ работает:
`cannot copy to non-directory: /bin` — в bookworm `/bin` симлинк (usrmerge),
в emqx-образе (Debian 11) `/bin` — реальный каталог.
2. `emqx/emqx.conf` — удалён `salt_rounds = 10` из `password_hash_algorithm`:
схема 5.5.1 его НЕ принимает (`unknown_fields, unknown => "salt_rounds"` →
`call_hocon_failed`, контейнер не стартует). Роль поля нулевая: EMQX только
сверяет bcrypt-хеш из БД, cost берётся из самого хеша.
Верификация образа (локально):
- `docker inspect … --format '{{json .Config.ExposedPorts}}'` → `{"8083/tcp":{}}`;
- запуск контейнера → `Listener tcp:default on 0.0.0.0:1883 started`,
`Listener ws:default on 0.0.0.0:8083 started`, `EMQX 5.5.1 is running now!`,
статус `running`. Ошибки `econnrefused` к `127.0.0.1:5432` в тесте ожидаемы
(PG рядом нет).
- Реестр Gitea: теги `latest`, `v0.2.4`, `v0.2.5`.
- `terraform.tfvars` / `terraform.tfvars.example`: `iot_emqx_version = "v0.2.5"`.
⚠️ Блокер деплоя: инстанс `iot-emqx` (`3bdaef8e-e880-4419-870a-bd0158826751`)
остался в состоянии `not created` после неудачного apply и держит имя. По
документации платформы такие инстансы через API не удаляются — удалять вручную
в ЛК (deck). `terraform state list` пуст (в state ничего не записалось).
## 6. Второй `terraform apply` — падение iot-service и РАЗБОР через кластер
Инстанс-блокер был удалён в ЛК владельцем (`3bdaef8e…` → 404). Повторный apply:
EMQX создался и работает, `nubes_http.iot_service` упал через ~10 мин:
`Приложение не запустилось. Производится полный откат установки. Ошибка:
jlib.k8s [correctReplicaActive] | Под(ы) не работают: 'containerk8s' (Deployment)`,
затем таймаут клиента 600 с. Namespace iot-service откатан и удалён.
Разбор (kubeconfig `~/.kube/config` → кластер `iot-naeel`, контекст
`tazetdinovn@gmail.com@iot-naeel`):
1. **Причина падения — PG, а не образ.** Эфемерный диагностический под
(`kubectl run iot-diag-bad`, env — как в tfvars, удалён после проверки):
```
level=ERROR msg="devices store" err="store: init schema: pq: must be owner of table iot_devices (42501)"
exit=1 reason=Error
```
Приложение падает на `CREATE UNIQUE INDEX IF NOT EXISTS` (`internal/service/store/open.go:64`
+ SQL на `:29`): таблица уже существует и принадлежит ДРУГОЙ роли.
2. **Владелец.** В БД `iotdb` инстанса `509145c3…` таблицы `iot_devices`,
`iot_devices_pkey`, `idx_iot_devices_ns_device`, `tenant_credentials` принадлежали
роли **`iot_service`** (создана 2026-08-16, пароль утерян — нет ни в vault
платформы, ни в `IoT/pg.key`). Роль `iotservice` из tfvars — не владелец и без
прав: `has_table_privilege('iotservice','iot_devices','SELECT')=false`.
Данные: `iot_devices` 26 строк (в т.ч. `__bridge/iot-bridge`), `tenant_credentials` 31.
3. **Вторая ошибка (сработала бы следующей).** `MQTT_HOST` из
`local.emqx_internal_host` = `iot-emqx.<uid>.svc.cluster.local` — **NXDOMAIN**.
Service в namespace платформы называется `containerk8s`:
`containerk8s.<uid>.svc.cluster.local` → 10.98.42.138 ✅ (проверено `getent hosts`
изнутри пода EMQX; тот же формат — `HISTORY/2026-08-16-session-log.md:824`).
### Исправление (2026-10-11)
- **PG** (через `psql` в поде `postgresqlk8s-0`, роль `postgres`):
`BEGIN; ALTER TABLE public.iot_devices OWNER TO iotservice; ALTER TABLE public.tenant_credentials
OWNER TO iotservice; ALTER INDEX public.iot_devices_pkey OWNER TO iotservice;
ALTER INDEX public.idx_iot_devices_ns_device OWNER TO iotservice; COMMIT;`
Пароли не менялись, БД не создавались, env EMQX не менялся.
- `deploy/terraform/resources.tf` — `emqx_internal_host` → `containerk8s.<instanceUid>`
(было `resource_name`).
### Перепроверка после правки (обязательна)
- Владельцы в PG: все 4 объекта → `iotservice` ✅
- DDL приложения под `iotservice` по TCP (`sslmode=require`), ровно тот же SQL:
`CREATE TABLE … / CREATE UNIQUE INDEX IF NOT EXISTS …` → `exit=0`, без 42501 ✅
- Данные целы: `iot_devices` 26 строк, `tenant_credentials` 31 ✅
- Диагностический под `iot-diag-ok` с `MQTT_HOST=containerk8s.<uid>`: `devices store ready`,
`bridge device ensured`, `telemetry store ready`, `bridge: MQTT connected`,
`bridge: subscribed filter=+/telemetry/+`; `/health`=200, `/ready`=200 `{"status":"ready"}` ✅
- `terraform validate` Success, `terraform plan` = **1 to add, 0 to change, 0 to destroy**
(EMQX не меняется — его env правки не требует); `terraform console local.emqx_internal_host`
= `containerk8s.0d09ee82-c8f9-458f-88a3-89571e5676ed.svc.cluster.local` ✅
- Диагностические поды удалены, в ns EMQX остались только `containerk8s` и `vmagent`.
### Правка дефолтов конфига (2026-10-11, после разбора)
- `internal/service/config/defaults.go` + `Dockerfile`: дефолт `SQS_ENDPOINT` был
`http://containerk8s.f1ffb134-…:4100` (устаревший namespace — shared-sqs давно
на ВМ 213) → заменён на `https://sqs.sinefo.ru`.
- `DefaultMQTTHost` был `"emqx"` (не резолвится: EMQX в другом namespace) → пусто,
`MQTT_HOST` стал обязательным: `config.Load` добавляет его в список `missing`
(`MQTT_HOST (или MQTT_BROKER_URL)`), а не строит кривой `ws://:8083/mqtt`.
- В нашем деплое оба значения всё равно задаются Terraform'ом (`resources.tf`) —
правка убирает ловушку для деплоя без tfvars. `go build/vet/test` зелёные.
## 7. Деплой IoT на Nubes — ✅ ГОТОВО (2026-10-11, третий apply)
`terraform apply` прошёл успешно. Итог:
- **EMQX** (`nubes_http.iot_emqx`): инстанс `0d09ee82-c8f9-458f-88a3-89571e5676ed`,
домен `https://exqx-iot.containerk8s.dev.nubes.ru`, под `containerk8s` 1/1 Running.
- **iot-service** (`nubes_http.iot_service`): инстанс `6d78ebc3-bb90-4af9-8d2f-61186e04ee19`,
домен `https://iot-api.containerk8s.dev.nubes.ru`, под `containerk8s` 1/1 Running, 0 рестартов.
Проверено после apply:
- логи старта: `devices store ready` → `bridge device ensured` → `telemetry store ready` →
`iot-service listening :9090` → `bridge: MQTT connected` → `bridge: subscribed filter=+/telemetry/+`
→ `consumer/bridge: SQS queue resolved queue=iot-telemetry dlq=iot-telemetry-dlq`;
- публично: `/health`=200, `/ready`=200; в логе `GET /`=200, `GET /static/favicon.svg`=200 (консоль).
- `terraform state list` = `nubes_http.iot_emqx` + `nubes_http.iot_service`;
outputs: `emqx_url`, `iot_service_url`, `iot_mqtt_broker_url`.
Осталось (не блокеры): end-to-end телеметрия (устройство → MQTT → SQS → per-tenant PG),
домен EMQX `wss://exqx-iot/mqtt` для внешних устройств, Terraform на PROD.
## 8. E2E-тест телеметрии — ✅ ПРОЙДЕН (2026-10-11)
Проверен полный путь снаружи кластера: HTTP API → MQTT (`wss`) → EMQX → bridge → SQS →
consumer → per-tenant PostgreSQL → HTTP API.
1. **Устройство через API** (`/tmp/e2e_api.py`, PyJWT HS256 `sub=<ns>`):
`POST /v1/namespaces/e2e-nubes/iot/devices {"name":"dev1","device_id":"d-01"}` → **201**,
выданы `mqtt_username=e2e-nubes_d-01`, `mqtt_password=347425f2…753e`,
`telemetry_topic=e2e-nubes/telemetry/d-01`.
⚠ `GET …/devices/dev1` пароль НЕ возвращает (как и задокументировано ранее).
2. **Публикация через внешний MQTT** `wss://exqx-iot.containerk8s.dev.nubes.ru/mqtt`
(`/tmp/e2e_mqtt.py`; MQTT-over-WS написан вручную на stdlib — локально нет
paho/websockets): `HTTP/1.1 101 Switching Protocols` → **`CONNACK rc=0`**
(auth прошёл: bcrypt-хеш сверен EMQX напрямую с `iot_devices`) → `PUBLISH
e2e-nubes/telemetry/d-01 {"temp":21.5,…}`.
ACL: разрешён только свой топик — публикация в него не отклонена.
3. **Доставка**: лог iot-service — `iotpg: created tenant DB namespace=e2e-nubes
db=tenant_e2e_nubes`; в `iotdb` появилась строка `e2e-nubes/dev1 (d-01)`.
4. **Чтение**: `GET /v1/namespaces/e2e-nubes/iot/telemetry?device_id=d-01` → **200**
`{"count":1,"items":[{"id":1,"device_id":"d-01","payload":{"ts":1791737028,
"src":"e2e-manual","temp":21.5}}]}`; в PG `tenant_e2e_nubes.iot_telemetry` —
1 строка, `payload` совпадает побайтово, `message_id=30cf2047a060…` (SHA256-дедуп).
Следствия: внешний MQTT-домен EMQX годится для устройств; SQS-креды, PG-DSN, ACL и
дедуп работают. Тестовые артефакты: устройство `e2e-nubes/dev1`, БД `tenant_e2e_nubes`.
Осталось (не блокеры): Terraform на PROD; чистка тестовых артефактов e2e (по команде).
## 9. Проверки безопасности/корректности развёрнутого IoT — PASS=12 FAIL=0
Скрипт `/tmp/e2e_neg.py` (тест-стенд, namespace `e2e-nubes`, устройство `dev1`):
| # | Проверка | Результат |
|---|---|---|
| 1 | MQTT auth: верные креды | `CONNACK rc=0` ✅ |
| 2 | MQTT auth: неверный пароль | `rc=4` (bad user/password) ✅ |
| 3 | MQTT auth: неизвестный пользователь | `rc=5` (not authorized) ✅ |
| 4 | API без токена | `401` ✅ |
| 5 | API: `sub` ≠ namespace в пути | `403` ✅ |
| 6 | API: подделанная подпись JWT | `401` ✅ |
| 7 | MQTT ACL: публикация в чужой топик `other-ns/telemetry/x` | соединение разорвано (`deny_action=disconnect`) ✅ |
| 8 | Дедуп: два одинаковых payload | `iot_telemetry` 1→2 (+1, не +2), `message_id` SHA256 ✅ |
| 9 | `PATCH enabled=false` | `200` ✅ |
| 10 | MQTT auth выключенного устройства | `rc=5` ✅ |
| 11 | `PATCH enabled=true` | `200` ✅ |
| 12 | MQTT auth после включения | `rc=0` ✅ |
Вывод: auth (bcrypt из PG), ACL (own-topic only, disconnect на нарушение), tenant-изоляция API
(`namespace == sub`), проверка подписи JWT и дедуп повторов — работают на развёрнутом стенде.
## 10. Расширенный прогон — PASS=49 FAIL=0 KNOWN=1 (2026-10-11)
Скрипт `/tmp/e2e_suite.py` (~50 проверок на развёрнутом стенде).
**Что покрыто:** CRUD устройств (201/409/400/404/204) · лимиты идентификаторов
(name/device_id 64 → 201, 65 → 400; namespace 56 → 201, 57 → 400) · пагинация
(limit/offset, потолок 1000) · JWT (без токена 401, `sub`≠namespace 403, чужой
ключ 401, истёкший 401, не-HS256 401) · MQTT (SUBACK на свой топик, round-trip,
QoS1 → PUBACK, PINGRESP) · ACL-отказы с разрывом соединения (чужое устройство,
wildcard, чужой namespace, 11 уровней топика при `max_topic_levels=10`, retained
при `retain_available=false`, subscribe на `+/telemetry/+` и чужой ns) ·
payload (не-JSON → строка, 200 КБ → доставлен, 20 сообщений подряд → 20/20) ·
служебное (`/health`, `/healthz`, `/ready`, `/console`, `/iot-admin`, `/` = 200;
`POST /health` = 405; `/iot-admin/stats` без токена 401).
**Найдено/подтверждено (требует решения):**
1. 🟡 **Дедуп — по СОДЕРЖИМОМУ.** `message_id = SHA256(ns|device_id|topic|payload)`
(`bridge/handler.go:73`), поэтому два одинаковых payload подряд дают +1 строку
(проверено: 49→50). Для реальных устройств это значит: если payload не содержит
timestamp/счётчика, легитимные повторные одинаковые показания будут отброшены.
Надо предупредить в документации для устройств (или включать ts в payload).
2. 🟡 **Валидация namespace только в POST.** `validateIdentifier(ns, "namespace", 56)`
вызывается только в `CreateIoTDevice` (`devices.go:168`); `GET/DELETE/PATCH`
принимают любой ns → `200` (проверено: `GET /v1/namespaces/bad_ns/iot/devices` → 200).
Хранилище такой ns отсекает (`safeNamespaceRe`), поэтому чтение телеметрии
отдаёт `500` вместо `400`. Утечки нет (изоляция по `sub`), но реакция
несогласованная — вынести проверку в общий middleware/хелпер.
3. ⚪ `Dockerfile` (комментарий) обещает `/metrics` на порту API — маршрута нет (404).
4. ⚪ Чтение телеметрии `?limit=200` возвращает payload'ы целиком (в тесте — 200 КБ
на запись); при массовом опросе API ощутимо тормозит (наблюдалось на 200m CPU).
Методическая заметка: при повторных прогонах тесты «доставки» надо делать с
уникальным payload, иначе срабатывает дедуп и тест ложно падает.
## 11. Правки по итогам §10 (2026-10-11)
1. **Валидация namespace во всех эндпоинтах.** Добавлены хелперы
`namespaceFromPath` / `requireNamespace` (`handler.go`); `ListIoTDevices`,
`GetIoTDevice`, `DeleteIoTDevice`, `UpdateIoTDevice`, `ListIoTTelemetry`
теперь отвечают `400` на невалидный `{namespace}` (раньше отдавали 200, а
телеметрия падала в хранилище с 500). `CreateIoTDevice` оставлен с прежним
порядком (JSON → валидация), текст ошибки не изменился.
Регрессионные тесты: `internal/service/api/handler/namespace_test.go`
(8 невалидных ns × 5 обработчиков; пустой ns → `default`).
`go build/vet/test -race ./...` — зелёные.
2. **Документ для интеграторов:** `doc/device-integration.md` — регистрация
устройства, MQTT (`wss`, username/topic), ACL-ограничения,
⚠️ **требование ts/seq в payload** (дедуп по содержимому), лимиты
(1 МБ пакет, 250 КБ envelope), QoS1, чтение телеметрии, реальная задержка
«MQTT → API» до ~20 с (long-poll консьюмера).
Собрано и опубликовано: образ `iot-service:v0.1.14`
(`latest`, `v0.1.13`, `v0.1.14` в Gitea). Проверено живым подом `iot-verify`
(удалён): `/health` → 200 `v0.1.14`; `bad_ns` для списка/телеметрии/карточки →
`400 {"error":"namespace must match [a-zA-Z0-9][a-zA-Z0-9-]* …"}`; валидный
`e2e-nubes` → 200. `terraform plan` = **0 to add, 1 to change** (registry_path
`v0.1.13` → `v0.1.14`). Применение — за владельцем.
+4 -10
View File
@@ -19,14 +19,8 @@
- `legacy/2026-08-13-migration-to-managed.md` — СТАРЫЙ план миграции (Node.js) — ОТМЕНЁН - `legacy/2026-08-13-migration-to-managed.md` — СТАРЫЙ план миграции (Node.js) — ОТМЕНЁН
- `legacy/Dockerfile.old` — собирал 3 старых бинарника - `legacy/Dockerfile.old` — собирал 3 старых бинарника
- `legacy/Makefile.old` — старые k8s-цели - `legacy/Makefile.old` — старые k8s-цели
- ⛔ `cmd/iot-operator/`, `controllers/`, `api/v1alpha1/` — оператор (CRD-контроллер): - `cmd/iot-operator/`, `controllers/`, `api/v1alpha1/` — оператор (CRD-контроллер);
**УДАЛЁН 2026-10-10** — не импортировался активным монолитом, не собирался. пока в корне из-за импортов (перенос — во время Фазы 1 после переписывания хендлеров)
- ⛔ `internal/api/` — старый REST API + UI-embeds (admin/console): **УДАЛЁН 2026-10-10**
(активный API — `internal/service/api/`).
- ⛔ `cmd/mqtt-bridge/` — старый standalone bridge: **УДАЛЁН 2026-10-10** (заменён
`internal/service/bridge/`).
- k8s-зависимости (`k8s.io/*`, `controller-runtime`) — **УДАЛЕНЫ из go.mod 2026-10-10**
(`go mod tidy` после удаления оператора/CRD).
## Аудит вмешательства старого IoT (2026-08-16, проверено) ## Аудит вмешательства старого IoT (2026-08-16, проверено)
@@ -49,8 +43,8 @@
- `HISTORY/2026-08-16-session-log.md` - `HISTORY/2026-08-16-session-log.md`
- `doc/sqs-integration.md` - `doc/sqs-integration.md`
- `ws-probe/` - `ws-probe/`
- `cmd/iot-service/`, `internal/service/`, `internal/storage/iotpg/` — активный - `cmd/mqtt-bridge/`, `internal/` — Go-код, переиспользуется в новом монолите
монолит iot-service (после адаптации под платформу)
- ⛔ `cmd/sqs-consumer/` — **УДАЛЁН 2026-10-07**: standalone-консьюмер, который - ⛔ `cmd/sqs-consumer/` — **УДАЛЁН 2026-10-07**: standalone-консьюмер, который
НЕ собирался Dockerfile/Makefile (те билдят только `./cmd/iot-service/`); его НЕ собирался Dockerfile/Makefile (те билдят только `./cmd/iot-service/`); его
логика поглощена `internal/service/consumer/`. Был источником пустого логика поглощена `internal/service/consumer/`. Был источником пустого
+2 -4
View File
@@ -1,10 +1,8 @@
# Makefile — монолит iot-service (образ gitea.services.ngcloud.ru/nail/iot-service). # Makefile — монолит iot-service (образ naeel/iot-service).
# Registry: Gitea (gitea.services.ngcloud.ru) — managed-платформа тянет образы отсюда.
# Старые k8s-цели — в legacy/Makefile.old. # Старые k8s-цели — в legacy/Makefile.old.
REGISTRY ?= gitea.services.ngcloud.ru/nail
VERSION ?= v0.1.13 VERSION ?= v0.1.13
IMAGE ?= $(REGISTRY)/iot-service IMAGE ?= naeel/iot-service
LDFLAGS = -X main.version=$(VERSION) LDFLAGS = -X main.version=$(VERSION)
.PHONY: build vet test tidy docker-build docker-push .PHONY: build vet test tidy docker-build docker-push
+102
View File
@@ -0,0 +1,102 @@
// ⛔⛔⛔ ЛЕГАСИ (2026-08-16) — СТАРЫЙ IoT (CRD-типы). В новой архитектуре заменяется таблицей iot_devices в PG.
// Актуальное: HISTORY/2026-08-16-session-log.md
// Создано: 2026-04-04
// Описание CRD IoTDevice — регистрация IoT-устройства в платформе sless.
// Пользователь создаёт IoTDevice, контроллер автоматически генерирует MQTT-credentials
// и сохраняет их в k8s Secret в том же namespace.
// При удалении IoTDevice — Secret удаляется каскадно через OwnerReference.
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// IoTDeviceSpec — желаемое состояние IoT-устройства.
type IoTDeviceSpec struct {
// DeviceID — уникальный идентификатор устройства внутри namespace.
// Используется как часть MQTT username и имени Secret.
// Разрешены только строчные буквы, цифры и дефис — для совместимости с k8s именами.
// +kubebuilder:validation:Required
// +kubebuilder:validation:Pattern=`^[a-z0-9][a-z0-9-]*[a-z0-9]$`
// +kubebuilder:validation:MaxLength=48
DeviceID string `json:"deviceId"`
// Metadata — произвольные метаданные устройства (модель, локация и т.д.).
// Хранятся только в CRD, не влияют на логику контроллера.
// +optional
Metadata map[string]string `json:"metadata,omitempty"`
// Enabled — активно ли устройство (может подключаться к MQTT).
// Если false — контроллер устанавливает phase=Disabled, EMQX auth отклоняет подключение.
// Secret с credentials НЕ удаляется — при re-enable пароль остаётся прежним.
// +kubebuilder:default=true
Enabled bool `json:"enabled"`
}
// IoTDevicePhase — текущая фаза жизненного цикла устройства.
type IoTDevicePhase string
const (
// IoTDevicePhaseActive — устройство активно, credentials выданы, может подключаться к MQTT.
IoTDevicePhaseActive IoTDevicePhase = "Active"
// IoTDevicePhaseDisabled — устройство отключено (spec.enabled=false). EMQX отклоняет подключения.
IoTDevicePhaseDisabled IoTDevicePhase = "Disabled"
// IoTDevicePhasePending — устройство создано, контроллер ещё не успел сгенерировать credentials.
IoTDevicePhasePending IoTDevicePhase = "Pending"
// IoTDevicePhaseError — ошибка при создании credentials (см. status.message).
IoTDevicePhaseError IoTDevicePhase = "Error"
)
// IoTDeviceStatus — наблюдаемое состояние IoT-устройства (заполняет контроллер).
type IoTDeviceStatus struct {
// Phase — текущее состояние: Active, Disabled, Pending, Error.
Phase IoTDevicePhase `json:"phase,omitempty"`
// MQTTUsername — имя пользователя для подключения к MQTT-брокеру.
// Формат: {namespace}_{deviceId} — глобально уникален в рамках EMQX.
MQTTUsername string `json:"mqttUsername,omitempty"`
// SecretName — имя k8s Secret в том же namespace, содержащего mqtt-username и mqtt-password.
SecretName string `json:"secretName,omitempty"`
// TopicPrefix — MQTT topic prefix, на который разрешена публикация.
// Формат: {namespace}/ — устройство не может публиковать в чужие namespace.
TopicPrefix string `json:"topicPrefix,omitempty"`
// LastConnected — время последнего MQTT-подключения устройства.
// Заполняется MQTT auth-сервисом при каждом успешном CONNECT.
// +optional
LastConnected *metav1.Time `json:"lastConnected,omitempty"`
// Message — человекочитаемое сообщение о текущем статусе или ошибке.
Message string `json:"message,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="DeviceID",type=string,JSONPath=`.spec.deviceId`
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
// +kubebuilder:printcolumn:name="Enabled",type=boolean,JSONPath=`.spec.enabled`
// +kubebuilder:printcolumn:name="MQTTUser",type=string,JSONPath=`.status.mqttUsername`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
// IoTDevice — ресурс для регистрации IoT-устройства в платформе.
// Контроллер автоматически создаёт k8s Secret с MQTT-credentials.
type IoTDevice struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec IoTDeviceSpec `json:"spec,omitempty"`
Status IoTDeviceStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
// IoTDeviceList — список IoTDevice объектов.
type IoTDeviceList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []IoTDevice `json:"items"`
}
+33
View File
@@ -0,0 +1,33 @@
// ⛔⛔⛔ ЛЕГАСИ (2026-08-16) — СТАРЫЙ IoT (CRD-типы). В новой архитектуре не используется.
// Актуальное: HISTORY/2026-08-16-session-log.md
// Создано: 2026-04-04
// groupversion_info.go — регистрация API group iot.kube5s.ru/v1alpha1.
// ВАЖНО: группа намеренно отдельная от sless.kube5s.ru — при выносе IoT в
// отдельную репу CRD не будет конфликтовать с sless CRD.
// Package v1alpha1 содержит API Schema definitions для группы iot.kube5s.ru.
// +kubebuilder:object:generate=true
// +groupName=iot.kube5s.ru
package v1alpha1
import (
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/scheme"
)
var (
// GroupVersion — API group/version для IoT ресурсов.
// Отдельный от sless.kube5s.ru — для изоляции и будущего разделения репозиториев.
GroupVersion = schema.GroupVersion{Group: "iot.kube5s.ru", Version: "v1alpha1"}
// SchemeBuilder регистрирует Go-типы в GroupVersionKind scheme.
SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
// AddToScheme добавляет типы этой API группы в scheme manager'а.
AddToScheme = SchemeBuilder.AddToScheme
)
func init() {
SchemeBuilder.Register(&IoTDevice{}, &IoTDeviceList{})
}
+109
View File
@@ -0,0 +1,109 @@
//go:build !ignore_autogenerated
// Code generated by controller-gen. DO NOT EDIT.
package v1alpha1
import (
runtime "k8s.io/apimachinery/pkg/runtime"
)
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *IoTDevice) DeepCopyInto(out *IoTDevice) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IoTDevice.
func (in *IoTDevice) DeepCopy() *IoTDevice {
if in == nil {
return nil
}
out := new(IoTDevice)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *IoTDevice) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *IoTDeviceList) DeepCopyInto(out *IoTDeviceList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]IoTDevice, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IoTDeviceList.
func (in *IoTDeviceList) DeepCopy() *IoTDeviceList {
if in == nil {
return nil
}
out := new(IoTDeviceList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *IoTDeviceList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *IoTDeviceSpec) DeepCopyInto(out *IoTDeviceSpec) {
*out = *in
if in.Metadata != nil {
in, out := &in.Metadata, &out.Metadata
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IoTDeviceSpec.
func (in *IoTDeviceSpec) DeepCopy() *IoTDeviceSpec {
if in == nil {
return nil
}
out := new(IoTDeviceSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *IoTDeviceStatus) DeepCopyInto(out *IoTDeviceStatus) {
*out = *in
if in.LastConnected != nil {
in, out := &in.LastConnected, &out.LastConnected
*out = (*in).DeepCopy()
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IoTDeviceStatus.
func (in *IoTDeviceStatus) DeepCopy() *IoTDeviceStatus {
if in == nil {
return nil
}
out := new(IoTDeviceStatus)
in.DeepCopyInto(out)
return out
}
+148
View File
@@ -0,0 +1,148 @@
// ⛔⛔⛔ ЛЕГАСИ (2026-08-16) — СТАРЫЙ IoT (оператор, CRD-контроллер). В новой архитектуре не используется.
// Актуальное: HISTORY/2026-08-16-session-log.md
// Создано: 2026-04-12
// main.go — точка входа IoT managed service.
// Запускает controller-manager (IoTDevice CRD) и REST API сервер (порт 9090) в одном процессе.
// Перенесено из sless/main.go — только IoT-специфичная часть.
//
// Компоненты:
// 1. Controller-Manager (controller-runtime) — reconcile IoTDevice CRD
// 2. REST API Server (gorilla/mux) — CRUD устройств, телеметрия, MQTT auth, admin
//
// Конфигурация через env vars:
// IOT_PG_DSN — DSN для IoT Postgres (опционально)
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
iotv1alpha1 "gitea.services.ngcloud.ru/Nail/IoT/api/v1alpha1"
iotcontrollers "gitea.services.ngcloud.ru/Nail/IoT/controllers"
iotapi "gitea.services.ngcloud.ru/Nail/IoT/internal/api"
"gitea.services.ngcloud.ru/Nail/IoT/internal/api/handler"
"gitea.services.ngcloud.ru/Nail/IoT/internal/storage/iotpg"
)
var scheme = runtime.NewScheme()
func init() {
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
// IoT API group iot.kube5s.ru/v1alpha1
utilruntime.Must(iotv1alpha1.AddToScheme(scheme))
}
func main() {
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
ctrl.SetLogger(zap.New(zap.UseDevMode(true)))
log.Info("starting IoT managed service operator")
// Controller Manager — управляет reconcile loop для IoTDevice CRD
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
HealthProbeBindAddress: ":8081",
LeaderElection: false,
})
if err != nil {
log.Error("unable to create controller manager", "err", err)
os.Exit(1)
}
// Регистрируем IoTDevice controller
if err = (&iotcontrollers.IoTDeviceReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr); err != nil {
log.Error("unable to create IoTDevice controller", "err", err)
os.Exit(1)
}
// Health/ready пробы для k8s
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
log.Error("unable to set up health check", "err", err)
os.Exit(1)
}
if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
log.Error("unable to set up ready check", "err", err)
os.Exit(1)
}
// IoT Postgres — опционален (если IOT_PG_DSN не задан — телеметрия отключена)
iotPGStore, err := iotpg.NewFromEnv(log)
if err != nil {
log.Error("failed to init IoT Postgres", "err", err)
os.Exit(1)
}
if iotPGStore != nil {
defer iotPGStore.Close()
}
// REST API handler
h := &handler.Handler{
K8s: mgr.GetClient(),
Scheme: mgr.GetScheme(),
IoTPG: iotPGStore,
Log: log,
BridgeUsername: os.Getenv("MQTT_BRIDGE_USERNAME"),
BridgePassword: os.Getenv("MQTT_BRIDGE_PASSWORD"),
}
router := iotapi.NewRouter(h, log)
// HTTP API сервер на порту 9090
apiServer := &http.Server{
Addr: ":9090",
Handler: router,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
}
// Запускаем API сервер в горутине
go func() {
log.Info("starting REST API server", "addr", ":9090")
if err := apiServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Error("API server failed", "err", err)
os.Exit(1)
}
}()
// Graceful shutdown
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer cancel()
// Запускаем controller-manager (блокирует до ctx.Done)
go func() {
log.Info("starting controller manager")
if err := mgr.Start(ctx); err != nil {
log.Error("controller manager failed", "err", err)
os.Exit(1)
}
}()
<-ctx.Done()
log.Info("shutting down IoT operator")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
if err := apiServer.Shutdown(shutdownCtx); err != nil {
log.Error("API server shutdown error", "err", err)
}
fmt.Println("IoT operator stopped")
}
+11 -76
View File
@@ -16,7 +16,6 @@ import (
"os" "os"
"os/signal" "os/signal"
"sync" "sync"
"sync/atomic"
"syscall" "syscall"
"time" "time"
@@ -30,37 +29,14 @@ import (
"gitea.services.ngcloud.ru/Nail/IoT/internal/storage/iotpg" "gitea.services.ngcloud.ru/Nail/IoT/internal/storage/iotpg"
) )
// shutdownGrace — максимум ожидания остановки подсистем при завершении. Если
// что-то зависло — не блокируем выход (ревью 2026-10-10, «ограничить wg.Wait»).
const shutdownGrace = 15 * time.Second
// waitWithTimeout ждёт wg не дольше d; false = не дождались.
func waitWithTimeout(wg *sync.WaitGroup, d time.Duration) bool {
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
select {
case <-done:
return true
case <-time.After(d):
return false
}
}
// version — подставляется через ldflags при сборке. // version — подставляется через ldflags при сборке.
var version = "dev" var version = "dev"
// main — тонкая обёртка: os.Exit обходит defer-ы, поэтому вся логика в run(),
// которая возвращает код выхода и выполняет свои defer-ы (devices/iotStore
// Close, финальный flush) ДО выхода (ревью 🔴5: «os.Exit обходит defer-ы»).
func main() { func main() {
os.Exit(run())
}
func run() int {
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
slog.Error("config", "err", err) slog.Error("config", "err", err)
return 1 os.Exit(1)
} }
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.LogLevel})) log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.LogLevel}))
@@ -77,7 +53,7 @@ func run() int {
storeCancel() storeCancel()
if err != nil { if err != nil {
log.Error("devices store", "err", err) log.Error("devices store", "err", err)
return 1 os.Exit(1)
} }
defer devices.Close() defer devices.Close()
log.Info("devices store ready") log.Info("devices store ready")
@@ -88,14 +64,14 @@ func run() int {
ensureCancel() ensureCancel()
if err != nil { if err != nil {
log.Error("ensure bridge device", "err", err) log.Error("ensure bridge device", "err", err)
return 1 os.Exit(1)
} }
log.Info("bridge device ensured", "username", cfg.MQTTUsername) log.Info("bridge device ensured", "username", cfg.MQTTUsername)
iotStore, err := iotpg.New(cfg.IOTPGDSN, log) iotStore, err := iotpg.New(cfg.IOTPGDSN, log)
if err != nil { if err != nil {
log.Error("iotpg store", "err", err) log.Error("iotpg store", "err", err)
return 1 os.Exit(1)
} }
defer iotStore.Close() defer iotStore.Close()
log.Info("telemetry store ready") log.Info("telemetry store ready")
@@ -120,50 +96,27 @@ func run() int {
Addr: ":" + cfg.APIPort, Addr: ":" + cfg.APIPort,
Handler: router, Handler: router,
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 15 * time.Second,
// WriteTimeout с запасом: admin-stats/telemetry отвечают через SQS/PG
// (внутренние таймауты до 15с). IdleTimeout — для keep-alive платформы.
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
} }
// bridge + consumer — в фоне. // bridge + consumer — в фоне.
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(2) wg.Add(2)
// shutdownDone — закрывается после завершения server.Shutdown (дренаж
// in-flight HTTP-запросов). run() ждёт его перед выходом, чтобы deferred
// Close (iotStore/devices) не сработали, пока запросы ещё дренируются.
shutdownDone := make(chan struct{})
// fatal — подсистема (bridge/consumer) завершилась с ПОСТОЯННОЙ ошибкой.
// Тогда после штатной остановки выходим с кодом ≠ 0, чтобы платформа
// перезапустила контейнер (ревью 🔴5: раньше процесс оставался жив, а
// /health отдавал 200).
var fatal atomic.Bool
go func() { go func() {
defer wg.Done() defer wg.Done()
// ready.SetBridge управляется самим bridge: true только после успешной ready.SetBridge(true)
// подписки (находка #3). Иначе «соединён, но не подписан» давал /ready=200
// и молча терял телеметрию.
defer ready.SetBridge(false) defer ready.SetBridge(false)
if err := bridge.Run(ctx, cfg, sqsClient, log, ready.SetBridge); err != nil && !errors.Is(err, context.Canceled) { if err := bridge.Run(ctx, cfg, sqsClient, log); err != nil && !errors.Is(err, context.Canceled) {
log.Error("bridge stopped", "err", err) log.Error("bridge stopped", "err", err)
fatal.Store(true)
cancel() // штатный shutdown, затем выход ≠ 0
} }
}() }()
go func() { go func() {
defer wg.Done() defer wg.Done()
// ready.SetConsumer управляется consumer'ом: true после успешного ready.SetConsumer(true)
// ReceiveMessage, false при ошибке SQS (находка «readiness»).
defer ready.SetConsumer(false) defer ready.SetConsumer(false)
if err := consumer.Run(ctx, cfg, sqsClient, iotStore, log, ready.SetConsumer); err != nil && !errors.Is(err, context.Canceled) { if err := consumer.Run(ctx, cfg, sqsClient, iotStore, log); err != nil && !errors.Is(err, context.Canceled) {
log.Error("consumer stopped", "err", err) log.Error("consumer stopped", "err", err)
fatal.Store(true)
cancel()
} }
}() }()
@@ -173,36 +126,18 @@ func run() int {
// (фикс MEDIUM из ревью 2026-08-16). // (фикс MEDIUM из ревью 2026-08-16).
go func() { go func() {
<-ctx.Done() <-ctx.Done()
// Ждём остановки подсистем, но не бесконечно (зависшую не ждём). wg.Wait()
if !waitWithTimeout(&wg, shutdownGrace) {
log.Warn("iot-service: подсистемы не завершились за отведённое время", "grace", shutdownGrace)
}
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel() defer shutdownCancel()
_ = server.Shutdown(shutdownCtx) _ = server.Shutdown(shutdownCtx)
close(shutdownDone)
}() }()
log.Info("iot-service listening", "addr", server.Addr) log.Info("iot-service listening", "addr", server.Addr)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Error("http server", "err", err) log.Error("http server", "err", err)
cancel() // останавливаем bridge/consumer os.Exit(1)
waitWithTimeout(&wg, shutdownGrace)
<-shutdownDone
return 1
} }
// Ждём завершения Shutdown (дренаж in-flight HTTP) до deferred Close. wg.Wait()
<-shutdownDone
if !waitWithTimeout(&wg, shutdownGrace) {
log.Warn("iot-service: подсистемы не завершились за отведённое время", "grace", shutdownGrace)
}
if fatal.Load() {
// Не os.Exit: возвращаем код, чтобы отработали defer-ы (iotStore.Close →
// финальный flush последнего батча). os.Exit их бы обошёл.
log.Error("iot-service: подсистема завершилась с ошибкой — выход ≠ 0")
return 1
}
log.Info("iot-service stopped") log.Info("iot-service stopped")
return 0
} }
+255
View File
@@ -0,0 +1,255 @@
// Создано: 2026-04-04
// Изменено: 2026-04-12 (замена Kafka → shared-SQS)
// mqtt-bridge/main.go — сервис-мост: MQTT (EMQX) → shared-SQS.
//
// Роль в архитектуре:
// IoT Device → MQTT PUBLISH → EMQX → [mqtt-bridge подписан на "+/telemetry/+"]
// → SQS очередь "iot-telemetry"
// → iot-sqs-consumer → Postgres (история телеметрии)
// → event-dispatcher → Serverless Functions (триггеры)
//
// Логика:
// 1. Подключиться к EMQX как MQTT клиент (credentials из env)
// 2. Подписаться на топик "+/telemetry/+" (any namespace / telemetry / any device)
// 3. При получении сообщения — отправить в SQS очередь "iot-telemetry"
// 4. Payload оборачивается в envelope с метаданными (namespace, device_id, ts)
//
// Конфигурация через env vars:
// MQTT_BROKER_URL — tcp://emqx.sless.svc:1883
// MQTT_USERNAME — username для подключения bridge к EMQX
// MQTT_PASSWORD — пароль bridge клиента
// SQS_ENDPOINT — https://qu.kube5s.ru (или внутрикластерный endpoint)
// SQS_ACCESS_KEY — Access Key для shared-SQS tenant
// SQS_SECRET_KEY — Secret Key для shared-SQS tenant
// SQS_QUEUE_NAME — имя очереди (default: iot-telemetry)
// SQS_REGION — регион (default: us-east-1)
package main
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/sqs"
mqtt "github.com/eclipse/paho.mqtt.golang"
)
// mqttBridgeConfig — конфигурация сервиса из env vars.
type mqttBridgeConfig struct {
MQTTBrokerURL string
MQTTUsername string
MQTTPassword string
SQSEndpoint string
SQSAccessKey string
SQSSecretKey string
SQSQueueName string
SQSRegion string
}
// iotTelemetryMessage — envelope сообщения публикуемого в SQS.
// Потребители (iot-sqs-consumer, event-dispatcher) читают этот формат.
type iotTelemetryMessage struct {
// Namespace — k8s namespace тенанта (из MQTT topic, первый сегмент)
Namespace string `json:"namespace"`
// DeviceID — идентификатор устройства (из MQTT topic, третий сегмент)
DeviceID string `json:"device_id"`
// Topic — оригинальный MQTT topic
Topic string `json:"topic"`
// Payload — данные от устройства (JSON as-is, или строка если не JSON)
Payload json.RawMessage `json:"payload"`
// ReceivedAt — время получения сообщения мостом (UTC, RFC3339)
ReceivedAt string `json:"received_at"`
}
func main() {
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
cfg := loadBridgeConfig()
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer cancel()
log.Info("starting iot-mqtt-bridge",
"mqtt_broker", cfg.MQTTBrokerURL,
"mqtt_username", cfg.MQTTUsername,
"sqs_endpoint", cfg.SQSEndpoint,
"sqs_queue", cfg.SQSQueueName,
)
// SQS клиент через AWS SDK Go v2. Endpoint переопределяем на shared-SQS.
sqsClient := sqs.New(sqs.Options{
Region: cfg.SQSRegion,
Credentials: credentials.NewStaticCredentialsProvider(
cfg.SQSAccessKey, cfg.SQSSecretKey, "",
),
BaseEndpoint: aws.String(cfg.SQSEndpoint),
})
// Получаем QueueUrl по имени — SQS API требует URL, а не имя очереди.
// Делаем это один раз при старте.
queueUrlOut, err := sqsClient.GetQueueUrl(ctx, &sqs.GetQueueUrlInput{
QueueName: aws.String(cfg.SQSQueueName),
})
if err != nil {
log.Error("failed to get SQS queue URL — очередь должна существовать", "queue", cfg.SQSQueueName, "err", err)
os.Exit(1)
}
queueURL := *queueUrlOut.QueueUrl
log.Info("SQS queue resolved", "queue_url", queueURL)
// Создаём MQTT клиент
mqttClient, err := connectMQTT(cfg, log)
if err != nil {
log.Error("failed to connect to MQTT broker", "err", err)
os.Exit(1)
}
defer mqttClient.Disconnect(500)
// Функция-обработчик MQTT сообщений — отправляет envelope в SQS
messageHandler := buildMQTTMessageHandler(ctx, sqsClient, queueURL, log)
// Подписываемся на все telemetry топики всех namespace
// "+/telemetry/+" = {любой namespace}/telemetry/{любой deviceId}
const telemetryTopicFilter = "+/telemetry/+"
token := mqttClient.Subscribe(telemetryTopicFilter, 1, messageHandler)
token.Wait()
if token.Error() != nil {
log.Error("mqtt subscribe failed", "topic", telemetryTopicFilter, "err", token.Error())
os.Exit(1)
}
log.Info("subscribed to MQTT topic", "filter", telemetryTopicFilter)
<-ctx.Done()
log.Info("shutting down iot-mqtt-bridge")
}
// loadBridgeConfig читает конфигурацию из env vars.
func loadBridgeConfig() mqttBridgeConfig {
required := func(key string) string {
v := os.Getenv(key)
if v == "" {
slog.Error("required env var not set", "key", key)
os.Exit(1)
}
return v
}
return mqttBridgeConfig{
MQTTBrokerURL: getEnvOrDefault("MQTT_BROKER_URL", "tcp://emqx.sless.svc:1883"),
MQTTUsername: required("MQTT_USERNAME"),
MQTTPassword: required("MQTT_PASSWORD"),
SQSEndpoint: required("SQS_ENDPOINT"),
SQSAccessKey: required("SQS_ACCESS_KEY"),
SQSSecretKey: required("SQS_SECRET_KEY"),
SQSQueueName: getEnvOrDefault("SQS_QUEUE_NAME", "iot-telemetry"),
SQSRegion: getEnvOrDefault("SQS_REGION", "us-east-1"),
}
}
func getEnvOrDefault(key, defaultVal string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultVal
}
// connectMQTT устанавливает подключение к EMQX брокеру.
func connectMQTT(cfg mqttBridgeConfig, log *slog.Logger) (mqtt.Client, error) {
opts := mqtt.NewClientOptions()
opts.AddBroker(cfg.MQTTBrokerURL)
opts.SetClientID("sless-iot-bridge")
opts.SetUsername(cfg.MQTTUsername)
opts.SetPassword(cfg.MQTTPassword)
opts.SetAutoReconnect(true)
opts.SetConnectRetry(true)
opts.SetConnectRetryInterval(5 * time.Second)
opts.SetKeepAlive(30 * time.Second)
opts.SetCleanSession(false)
opts.SetConnectionLostHandler(func(_ mqtt.Client, err error) {
log.Warn("MQTT connection lost, reconnecting...", "err", err)
})
opts.SetReconnectingHandler(func(_ mqtt.Client, _ *mqtt.ClientOptions) {
log.Info("MQTT reconnecting...")
})
opts.SetOnConnectHandler(func(_ mqtt.Client) {
log.Info("MQTT connected to broker")
})
client := mqtt.NewClient(opts)
token := client.Connect()
if !token.WaitTimeout(30 * time.Second) {
return nil, fmt.Errorf("MQTT connect timeout")
}
if token.Error() != nil {
return nil, fmt.Errorf("MQTT connect: %w", token.Error())
}
return client, nil
}
// buildMQTTMessageHandler возвращает обработчик MQTT сообщений.
// При получении — формирует envelope и отправляет в SQS очередь.
// MessageGroupId = namespace (для FIFO очередей — группировка по тенанту).
func buildMQTTMessageHandler(ctx context.Context, sqsClient *sqs.Client, queueURL string, log *slog.Logger) mqtt.MessageHandler {
return func(_ mqtt.Client, msg mqtt.Message) {
topic := msg.Topic()
payload := msg.Payload()
// Топик: "{namespace}/telemetry/{deviceId}"
parts := strings.SplitN(topic, "/", 3)
if len(parts) != 3 {
log.Warn("unexpected MQTT topic format, skipping", "topic", topic)
return
}
ns := parts[0]
deviceID := parts[2]
// Нормализуем payload: если не JSON — оборачиваем в строку
rawPayload := json.RawMessage(payload)
if !json.Valid(payload) {
quotedBytes, _ := json.Marshal(string(payload))
rawPayload = json.RawMessage(quotedBytes)
}
envelope := iotTelemetryMessage{
Namespace: ns,
DeviceID: deviceID,
Topic: topic,
Payload: rawPayload,
ReceivedAt: time.Now().UTC().Format(time.RFC3339),
}
body, err := json.Marshal(envelope)
if err != nil {
log.Error("marshal telemetry message", "topic", topic, "err", err)
return
}
// Отправляем в SQS. SendMessage — синхронный, но HTTP round-trip быстрый.
// При ошибке логируем и продолжаем — не блокируем MQTT callback надолго.
_, err = sqsClient.SendMessage(ctx, &sqs.SendMessageInput{
QueueUrl: aws.String(queueURL),
MessageBody: aws.String(string(body)),
})
if err != nil {
log.Error("SQS SendMessage failed", "queue_url", queueURL, "err", err)
return
}
log.Info("forwarded IoT telemetry to SQS",
"mqtt_topic", topic,
"namespace", ns,
"device", deviceID,
"sqs_queue", queueURL,
)
}
}
+178
View File
@@ -0,0 +1,178 @@
// ⛔⛔⛔ ЛЕГАСИ (2026-08-16) — СТАРЫЙ IoT (CRD-контроллер). В новой архитектуре не используется.
// Актуальное: HISTORY/2026-08-16-session-log.md
// Создано: 2026-04-04
// iotdevice_controller.go — контроллер IoTDevice CRD.
// Логика Reconcile:
// 1. Добавить finalizer при первом создании
// 2. При удалении (DeletionTimestamp != nil) — убрать finalizer.
// Secret удалится автоматически по OwnerReference (cascade GC).
// 3. Если spec.enabled=false — установить phase=Disabled, не создавать Secret.
// Если Secret уже существует — НЕ удалять (при re-enable пароль сохранится).
// 4. Если Secret не существует — сгенерировать пароль, создать Secret с OwnerRef.
// 5. Обновить status: phase, mqttUsername, secretName, topicPrefix.
package controllers
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/log"
iotv1alpha1 "gitea.services.ngcloud.ru/Nail/IoT/api/v1alpha1"
)
// iotDeviceFinalizer — имя finalizer для IoTDevice.
// Гарантирует что контроллер обработает удаление до того как k8s удалит объект.
const iotDeviceFinalizer = "iot.kube5s.ru/device-cleanup"
// IoTDeviceReconciler управляет жизненным циклом IoTDevice CRD.
type IoTDeviceReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=iot.kube5s.ru,resources=iotdevices,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=iot.kube5s.ru,resources=iotdevices/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=iot.kube5s.ru,resources=iotdevices/finalizers,verbs=update
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;delete
// Reconcile — главный цикл управления IoTDevice.
func (r *IoTDeviceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
// Читаем IoTDevice из k8s
device := &iotv1alpha1.IoTDevice{}
if err := r.Get(ctx, req.NamespacedName, device); err != nil {
if errors.IsNotFound(err) {
// Объект удалён — финализация уже завершена
return ctrl.Result{}, nil
}
return ctrl.Result{}, fmt.Errorf("get iotdevice: %w", err)
}
// Если объект помечен к удалению — убираем finalizer.
// Secret удалится каскадно через OwnerReference — явно удалять не нужно.
if !device.DeletionTimestamp.IsZero() {
if controllerutil.ContainsFinalizer(device, iotDeviceFinalizer) {
controllerutil.RemoveFinalizer(device, iotDeviceFinalizer)
if err := r.Update(ctx, device); err != nil {
return ctrl.Result{}, fmt.Errorf("remove finalizer: %w", err)
}
logger.Info("removed finalizer from IoTDevice", "device", req.Name)
}
return ctrl.Result{}, nil
}
// Добавляем finalizer при первом создании объекта
if !controllerutil.ContainsFinalizer(device, iotDeviceFinalizer) {
controllerutil.AddFinalizer(device, iotDeviceFinalizer)
if err := r.Update(ctx, device); err != nil {
return ctrl.Result{}, fmt.Errorf("add finalizer: %w", err)
}
// Requeue — обязательно, чтобы продолжить reconcile с обновлённым объектом
return ctrl.Result{Requeue: true}, nil
}
// Если устройство отключено — обновляем статус, не создаём Secret
if !device.Spec.Enabled {
return ctrl.Result{}, r.setStatus(ctx, device, iotv1alpha1.IoTDevicePhaseDisabled, "Device is disabled")
}
// Имя Secret и MQTT username по формату из плана
secretName := "iot-" + device.Spec.DeviceID
mqttUsername := fmt.Sprintf("%s_%s", device.Namespace, device.Spec.DeviceID)
// Проверяем наличие Secret — если нет, создаём с новыми credentials
existingSecret := &corev1.Secret{}
err := r.Get(ctx, client.ObjectKey{Namespace: device.Namespace, Name: secretName}, existingSecret)
if errors.IsNotFound(err) {
// Генерируем криптографически стойкий пароль: 32 байта = 64 hex символа
password, genErr := generateMQTTPassword()
if genErr != nil {
setErr := r.setStatus(ctx, device, iotv1alpha1.IoTDevicePhaseError, fmt.Sprintf("generate password: %v", genErr))
return ctrl.Result{}, setErr
}
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: secretName,
Namespace: device.Namespace,
},
Data: map[string][]byte{
"mqtt-username": []byte(mqttUsername),
"mqtt-password": []byte(password),
},
}
// OwnerReference — Secret принадлежит IoTDevice.
// При удалении IoTDevice k8s каскадно удалит Secret.
if ownerErr := controllerutil.SetControllerReference(device, secret, r.Scheme); ownerErr != nil {
return ctrl.Result{}, fmt.Errorf("set owner reference: %w", ownerErr)
}
if createErr := r.Create(ctx, secret); createErr != nil {
setErr := r.setStatus(ctx, device, iotv1alpha1.IoTDevicePhaseError, fmt.Sprintf("create secret: %v", createErr))
return ctrl.Result{}, setErr
}
logger.Info("created MQTT credentials Secret", "secret", secretName, "device", device.Spec.DeviceID)
} else if err != nil {
// Неожиданная ошибка при чтении Secret
return ctrl.Result{}, fmt.Errorf("get secret: %w", err)
}
// else — Secret уже существует, ничего не делаем (пароль не меняем)
// Обновляем status — отражаем актуальное состояние устройства
device.Status.Phase = iotv1alpha1.IoTDevicePhaseActive
device.Status.MQTTUsername = mqttUsername
device.Status.SecretName = secretName
device.Status.TopicPrefix = device.Namespace + "/"
device.Status.Message = ""
if statusErr := r.Status().Update(ctx, device); statusErr != nil {
return ctrl.Result{}, fmt.Errorf("update status: %w", statusErr)
}
return ctrl.Result{}, nil
}
// setStatus — вспомогательная функция для обновления status.Phase и status.Message.
// Используется для промежуточных состояний (Disabled, Error).
func (r *IoTDeviceReconciler) setStatus(ctx context.Context, device *iotv1alpha1.IoTDevice, phase iotv1alpha1.IoTDevicePhase, message string) error {
device.Status.Phase = phase
device.Status.Message = message
if err := r.Status().Update(ctx, device); err != nil {
return fmt.Errorf("update status to %s: %w", phase, err)
}
return nil
}
// generateMQTTPassword генерирует криптографически стойкий пароль для MQTT.
// 32 байта случайных данных → hex строка 64 символа = 256 бит энтропии.
func generateMQTTPassword() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("rand.Read: %w", err)
}
return hex.EncodeToString(buf), nil
}
// SetupWithManager регистрирует контроллер в manager'е.
// Watches только IoTDevice — secrets обновляются через reconcile loop при необходимости.
func (r *IoTDeviceReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&iotv1alpha1.IoTDevice{}).
Owns(&corev1.Secret{}).
Complete(r)
}
-7
View File
@@ -1,7 +0,0 @@
# Terraform state и секреты — НЕ коммитить (.terraform.lock.hcl — коммитим)
.terraform/
*.tfstate
*.tfstate.*
terraform.tfvars
*.tfvars
crash.log
-13
View File
@@ -1,13 +0,0 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "tf-registry.containerk8s.services.ngcloud.ru/nubes-test/nubes" {
version = "3.1.0"
constraints = "3.1.0"
hashes = [
"h1:grJn9gwqsPZu5THrt3ZfmOYstRK2N/Fe3Czmf5fwy5k=",
"zh:530e741fae503f959ce5d7e26a4488cbeee7f2294786edae1af6e807afe1d1f8",
"zh:65fda474cc7abc24a0053638088abcc188ff69ded2c56a0a1d3d4fab2bbaf355",
"zh:bf039029d7fb67bfeb24b693ca791993255e21be062a1f69b913ae651bcaf67f",
]
}
-52
View File
@@ -1,52 +0,0 @@
# IoT на Nubes через Terraform
Разворачивает IoT-стек двумя HTTP-контейнерами провайдера `nubes-test` (ресурс `nubes_http`):
- `nubes_http.iot_emqx` — EMQX 5.5.1 (PG-auth) → `gitea.services.ngcloud.ru/nail/iot-emqx:v0.2.4`
- `nubes_http.iot_service` — монолит `iot-service` → `.../iot-service:v0.1.13` (bridge + consumer + api)
`iot-service` создаётся **после** EMQX (`depends_on`), подключается к shared-SQS (`sqs.sinefo.ru`),
managed PostgreSQL (инстанс `foriot`) и EMQX по внутреннему адресу.
> Источник провайдера: `tf-registry.containerk8s.services.ngcloud.ru/nubes-test/nubes` (TEST-стенд),
> версия `3.1.0`. Референсный пример — `~/TF/tf_provider/TEST_STAND/Maria_CRUD`.
## Предусловия (готово на 2026-10-11)
- Образы в Gitea: `nail/iot-emqx:v0.2.4`, `nail/iot-service:v0.1.13`.
- PG (инстанс `foriot`): user `iotservice` (ddl_user → CREATEDB+CREATEROLE) + БД `iotdb`.
- shared-SQS (тенант `demo-service`): очереди `iot-telemetry` + `iot-telemetry-dlq`.
## Запуск
```bash
cd deploy/terraform
cp terraform.tfvars.example terraform.tfvars # заполнить секреты
terraform init
terraform plan
terraform apply # ← запускает ВЛАДЕЛЕЦ (не агент)
```
## Проверка
```bash
terraform output iot_service_url # → https://<iot_domain>/health
terraform output iot_mqtt_broker_url # → wss://<emqx_domain>/mqtt
curl -s https://<iot_domain>/health # {"status":"ok",...}
curl -s https://<emqx_domain>/mqtt # 400 (норма: Cowboy без subprotocol)
```
В логах `iot-service` (ссылка в ЛК): `bridge: MQTT connected` → `bridge: subscribed` →
`consumer: SQS queue resolved`.
## Нюансы
- **`MQTT_HOST`**: по умолчанию вычисляется как `<resource_name>.<instanceUid>.svc.cluster.local`.
Если при первом apply адрес окажется другим — задать `mqtt_host` в `terraform.tfvars` (взять из ЛК).
`ws://` внутри кластера (порт `8083`, путь `/mqtt`).
- **`realm`**: ресурсная платформа K8s; должна существовать в ЛК, иначе
«Ресурсной платформы '<realm>' нету в Личном кабинете». Сверить у PG-инстанса.
- **Имя PG-пользователя**: платформенный regex `^[A-Za-z0-9]+$` — подчёркивание нельзя (`iotservice`, не `iot_service`).
- **`destroy` усыпляет** (`suspend_on_destroy=true`), не удаляет: удаление усыплённого — вручную в ЛК через 14 дней.
- **`reconcile`/`redeploy`** из Terraform недоступны (только ЛК).
- Секреты: `terraform.tfvars` и `*.tfstate` в `.gitignore` — НЕ коммитить.
-21
View File
@@ -1,21 +0,0 @@
# main.tf — IoT на платформе Nubes через Terraform (провайдер nubes-test).
#
# Разворачивает два HTTP-контейнера: EMQX (первым) и iot-service (вторым).
# Образы — из Gitea container registry (gitea.services.ngcloud.ru/nail).
#
# Стенд: TEST. Для DEV/PROD менять source провайдера (namespace) + api_endpoint + токен.
# См. TEST_STAND/Maria_CRUD — референсный пример (nubes_http, nubes_mariadb).
terraform {
required_providers {
nubes = {
source = "tf-registry.containerk8s.services.ngcloud.ru/nubes-test/nubes"
version = "3.1.0"
}
}
}
provider "nubes" {
api_token = var.api_token
api_endpoint = var.api_endpoint
}
-16
View File
@@ -1,16 +0,0 @@
# outputs.tf — адреса развёрнутых контейнеров.
output "emqx_url" {
value = try(nubes_http.iot_emqx.state_out.connectionUrl, "")
description = "Публичный адрес EMQX (wss MQTT)"
}
output "iot_service_url" {
value = try(nubes_http.iot_service.state_out.connectionUrl, "")
description = "Публичный адрес iot-service (REST/health)"
}
output "iot_mqtt_broker_url" {
value = "wss://${var.emqx_domain}/mqtt"
description = "MQTT broker URL для клиентов/консоли"
}
-87
View File
@@ -1,87 +0,0 @@
# resources.tf — два HTTP-контейнера: EMQX (первым) и iot-service (вторым).
# Образы: gitea.services.ngcloud.ru/nail/{iot-emqx,iot-service}.
# Провайдер nubes_http: docs-rendered/http.md.
#
# Порядок важен: iot-service подключается к EMQX (MQTT) → depends_on.
locals {
# Внутренний адрес EMQX-контейнера (для bridge внутри кластера).
# ⚠️ Service в namespace платформы ВСЕГДА называется `containerk8s`, НЕ
# resource_name. Проверено в кластере iot-naeel (2026-10-11):
# containerk8s.<instanceUid>.svc.cluster.local → 10.98.42.138 ✅
# iot-emqx.<instanceUid>.svc.cluster.local → NXDOMAIN ❌
# (тот же формат зафиксирован в HISTORY/2026-08-16-session-log.md:824).
# Если окажется иным — переопредели var.mqtt_host.
emqx_internal_host = "containerk8s.${nubes_http.iot_emqx.state_out.instanceUid}.svc.cluster.local"
mqtt_host = var.mqtt_host != "" ? var.mqtt_host : local.emqx_internal_host
}
# ── EMQX (создаётся ПЕРВЫМ) ──
resource "nubes_http" "iot_emqx" {
resource_name = "iot-emqx"
resource_realm = var.realm
domain = var.emqx_domain
registry_path = "${var.registry}/iot-emqx:${var.iot_emqx_version}"
resource_cpu = 1000
resource_memory = 1024
resource_instances = 1
# EMQX auth/ACL — напрямую в PostgreSQL (таблица iot_devices).
# ⚠ НЕ задавать ...__URL (HTTP-auth старой схемы — уронит под).
json_env = jsonencode({
EMQX_AUTHENTICATION__1__SERVER = "${var.pg_host}:${var.pg_port}"
EMQX_AUTHENTICATION__1__DATABASE = var.pg_db
EMQX_AUTHENTICATION__1__USERNAME = var.pg_user
EMQX_AUTHENTICATION__1__PASSWORD = var.pg_password
EMQX_AUTHORIZATION__SOURCES__1__SERVER = "${var.pg_host}:${var.pg_port}"
EMQX_AUTHORIZATION__SOURCES__1__DATABASE = var.pg_db
EMQX_AUTHORIZATION__SOURCES__1__USERNAME = var.pg_user
EMQX_AUTHORIZATION__SOURCES__1__PASSWORD = var.pg_password
EMQX_LOG__CONSOLE_HANDLER__LEVEL = "warning"
})
adopt_existing_on_create = true
}
# ── iot-service (создаётся ВТОРЫМ, после EMQX) ──
resource "nubes_http" "iot_service" {
resource_name = "iot-service"
resource_realm = var.realm
domain = var.iot_domain
registry_path = "${var.registry}/iot-service:${var.iot_service_version}"
resource_cpu = 200
resource_memory = 512
resource_instances = 1
json_env = jsonencode({
IOT_PG_DSN = "postgresql://${var.pg_user}:${var.pg_password}@${var.pg_host}:${var.pg_port}/${var.pg_db}?sslmode=require"
SQS_ENDPOINT = var.sqs_endpoint
SQS_ACCESS_KEY = var.sqs_access_key
SQS_SECRET_KEY = var.sqs_secret_key
SQS_QUEUE_NAME = var.sqs_queue_name
SQS_DLQ_NAME = var.sqs_dlq_name
SQS_REGION = "us-east-1"
MQTT_HOST = local.mqtt_host
MQTT_PORT = "8083"
MQTT_WS_PATH = "/mqtt"
MQTT_USERNAME = var.mqtt_username
MQTT_PASSWORD = var.mqtt_password
JWT_HMAC_SECRET = var.jwt_hmac_secret
ADMIN_STATS_TOKEN = var.admin_stats_token
AUTH_TEST_MODE = "false"
LOG_LEVEL = "info"
API_PORT = "9090"
})
# iot-service стартует только после создания EMQX
depends_on = [nubes_http.iot_emqx]
adopt_existing_on_create = true
}
-132
View File
@@ -1,132 +0,0 @@
# variables.tf — входные переменные стека IoT.
# Значения — в terraform.tfvars (не коммитится, см. .gitignore).
variable "api_token" {
type = string
sensitive = true
description = "Токен Nubes API (test.token)"
}
variable "api_endpoint" {
type = string
default = "https://lk-api-gateway-test.ngcloud.ru/api/v1/svc"
description = "Endpoint Nubes API (TEST). Менять вместе с source провайдера"
}
variable "realm" {
type = string
description = "Ресурсная платформа K8s (должна существовать в ЛК; у PG Инстанса foriot — iot-naeel)"
}
# ── Домены (короткое имя → платформа создаёт DNS в зоне realm; FQDN → проверка A-записи) ──
variable "emqx_domain" {
type = string
default = "exqx-iot"
description = "Домен EMQX (wss MQTT)"
}
variable "iot_domain" {
type = string
default = "iot-api"
description = "Домен iot-service (REST)"
}
# ── Реестр ──
variable "registry" {
type = string
default = "gitea.services.ngcloud.ru/nail"
description = "Gitea container registry (namespace nail)"
}
variable "iot_service_version" {
type = string
default = "v0.1.13"
}
variable "iot_emqx_version" {
type = string
default = "v0.2.4"
}
# ── PostgreSQL (managed, инстанс foriot 509145c3) ──
variable "pg_host" {
type = string
default = "postgresqlk8s-master.509145c3-ba71-4687-a084-d9dcbc88d3d2.svc.cluster.local"
}
variable "pg_port" {
type = string
default = "5432"
}
variable "pg_db" {
type = string
default = "iotdb"
}
variable "pg_user" {
type = string
default = "iotservice"
description = "PG-пользователь (платформенный regex ^[A-Za-z0-9]+$ — подчёркивание нельзя)"
}
variable "pg_password" {
type = string
sensitive = true
}
# ── shared-SQS (тенант demo-service) ──
variable "sqs_endpoint" {
type = string
default = "https://sqs.sinefo.ru"
description = "Публичный endpoint shared-sqs (на ВМ 213, не в кластере)"
}
variable "sqs_access_key" {
type = string
sensitive = true
}
variable "sqs_secret_key" {
type = string
sensitive = true
}
variable "sqs_queue_name" {
type = string
default = "iot-telemetry"
}
variable "sqs_dlq_name" {
type = string
default = "iot-telemetry-dlq"
}
# ── MQTT (bridge iot-service → EMQX) ──
variable "mqtt_username" {
type = string
default = "iot-bridge"
}
variable "mqtt_password" {
type = string
sensitive = true
}
variable "mqtt_host" {
type = string
default = ""
description = "Внутренний адрес EMQX. Пусто → вычисляется из state_out EMQX (проверить в ЛК при первом apply)"
}
# ── JWT API ──
variable "jwt_hmac_secret" {
type = string
sensitive = true
}
variable "admin_stats_token" {
type = string
sensitive = true
default = ""
}
+4 -7
View File
@@ -1,9 +1,7 @@
# Деплой IoT на платформе Nubes без kubectl (чужой кластер) # Деплой IoT на платформе Nubes без kubectl (чужой кластер)
**Версии на 2026-08-16:** `gitea.services.ngcloud.ru/nail/iot-emqx:v0.2.4`, `gitea.services.ngcloud.ru/nail/iot-service:v0.1.2`. **Версии на 2026-08-16:** `naeel/iot-emqx:v0.2.4`, `naeel/iot-service:v0.1.2`.
Образы — в **Gitea container registry** (`gitea.services.ngcloud.ru`), не на Docker Hub Образы публичные на Docker Hub, каждый имеет РОВНО один EXPOSE
(managed-платформа тянет образы из Gitea; логин — `docker login gitea.services.ngcloud.ru`).
Каждый образ имеет РОВНО один EXPOSE
(EMQX — 8083, iot-service — 9090). Поле порта в deck отсутствует — порт (EMQX — 8083, iot-service — 9090). Поле порта в deck отсутствует — порт
берётся из EXPOSE образа. берётся из EXPOSE образа.
@@ -119,9 +117,8 @@ curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/jso
https://<iot-домен>/v1/namespaces/<ns>/iot/devices https://<iot-домен>/v1/namespaces/<ns>/iot/devices
``` ```
Ответ содержит `mqtt_username` = `<ns>_<device_id>` и `mqtt_password` — пароль Ответ содержит `mqtt_username` = `<ns>_<device_id>` и `mqtt_password`.
отдаётся ТОЛЬКО здесь, один раз (в БД хранится bcrypt-хэш, см. P0-3). GET пароль Повторный просмотр пароля: `GET /v1/namespaces/<ns>/iot/devices/<name>`.
НЕ возвращает — сохраните его сразу.
Устройство подключается: Устройство подключается:
- URL: `wss://<emqx-домен>/mqtt`, подпротокол `mqtt`; - URL: `wss://<emqx-домен>/mqtt`, подпротокол `mqtt`;
-88
View File
@@ -1,88 +0,0 @@
# Подключение устройства к IoT (Nubes, тест-стенд)
Проверено end-to-end 2026-10-11 на развёрнутом стенде
(`https://iot-api.containerk8s.dev.nubes.ru`, `wss://exqx-iot.containerk8s.dev.nubes.ru/mqtt`).
## 1. Регистрация устройства (HTTP API, JWT HS256)
JWT подписывается `JWT_HMAC_SECRET` (HS256), claim `sub` = ваш namespace,
`exp` обязателен; `namespace` в пути **обязан совпадать** с `sub`, иначе `403`.
```bash
curl -X POST "$API/v1/namespaces/$NS/iot/devices" \
-H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \
-d '{"name":"dev1","device_id":"d-01"}'
```
Ответ `201` содержит **единственный раз** выдаваемый пароль:
```json
{"name":"dev1","namespace":"e2e-nubes","device_id":"d-01","enabled":true,
"mqtt_username":"e2e-nubes_d-01","mqtt_password":"<hex>",
"telemetry_topic":"e2e-nubes/telemetry/d-01"}
```
`GET …/devices/{name}` пароль **не** возвращает. Ограничения идентификаторов:
`^[a-zA-Z0-9][a-zA-Z0-9-]*$`, namespace ≤ 56, name/device_id ≤ 64 символов
(иначе `400`). Дубликаты name/device_id → `409`.
## 2. Подключение по MQTT
- URL: `wss://exqx-iot.containerk8s.dev.nubes.ru/mqtt` (WebSocket, subprotocol `mqtt`);
внутри кластера — `ws://containerk8s.<uuid-инстанса EMQX>.svc.cluster.local:8083/mqtt`.
- username = `namespace_device_id`, пароль — из ответа на создание.
- Публикация: топик `namespace/telemetry/device_id` (`telemetry_topic`).
ACL: устройству разрешён **только свой** топик. Публикация/подписка куда-либо ещё
(чужой namespace, другое устройство, wildcard `+`/`#`) → `deny_action=disconnect`,
соединение рвётся. Также запрещены: retained-публикации
(`retain_available=false`), топики глубже 10 уровней, subscribe на
`+/telemetry/+` (это привилегия бриджа).
Коды CONNACK: `0` — успех, `4` — неверный username/пароль, `5` — не авторизован
(в т.ч. устройство выключено через `PATCH {"enabled": false}`).
## 3. Требование к payload — ВАЖНО
Дедупликация выполняется по **содержимому сообщения**:
`message_id = SHA256(namespace | device_id | topic | payload)`
(`internal/service/bridge/handler.go`). Поэтому:
- **два побайтово одинаковых payload подряд считаются дубликатом и сохраняются
один раз** (это защита от повторной доставки SQS at-least-once);
- ⇒ в payload обязателен **timestamp или счётчик**, иначе легитимные повторные
одинаковые показания (например, `{"t":21.0}` каждую минуту) будут отброшены.
Пример корректного payload:
```json
{"temp": 21.5, "ts": 1791737028, "seq": 4211}
```
Payload валидный JSON сохраняется как JSON; не-JSON (текст, бинарь) оборачивается
в строку. Лимиты: MQTT-пакет ≤ 1 МБ (`max_packet_size`), envelope для SQS ≤ 250 КБ
(`maxSQSBodyBytes`) — сообщения больше отбрасываются мостом.
QoS: `QoS0` — «выстрелил и забыл»; для гарантии доставки используйте `QoS1`
(дождитесь PUBACK перед закрытием соединения).
## 4. Чтение телеметрии
```bash
curl "$API/v1/namespaces/$NS/iot/telemetry?device_id=d-01&limit=50&offset=0" \
-H "Authorization: Bearer $JWT"
```
`limit` — дефолт 50, потолок 1000. Ответ:
`{"count":N,"items":[{"id":…,"device_id":…,"ts":…,"payload":…}]}`.
⚠️ Чтение отдаёт payload целиком, поэтому `limit=1000` при крупных payload'ах
дорого для клиента и сервиса — для больших объёмов берите меньше и
используйте `offset`.
## 5. Наблюдаемое поведение (e2e, 2026-10-11)
Задержка «MQTT → видно в API» недетерминирована и доходит до ~20 с: потребитель
SQS работает long-poll окном (`SQS_LONG_POLL_SECONDS=20`), батч пишется в PG и
только потом удаляется из очереди. Для тестов/интеграции — опрашивать с
таймаутом ≥ 30 с, а не фиксированной паузой 1–5 с.
-100
View File
@@ -1,100 +0,0 @@
# IoT на Nubes — чек-лист деплоя (актуально на 2026-10-11)
> Детальная процедура — `deployment-nubes-production.md`. Здесь — конкретные
> шаги текущего стенда. Секреты НЕ хранятся в репо (`*.key` в `.gitignore`);
> значения `<>` берутся из `pg.key`/deck/генератора.
## 0. Готово
- **Образы в Gitea** (`gitea.services.ngcloud.ru/nail`):
- `iot-service:v0.1.13` (+ latest)
- `iot-emqx:v0.2.5` (+ latest), `iot-service:v0.1.13` (+ latest)
⚠️ **Образ обязан иметь РОВНО ОДИН `EXPOSE`** — платформа Nubes отказывает в создании
контейнера с «Не указан applicationUsePort…», если EXPOSE несколько. У `emqx/emqx:5.5.1`
их 7 → образ собирается через `FROM scratch` + `COPY --from=base / /` (EXPOSE не
наследуется), остаётся только `8083`. Параметра `applicationUsePort` у провайдера nubes
3.1.0 нет — обойти на стороне Terraform нельзя, только пересборкой образа.
- **shared-SQS** тенант `demo-service` (`SSAK-b8fb2b3000c178b865091686`, регион `us-east-1`):
- очередь `iot-telemetry` — создана;
- DLQ `iot-telemetry-dlq` — создана.
- публичный endpoint: `https://sqs.sinefo.ru`.
- **PostgreSQL** managed: хост `postgresqlk8s-master.509145c3-ba71-4687-a084-d9dcbc88d3d2.svc.cluster.local:5432` (из `pg.key`).
✅ **создано через Nubes API (test):** пользователь `iotservice` (role `ddl_user` → CREATEDB+CREATEROLE), БД `iotdb` (owner `iotservice`). Пароль `iotservice` — в vault инстанса.
## 1. PostgreSQL — ✅ ГОТОВО
Пользователь `iotservice` (role `ddl_user`, права CREATEDB+CREATEROLE) и БД `iotdb` (owner `iotservice`)
созданы через Nubes API (test): операции `create_user` (svcOperationId 241) и `create_database` (245)
на инстансе `509145c3-…`. ⚠ Гранулярность: имя PG-пользователя подчиняется платформенному regex
`^[A-Za-z0-9]+$` → `iot_service` НЕЛЬЗЯ (только `iotservice`).
Пароль: GET `/instances/509145c3-…/vault/users` (Nubes API, test) → поле `iotservice.password`.
Подключение:
```
IOT_PG_DSN = postgresql://iotservice:<PG_PASS>@postgresqlk8s-master.509145c3-ba71-4687-a084-d9dcbc88d3d2.svc.cluster.local:5432/iotdb?sslmode=require
```
Таблицы (`iot_devices`, per-tenant БД телеметрии) создаются кодом при старте.
## 2. EMQX-контейнер (создаётся ПЕРВЫМ, CPU ≥ 1000m)
Образ: `gitea.services.ngcloud.ru/nail/iot-emqx:v0.2.5`, порт из EXPOSE = `8083`.
Env (все — при создании, потом не меняются):
| Переменная | Значение |
|---|---|
| `EMQX_AUTHENTICATION__1__SERVER` | `postgresqlk8s-master.509145c3-ba71-4687-a084-d9dcbc88d3d2.svc.cluster.local:5432` |
| `EMQX_AUTHENTICATION__1__DATABASE` | `iotdb` |
| `EMQX_AUTHENTICATION__1__USERNAME` | `iotservice` |
| `EMQX_AUTHENTICATION__1__PASSWORD` | `<PG_PASS>` |
| `EMQX_AUTHORIZATION__SOURCES__1__SERVER` | как AUTHENTICATION |
| `EMQX_AUTHORIZATION__SOURCES__1__DATABASE` | `iotdb` |
| `EMQX_AUTHORIZATION__SOURCES__1__USERNAME` | `iotservice` |
| `EMQX_AUTHORIZATION__SOURCES__1__PASSWORD` | `<PG_PASS>` |
| `EMQX_LOG__CONSOLE_HANDLER__LEVEL` | `warning` |
⚠ Не задавать `...__URL` (HTTP-auth старой схемы — уронит под). SSL обязателен.
После создания — записать **UUID namespace EMQX** (нужен в `MQTT_HOST`).
⚠️ Внутренний Service в namespace платформы называется **`containerk8s`**, НЕ `iot-emqx`
(проверено 2026-10-11):
- `containerk8s.<instanceUid>.svc.cluster.local` → резолвится ✅
- `iot-emqx.<instanceUid>.svc.cluster.local` → NXDOMAIN ❌
⚠️ Роль-владелец таблиц: в БД `iotdb` таблицы `iot_devices`/`tenant_credentials` были созданы
ролью `iot_service` (деплой 2026-08-16). Приложению нужен ВЛАДЕЛЕЦ (иначе
`store: init schema: pq: must be owner of table iot_devices (42501)` → под падает, платформа
откатывает установку). Владение передано роли `iotservice` 2026-10-11:
`ALTER TABLE public.iot_devices OWNER TO iotservice;` + `tenant_credentials` + индексы.
## 3. iot-service-контейнер (создаётся ВТОРЫМ)
Образ: `gitea.services.ngcloud.ru/nail/iot-service:v0.1.13`, порт из EXPOSE = `9090`.
Env (обязательные — секреты; необязательные имеют дефолты в образе):
| Переменная | Значение |
|---|---|
| `IOT_PG_DSN` | `postgresql://iotservice:<PG_PASS>@postgresqlk8s-master.509145c3-ba71-4687-a084-d9dcbc88d3d2.svc.cluster.local:5432/iotdb?sslmode=require` |
| `SQS_ACCESS_KEY` | `SSAK-b8fb2b3000c178b865091686` |
| `SQS_SECRET_KEY` | `<из Admin API / лога сервиса>` |
| `MQTT_USERNAME` | `iot-bridge` (device_id строки бриджа) |
| `MQTT_PASSWORD` | `<сгенерировать>` |
| `JWT_HMAC_SECRET` | `<сгенерировать>` |
| `SQS_ENDPOINT` | `https://sqs.sinefo.ru` (не дефолт: shared-sqs НЕ в кластере, а на ВМ 213) |
| `SQS_QUEUE_NAME` | `iot-telemetry` (дефолт) |
| `SQS_DLQ_NAME` | `iot-telemetry-dlq` (дефолт) |
| `MQTT_HOST` | `containerk8s.<UUID namespace EMQX из шага 2>.svc.cluster.local` (см. предупреждение в шаге 2) |
| `MQTT_PORT` | `8083` (дефолт) |
| `AUTH_TEST_MODE` | `false` |
При старте монолит: подключается к PG, создаёт схему, `EnsureBridgeDevice(MQTT_USERNAME,
MQTT_PASSWORD)` → строка `__bridge/iot-bridge` в `iot_devices` для EMQX-auth.
## 4. Проверка
- `/health` и `/ready` (порт 9090) → 200.
- В логе: `bridge: MQTT connected`, `bridge: subscribed`, `consumer: SQS queue resolved`.
- EMQX: `curl https://<emqx-домен>/mqtt` → 400 (норма). wss-рука — клиент с subprotocol `mqtt`.
-230
View File
@@ -1,230 +0,0 @@
# IoT ↔ SQS: сводка всех находок и мыслей (handoff для нового чата/репы)
> Дата: 2026-10-05.
> Назначение: полностью самодостаточный документ — чтобы, открыв его в НОВОЙ
> репе или новом чате, можно было продолжить без чтения истории.
> Вся информация ниже взята из реального кода/доков репозитория IoT
> (`/home/naeel/nubes/IoT`), а НЕ из памяти и НЕ из чужих заметок.
---
## 0. КЛЮЧЕВОЙ РАЗВОРОТ: что в какой репе
**Есть ДВА разных проекта, в ДВУХ разных репах:**
| Проект | Репа | Что делает | Состояние |
|---|---|---|---|
| **shared-sqs** | отдельная репа (SQS-service) | SQS-совместимая очередь (AWS API, Go, форк GoAWS), multi-tenant | **ГОТОВ, работает на проде v0.1.35** (все тесты зелёные, нагрузка без ошибок) |
| **IoT** | эта репа (`/home/naeel/nubes/IoT`) | телеметрия устройств: MQTT → SQS → PostgreSQL → REST | **MVP, не production-ready** (есть подтверждённые блокеры) |
**Зависимость:** IoT **потребляет** shared-sqs как внешний сервис (очередь
телеметрии `iot-telemetry`). shared-sqs от IoT **не зависит**.
**Вывод о порядке (отвечая на вопрос «что сначала — SQS или IoT»):**
- shared-sqs фактически **уже готов и работает** раньше, чем IoT дотянет до
production. Его «доделывать» не надо для старта работ по IoT.
- Порядок работ по сути такой: **SQS уже есть (работает) → теперь доработки
IoT**, который на него опирается.
- Если планируется создавать оба «с нуля» — логический порядок архитектурно:
сначала SQS, потом IoT (т.к. IoT от него зависит).
**ВНИМАНИЕ про ветки IoT-репы (важно, легко запутаться):**
- Актуальный код IoT (монолит `iot-service`, `internal/service/`,
`internal/storage/iotpg/`) лежит на ветке **`feature/replace-kafka-with-sqs`**.
Имя ветки — ПЕРЕЖИТОК старой миграции Kafka→SQS (апрель 2026), к текущей
задаче отношения не имеет. Не путать имя с сутью: это текущая ветка IoT.
- Ветка **`master`** — СТАРАЯ, без монолита (там легаси: `internal/api/`, CRD,
k8s-манифесты, `deployments/`, `examples/`, `config/crd/`). НЕ актуальна.
- На `master` ошибочно закоммичены 3 файла документации (см. раздел 5).
---
## 1. Что такое текущий IoT (архитектура)
**Монолит `iot-service`** — один Go-бинарник с тремя ролями в одном процессе:
1. **REST API** + MQTT auth/acl на порту `:9090` (устройства хранятся в PostgreSQL);
2. **bridge** — MQTT-подписка на EMQX → отправка в shared-SQS;
3. **consumer** — чтение из shared-SQS (long-poll) → запись в per-tenant PostgreSQL.
**Конвейер телеметрии:**
```
устройство → wss → EMQX (authn+authz напрямую в PG) → бридж → shared-SQS
→ consumer → per-tenant PostgreSQL → REST API (/v1/namespaces/{ns}/iot/telemetry)
```
**Стек:**
- Go 1.25, gorilla/mux, paho.mqtt.golang, aws-sdk-go-v2 (SQS), lib/pq.
- EMQX 5.5.1 (образ `naeel/iot-emqx`), shared-SQS (SQS-совместимая очередь),
PostgreSQL managed (per-tenant БД).
- Модуль: `gitea.services.ngcloud.ru/Nail/IoT`.
**Ключевые файлы актуального кода (на ветке feature):**
- `cmd/iot-service/main.go` — точка входа: config → PG → SQS → API + горутины bridge/consumer.
- `internal/service/config/config.go`, `defaults.go` — env-конфигурация.
- `internal/service/api/router.go`, `middleware/auth.go` — маршруты + JWT.
- `internal/service/api/handler/*.go` — devices, mqtt_auth, telemetry, admin, sqsstats.
- `internal/service/store/*.go` — устройства в PG (`iot_devices`).
- `internal/storage/iotpg/iot_telemetry_store.go` — запись телеметрии (batch).
- `internal/service/bridge/*.go` — bridge → SQS.
- `internal/service/consumer/*.go` — SQS → PG.
- `internal/service/sqsclient/sqsclient.go` — SQS-клиент (endpoint override).
- `emqx/emqx.conf` — конфиг EMQX (auth/ACL — напрямую в PostgreSQL).
---
## 2. Как IoT использует shared-sqs (факты)
- Внутренний endpoint shared-sqs (тот же кластер Nubes, realm `iot-naeel`):
`http://containerk8s.f1ffb134-7d16-45bd-8bef-69f6ec8ab33c.svc.cluster.local:4100`
(вшит дефолтом в `config/defaults.go`).
- Очередь: `iot-telemetry`, регион `us-east-1`.
- Тенант IoT: `t-fec713a719ad0b33`; креды детерминированы из email:
tenantID `t-`+sha256(email)[:8], AccessKey `SSAK-`+sha256(email)[:12],
SecretKey sha256(email+":shared-sqs:secret-key:v1").
- Лимиты shared-sqs: 50 очередей на тенанта, сообщение до 256 КБ, Receive 1–10.
- В бридже: неблокирующий SQS-диспетчер (канал 10000 + 4 worker'а, ретраи 1/2/4с),
дроп при переполнении буфера и при oversize >250 КБ.
- В consumer: long-poll 20с, VisibilityTimeout 120с (в коде), backoff при
сбое PG (1с→60с), DeleteMessage после обработки.
---
## 3. ПОДТВЕРЖДЁННЫЕ БЛОКЕРЫ IoT (для доработок)
> Источник: два независимых анализа + ревью Opus + моя перепроверка построчно.
> Все — по КОДУ, с точными ссылками. Сгруппировано по приоритету.
### P0 — компрометация/потеря данных или доступ к чужим данным
**P0-1. Tenant-изоляция не работает.**
- `internal/service/api/middleware/auth.go` (`validateJWT`, ~стр. 62, 120):
проверяет только наличие `sub` и (необязательно) `exp`, а `sub` НИКАК не
сверяется с `{namespace}` из URL.
- Обработчики берут namespace из пути (`handler/devices.go:91`,
`handler/telemetry.go:19`) без сверки с токеном.
- Итог: **любой валидный токен читает/пишет ЛЮБОЙ namespace** — горизонтальный
доступ к чужим устройствам и телеметрии.
**P0-2. Проверка подписи JWT необязательна.**
- `auth.go` (~стр. 70): при пустом `JWT_HMAC_SECRET` подпись НЕ проверяется —
только структура токена. Токен подделывается вручную (`{"sub":"x","exp":...}`).
- `config/config.go` (~стр. 118): дефолт секрета пустой, секрет не в списке
required env.
**P0-3. Пароли устройств в открытом виде.**
- `store/open.go:21`: `mqtt_password TEXT` — хранится открытым текстом.
- `handler/devices.go:189`: пароль отдаётся повторно через `GET /devices/{name}`.
- `emqx/emqx.conf:46`: `password_hash_algorithm name=plain`.
- Компрометация БД = все пароли устройств + возможность выдать их повторно.
**P0-7. Потеря телеметрии: SQS-удаление до фактической записи.**
- `consumer/consumer.go:78`: удаляет сообщение из SQS после
`processTelemetry`==nil.
- `iotpg/iot_telemetry_store.go:356-376`: `InsertTelemetry` при неполном
батче кладёт строку В ПАМЯТЬ и возвращает nil (запись ещё не сделана).
- `iotpg/iot_telemetry_store.go:129-135` (`flushAll`): подменяет буфер на
пустой ДО записи в PG, при ошибке — только логирует.
- Итог: краш между «кладём в буфер» и «flush» → сообщение уже удалено из SQS,
в PG не попало → **безвозвратная потеря**.
**P0-8. Нет идемпотентности (дубли).**
- `iot_telemetry` таблица: `id BIGSERIAL`, без ключа дедупликации.
- `bridge/handler.go:19`: envelope без message-id.
- At-least-once SQS → **молчаливые дубли** при повторе сообщения.
### P1 — важные риски эксплуатации
**P1-4. Лишние HTTP-auth/ACL-эндпоинты.**
- `router.go:50-51`: `/internal/mqtt/auth` и `/internal/mqtt/acl` висят на
публичном порту 9090 БЕЗ аутентификации.
- Но EMQX ходит по PG-auth напрямую (`emqx/emqx.conf`, `backend=postgresql`),
эти эндпоинты — НЕиспользуемый fallback. Рекомендация: **удалить**, а не защищать.
- Риск: кто достучался до порта — перебирает пароли устройств и получает ACL.
**P1-5. `AUTH_TEST_MODE=true` на проде.**
- Был включён для диагностики DevOps (HISTORY §38); любой Bearer-токен проходит.
- Нужно выставить `false` + задать `JWT_HMAC_SECRET`.
**P1-9. `/health` не отражает состояние.**
- `router.go:72`: `healthHandler` всегда `{"status":"ok"}`, не проверяет
bridge/consumer/PG/SQS.
- `main.go`: ошибки `bridge.Run`/`consumer.Run` только логируются; горутина
завершается, а liveness остаётся зелёным.
**P1-10. Дропы в бридже без метрик.**
- `bridge/sender.go:73`: дроп при переполнении RAM-очереди (10000).
- `bridge/sender.go:112`: дроп после исчерпания ретраев.
### P2 — обвязка/гигиена
**P2-6. TLS.** `sslmode=require` (без verify-full) к PG; EMQX `verify=verify_none`
(`emqx.conf:40`).
**P2-11/P2-12. Нет тестов и CI.** `*_test.go` отсутствует; `.github/workflows`
нет (в `.github/` только правила).
**P2-13. `/metrics` заявлен, но не зарегистрирован.**
- `config/defaults.go:6`: в комментарии упомянут `/metrics`, но роут в
`router.go` НЕ создан. prometheus — только indirect dep. Rate-limiting нет.
**P2-14. Легаси не вычищено.**
- Остались `cmd/iot-operator`, `controllers/`, `internal/api/` (старый k8s-код),
`cmd/mqtt-bridge`, `cmd/sqs-consumer`; k8s/controller-runtime в `go.mod`
(~12 строк); бинарники в `bin/`, `ws-probe/ws-probe`.
**P2-15. Дрейф доков.**
- `doc/deployment-nubes-production.md` — образы `v0.1.2`; Makefile — `v0.1.13`.
- `loadtests/README.md` — visibility «30с» (в коде 120с), описывает уже
исправленную блокировку MQTT-обработчика.
**P2-16. `emqx.conf`: `cookie = "iot-emqx-cookie-mvp"` захардкожен.**
### ПЛАТФОРМА (вне нашего кода, но блокирует прод)
1. **wss-обрывы ~150с** на внешнем шлюзе Nubes (детерминированно, доказано с
3 независимых точек: станция, Vultr; внутри кластера и с ВМ — чисто).
Тикет Nubes подготовлен (`doc/thinking/nubes-devops-report-2026-08-16.md`).
2. **MSS/MTU-баг** — плавающий обрыв ответов >~15–64 КБ (баг нестабилен по
маршруту/времени). Обход в коде: offset-пагинация API, внутренний endpoint SQS.
---
## 4. Что уже ХОРОШО (не трогать без нужды)
- Конвейер end-to-end работает и проверен на проде (HISTORY: baseline 2390/2390
без потерь/дублей, ACL-нарушения отсекаются, reconnect-storm чистый).
- Уже исправлены (по итогам ревью 2026-08-16): асинхронный SQS-диспатчер,
VisibilityTimeout 120с, backoff, graceful shutdown, batch-INSERT) — НЕ
регрессировать это.
- EMQX auth/ACL — напрямую в PostgreSQL, без зависимости от монолита.
- MQTT-reconnect с повторной подпиской (OnConnectHandler).
- Документация: деплой без kubectl, интеграция с SQS, 10 нагрузочных сценариев.
---
## 5. Что уже записано в этой репе (файлы документации)
- `doc/thinking/readiness-assessment-2026-10-05.md` — два анализа (5/10 и
60–65%) + моё мнение.
- `doc/thinking/remediation-plan-2026-10-05.md` — план доработок (4 трека, P0/P1/P2).
- `doc/thinking/opus-review-prompt.md` — промпт для ревью (allowlist файлов).
- **ВНИМАНИЕ:** эти 3 файла лежат в ветке `master` (коммит `a4e8fd5`, запушен),
а НЕ на актуальной `feature/replace-kafka-with-sqs`, где живёт код.
`doc/sqs-integration.md`, `doc/deployment-nubes-production.md`, `HISTORY/` —
на feature-ветке.
**Backup-ветки (сохранение состояний):**
- `backup/2026-08-17-feature-sqs` (старая точка).
- `backup/2026-10-05-feature-sqs` (сделана 2026-10-05, на `12db7d3`).
---
## 6. Рекомендуемый порядок работ (по плану)
1. P0-1 + P0-2 — tenant-граница (связать `sub` с namespace) + обязательная подпись JWT.
2. P0-7 + P0-8 — доставка без потерь (ack только после успешного flush) + dedup (message-id).
3. P0-5 — выключить `AUTH_TEST_MODE`.
4. P0-3 + P1-4 — пароли (не отдавать повторно/хешировать) + удалить `/internal/mqtt/*`.
5. P1-9 + P1-10 — честный `/health` + метрики дропов.
6. P1-11 + P1-12 — Go-тесты + CI.
7. P2 (гигиена: легаси, доки, cookie, TLS).
-154
View File
@@ -1,154 +0,0 @@
# Ревью iot-service (bridge → SQS → consumer → iotpg)
> Ответ Opus на промпт «код-ревью IoT». Дословно, без правок.
Код не запускал и тесты не писал. Поведение paho проверил по исходникам v1.5.1 в module cache. `emqx/emqx.conf` вне вашего списка, но я цитирую его строки, где от них зависит вывод.
## 🔴 Критично
1. **Подтверждение SQS до фактической записи (гонка с фоновым flusher).**
- `iot_telemetry_store.go:127-134`: `flushAll` забирает буфер целиком, а фоновый flusher (`:117`) идёт параллельно и игнорирует ошибку.
- `Flush` (`:164`), вызванный из `consumer.go:116`, видит пустой буфер и сразу возвращает `nil`. Тогда `consumer.go:120` удаляет сообщения, пока INSERT ещё в полёте или уже провалился.
- Если провал постоянный (`:149-154`), строки выброшены, а сообщения уже подтверждены. Это потеря данных.
- Тот же эффект в `InsertTelemetry` (`:345-354`). При полном батче и постоянной ошибке теряются до 99 строк от сообщений, которые уже лежат в `pending` и будут подтверждены.
- Нужна сериализация flush и ожидание in-flight записи, а `Flush` должен возвращать ошибку и фонового прохода.
2. **Ядовитое сообщение (poison pill) валит весь батч, и его никуда не отправляют.**
- `:144-154` и `:345-354` классифицируют ошибку на уровне батча, а не строки. Одна плохая строка выбрасывает до 100 валидных строк того же tenant или вечно блокирует их.
- Триггеры, которые проходят проверки:
- envelope без `payload` (`processor.go:39`) даёт NULL и ошибку 23502;
- `\u0000` в JSON-строке: `json.Valid` в `handler.go:47` пропускает, JSONB отвергает (22P05);
- пустые `namespace` и `device_id`: `processor.go:23-27` проверяет только синтаксис JSON, а `EnsureTenantDB` (`:234`) создаёт БД `tenant_`.
- В `consumer.go:116-119` сообщение не подтверждается и не уходит в DLQ. Счётчик `ApproximateReceiveCount` не читается, поэтому оно крутится вечно, если на очереди нет RedrivePolicy.
- Нужны построчный fallback при постоянной ошибке, семантическая валидация envelope как `errInvalidEnvelope`, и отправка в DLQ после N приёмов.
3. **Остаток буфера bridge при shutdown не доставляется.**
- `bridge.go:40-41` стартует воркеры с `ctx` от сигнала, а `bridge.go:54-55` вызывает `closeAndWait` уже после его отмены.
- `sender.go:124` и `:132` получают `context canceled` на первой попытке, и все накопленные сообщения логируются как «dropping».
- Комментарий в `main.go:123-126` про «flush SQS» не соответствует поведению.
- Дополнительно после `close(d.ch)` (`sender.go:87`) поздний колбэк paho паникует на `send on closed channel` (`:72`). Disconnect с quiesce 2 с не гарантирует остановку обработчиков.
4. **Короткая недоступность SQS ведёт к потере данных.**
- `sender.go:31` и `:119-145`: 3 попытки с паузами 1 и 2 с, затем сообщение дропается, хотя буфер на 10 000 почти пуст.
- У `SendMessage` (`:124`) нет таймаута на попытку. Зависшее соединение блокирует все 4 воркера, канал переполняется и `enqueue` дропает (`:70-83`).
- MQTT QoS1 подтверждается брокеру сразу после `enqueue` (`handler.go:52`), то есть до записи в SQS. Сквозной гарантии at-least-once нет.
- Нужны ретраи до дедлайна или shutdown и таймаут на попытку. Опционально отложенный `msg.Ack()` после успешной отправки.
5. **Пайплайн необратимо умирает после временной ошибки при старте, а `/health` остаётся 200.**
- `bridge.go:27-29` и `consumer.go:29-32`: ошибка `GetQueueUrl` фатальна без ретрая.
- `bridge.go:105-111`: таймаут подключения 30 с при `ConnectRetry=true` возвращает ошибку, но paho продолжает подключаться в фоне. Это утечка клиента и повод для паники на закрытом канале из п.3.
- `main.go:109-120` только логирует «stopped». Процесс не завершается и не перезапускается, а `/health` в `router.go:69` всегда отдаёт 200.
- Нужны ретрай старта с backoff или завершение процесса с кодом ≠ 0.
6. **Bridge «глохнет» после неудачной подписки.**
- `bridge.go:92` и `:97` вызывают `c.Disconnect(100)` «для реконнекта». В paho это явное отключение (`client.go:460`), автореконнект после него не срабатывает.
- Подписки нет, а `/ready` остаётся true.
7. **DLQ и подтверждение битых envelope (`consumer.go:33-39`, `:81-94`).**
- Любая ошибка `ResolveQueueURL` для DLQ, даже временная, навсегда превращает `dlqURL` в `""`, и битые сообщения подтверждаются и теряются (`:88-92`).
- Сбой `sendToDLQ` (`:85-87`) только логируется, затем следует подтверждение. Это потеря.
8. **MQTT-пароль устройства нигде не возвращается.**
- `devices.go:152` отдаёт `deviceToResponse(device, "")`, хотя комментарии (`:37-39`, `:78`) обещают отдать пароль один раз при создании. GET тоже отдаёт `""`.
- Сгенерированный пароль не получить, устройства не смогут подключиться. Это блокер и для Terraform.
## 🟡 Важно
- **JWT (`middleware/auth.go`).**
- `:140` при отсутствии `exp` токен вечный. Нужно требовать `exp`, желательно проверять `nbf`, `iss` и `aud`.
- `:57` отдаёт 403 на невалидный и просроченный токен. Должно быть 401, иначе клиент не отличит протухший токен от чужого namespace.
- `:38-42` и `config.go:121`: `AUTH_TEST_MODE` обходит проверку namespace и работает даже при заданном секрете. Лучше запретить при заданном `JWT_HMAC_SECRET` или громко логировать.
- `:92` в тестовом режиме JWT-подобные токены с пустым секретом принимаются без подписи.
- `:61` жёсткое `sub == namespace`. Проверьте, что платформа выдаёт именно такие токены, иначе Terraform получит 403.
- **Изоляция tenant и валидация входа.**
- `iot_telemetry_store.go:618-620`: `a-b` и `a_b` дают одну БД `tenant_a_b`. Длинные namespace (>56 символов) усекаются PG до 63 байт и тоже сливаются.
- `devices.go:106-113`: нет проверки `name`, `device_id` и namespace.
- `device_id` вида `+`, `#`, `/` превращает ACL-топик (`emqx.conf:75`) в wildcard.
- `_` в namespace делает username `ns_device` неоднозначным (`emqx.conf:46`, `LIMIT 1` без `ORDER BY`). Станет 🔴, если namespace допускают `_`.
- Префикс `__bridge` не зарезервирован.
- **Consumer: давление и дубли.**
- `consumer.go:99-105`: паузы 1…60 с внутри цикла по батчу при visibility 120 с (`config.go:116`) дают redelivery ещё не обработанных сообщений. Спасает только дедуп.
- `:116-119`: после ошибки flush нет backoff, цикл крутится «горячо» и вычерпывает очередь в память. Во время простоя PG буфер растёт и накапливает дубликаты строк (`store:147`, `:348`). Он не ограничен.
- `processor.go:30-32`: ошибки `EnsureTenantDB` не классифицируются, постоянные ошибки крутятся бесконечно.
- **`ts` и порядок.**
- `handler.go:57` пишет `received_at` с точностью до секунды.
- `iot_telemetry_store.go:410` и `:415`: `ORDER BY ts DESC` без tiebreaker плюс OFFSET даёт нестабильную пагинацию. Нужны RFC3339Nano и `id DESC`.
- **Дедуп.**
- `handler.go:58`: `message_id` создаётся на каждый приём из MQTT, поэтому дубль QoS1 (DUP после реконнекта) получает новый uuid и не дедуплицируется. Дедуп защищает только от повторной доставки SQS и ретраев отправки.
- `store:190-192`: пустой `message_id` превращается в NULL, дедупа нет. Лучше отвергать такие envelope или брать `MessageId` из SQS как запасной ключ.
- **Readiness.**
- `main.go:107` и `:116` выставляют `ready=true` до старта, а не после подключения MQTT и подписки.
- Потеря соединения MQTT (`bridge.go:81`), PG и SQS на `/ready` не влияют.
- **Shutdown.**
- `main.go:129` не ограничивает `wg.Wait` по времени, а платформа убьёт процесс раньше.
- `main.go:138`: `os.Exit(1)` обходит defer-ы, то есть Close и flush.
- `iot_telemetry_store.go:546`: `Close` делает одну попытку flush, строки после retryable-ошибки теряются при выходе. `Close` не идемпотентен.
- **MQTT.**
- `bridge.go:75-77`: `MaxReconnectInterval` по умолчанию 10 минут. Нужно ~30 с.
- Фиксированный `ClientID` при rolling-деплое с перекрытием даёт takeover и флаппинг двух инстансов.
- **`replaceDSNDatabase` (`:596-613`).**
- Работает только с URL-DSN. Для key=value DSN молча подставляет management-БД, и телеметрия всех tenant смешивается.
- `LastIndex("/")` ломается на `sslrootcert=/path` в query (типично для RDS).
- Пулы tenant (`:559-591`) не вытесняются, по 2 idle-соединения на tenant, и это упрётся в `max_connections`.
- **AWS-совместимость (`sqsclient.go:19-27`).**
- Только статические ключи: нет session token, IRSA и default chain.
- `SQS_ENDPOINT` нельзя оставить пустым (`config.go:109`), без override реальный AWS не используется.
- Нет таймаутов HTTP и настроек ретрайера.
- `SQS_LONG_POLL_SECONDS` не ограничен 20 (`consumer.go:50`).
- Envelope >250 КБ дропается без DLQ (`sender.go:101-110`).
- **`store/devices.go:19-21`: INSERT не пишет `phase`.**
- Устройство, созданное с `enabled=false`, лежит в БД как `Active`.
- При этом ответ POST считает `Disabled` (`devices.go:127-129`), и GET потом даёт другое значение.
- **P0-3 (коммент).**
- Хеширование пароля затронет `EnsureBridgeDevice` (`bridge.go:20-26`, перезапись при каждом старте) и `password_hash_algorithm` в `emqx.conf:42-46`. Менять нужно согласованно.
- `tenant_credentials.pg_password` (`:285`) тоже хранится открыто и нигде не используется для подключения.
## 🟢 Мелочи
- `handler/mqtt_auth.go` не подключён в `router.go`, то есть это мёртвый код. Если его подключить, он небезопасен: доверяет клиентскому `clientid` (`:123`) и разрешает subscribe на `#` (`:144`). Лучше удалить.
- `admin.go:23`: токен сравнивается не constant-time.
- Нет `MaxBytesReader` и `DisallowUnknownFields` (`devices.go:102`, `:233`).
- `main.go:95-99`: нет `WriteTimeout` и `IdleTimeout`.
- `processor.go:43` пишет «telemetry saved», хотя данные только буферизованы. Это Info на каждое сообщение.
- `logging.go:27` логирует пробы здоровья.
- `consumer.go:75-79`: пустое тело подтверждается без DLQ.
- `consumer.go:120`: сообщения удаляются по одному. Есть `DeleteMessageBatch`.
- При ошибке flush DLQ-сообщения отправляются повторно, и в DLQ появляются дубли.
- `defaults.go:6` обещает `/metrics`, маршрута нет. Нет счётчиков дропов (`sender.go:45`), глубины буфера и DLQ.
- Мёртвый код: `ns=="default"` в `devices.go`, nil-проверки `IoTPG`, `NewFromEnv`.
- `iot_telemetry_store.go:279-291`: сбой между `CREATE DATABASE` и записью credentials делает tenant невидимым в `GetAdminStats` (`:460`).
## Что стабилизировать в REST для Terraform
- **Пароль.** Вернуть `mqtt_password` один раз в 201 на POST, иначе ресурс не создать (п.8). Добавить ротацию (отдельный endpoint) или пароль, задаваемый клиентом. Решите заранее, что sensitive и что считается drift.
- **Идентичность ответов POST, GET и PATCH.**
- Сейчас при создании `created_at` пуст, потому что устройство не перечитывается из БД (`devices.go:152`).
- `created_at` в формате `2006-01-02 15:04:05 UTC` (`:73`) нужно привести к RFC3339, как `last_connected`.
- `phase` должен совпадать (см. 🟡 про INSERT).
- Пустой `metadata` (`{}`) не должен пропадать: из-за `omitempty` (`:50`) возникает drift `{}` против null.
- **Поля.**
- Детерминированные, `ForceNew`: `namespace`, `name`, `device_id`. Вычисляемые, стабильные: `mqtt_username` (`ns_device`) и `phase`.
- Нестабильное, только computed: `last_connected`.
- `topic_prefix` (`:66`) даёт `ns/`, а реальный топик `ns/telemetry/{device_id}`. Нужен явный `telemetry_topic`.
- **Изменяемость.** PATCH меняет только `enabled`, `metadata` обновить нельзя. Либо добавить update метаданных, либо зафиксировать `metadata` как `ForceNew`.
- **Контракт ошибок.**
- Токен: 401 против 403 (п. JWT выше).
- Ошибки middleware (`auth.go:27`, `:57`) идут как `text/plain`, а не JSON.
- 409 не различает конфликт `name` и `device_id`.
- Валидация входа возвращает 400 с понятным кодом и полем.
- Нужны единые `error`-структуры и идемпотентный DELETE (404 = «уже удалено»).
- **Валидация.** Регулярки для `name` и `device_id`, максимальная длина, запрет `+`, `#`, `/` и `_` в namespace.
- **Import.** GET по `{namespace}/{name}` уже есть, нужен стабильный ID.
- **List и telemetry.** Ограничения `limit` и `offset` молча игнорируются при ошибке парсинга (`devices.go:163-164`). Нужны явный потолок, `total` или cursor и tiebreaker сортировки.
- **Delete.** Телеметрия при удалении устройства остаётся, это нужно зафиксировать в документации API.
-74
View File
@@ -1,74 +0,0 @@
# Промпт для ревью (Opus) — IoT готовность к production
Ты ревьюишь Go-проект IoT. Цель — выявить блокеры, мешающие назвать сервис
production-ready. Дай ответ МАКСИМАЛЬНО сжато, без воды: списком, короткими
строками, с точными ссылками на файл и строку. Никаких пояснений-простыней.
**Запрещено:**
- Не читай память, историю сессий, заметки, предыдущие анализы, `HISTORY/`,
`LEGACY.md`, ничего в `legacy/`. Только код и документация из списка ниже.
- Не читай старые k8s-манифесты, CRD, оператор — это легаси, не актуально.
- Не предлагай рефакторинг ради рефакторинга — только то, что блокирует прод.
**Компоненты продукта (актуально):** монолит `iot-service` (API + MQTT-bridge +
SQS-consumer в одном бинарнике) + EMQX + shared-SQS + managed PostgreSQL.
Конвейер: устройство → wss → EMQX → бридж → SQS → consumer → PG → REST API.
**Смотри ТОЛЬКО эти файлы (актуальный код):**
Конфигурация и точка входа:
- `cmd/iot-service/main.go`
- `internal/service/config/config.go`
- `internal/service/config/defaults.go`
HTTP API:
- `internal/service/api/router.go`
- `internal/service/api/middleware/auth.go`
- `internal/service/api/middleware/logging.go`
- `internal/service/api/handler/handler.go`
- `internal/service/api/handler/devices.go`
- `internal/service/api/handler/mqtt_auth.go`
- `internal/service/api/handler/telemetry.go`
- `internal/service/api/handler/admin.go`
- `internal/service/api/handler/sqsstats.go`
Хранение:
- `internal/service/store/open.go`
- `internal/service/store/device.go`
- `internal/service/store/devices.go`
- `internal/service/store/bridge.go`
- `internal/storage/iotpg/iot_telemetry_store.go`
Бридж и consumer:
- `internal/service/bridge/bridge.go`
- `internal/service/bridge/handler.go`
- `internal/service/bridge/sender.go`
- `internal/service/consumer/consumer.go`
- `internal/service/consumer/processor.go`
- `internal/service/sqsclient/sqsclient.go`
Инфраструктура:
- `Dockerfile`
- `Makefile`
- `go.mod`
- `emqx/emqx.conf`
- `loadtests/README.md` (только для понимания критериев, не весь код)
**Вопросы (ответь строго по пунктам, каждому — вердикт + файл:строка):**
1. Tenant-изоляция: как `sub` из JWT связывается с `{namespace}` в URL?
Есть ли путь доступа к чужому namespace валидным токеном?
2. Гарантии доставки: есть ли окно потери сообщения между буфером записи,
ответом об успехе и удалением из SQS? Где именно?
3. Идемпотентность: защищён ли вход от дублей при at-least-once SQS?
4. `/health`: отражает ли реальную работоспособность bridge/consumer или
всегда `ok`?
5. Auth JWT: валидация sub/exp/подписи — что при пустом `JWT_HMAC_SECRET`?
6. Пароли устройств: как хранятся, как отдаются через API.
7. `/internal/mqtt/auth` и `/internal/mqtt/acl`: нужны ли ещё, защищены ли?
8. Ошибки записи в PG: что происходит с сообщением при сбое flush-батча?
9. Есть ли тесты (`*_test.go`), CI, rate-limiting, метрики, TLS-верификация PG?
**Формат ответа:** по каждому пункту — 1 строка вердикт (OK / ПРОБЛЕМА) +
`файл:строка` + при проблеме — 1 строка суть. В конце — список
«блокеры для прода» (только критические, без ранжирования по вкусу).
@@ -1,140 +0,0 @@
# Оценка готовности IoT к production (2026-10-05)
> Три взгляда на один репозиторий: анализ A, анализ B и моё мнение.
> Все три — только чтением кода и документации, без сборки и запуска тестов
> (правила репо: команды — только через SSH на ВМ; сборку/прогоны не выполняли).
---
## 1. Анализ A (первый) — итог 5/10
**Вердикт:** цельный MVP, но по коду не считать production-ready для
надёжной multi-tenant эксплуатации.
### Блокеры
- **Не проверяется принадлежность namespace пользователю.** Middleware
проверяет JWT, но не передаёт `sub` обработчикам для сравнения с namespace
из URL. CRUD и выдача пароля устройства берут namespace из запроса.
Валидный токен не ограничивает пользователя его tenant-ом:
`auth.go:20`, `router.go:54`, `devices.go:179`. `exp` фактически необязателен
(`auth.go:120`).
- **Путь потери уже принятых сообщений.** Flusher заменяет буфер на пустой
ДО записи в PG, при ошибке только логирует. Consumer при этом удаляет
сообщения из SQS после добавления в буфер: `iot_telemetry_store.go:129`,
`:135`, `consumer.go:81`.
- **Health зелёный при остановившемся pipeline.** Ошибки запуска bridge/consumer
только логируются; горутина завершается, а `/health` всегда отдаёт `ok`:
`main.go:106`, `router.go:74`.
- **Доставка допускает потери и дубли.** Переполненная RAM-очередь bridge
сбрасывает сообщения; исчерпание SQS-ретраев заканчивается drop. При
at-least-once нет идемпотентного ключа против дублей: `sender.go:70`, `:112`.
### Что хорошо
Компоненты сведены в понятный единый сервис; есть MQTT-reconnect с повторной
подпиской, SQS-ретраи, graceful shutdown, разделение хранения устройств и
телеметрии, широкий набор нагрузочных сценариев.
### Чего не хватает до уверенного релиза
- Автоматических Go-тестов (`*_test.go` не найдено) и CI.
- Согласованности доков с кодом: гайд деплоя — `iot-service:v0.1.2`, Makefile —
`v0.1.13`; loadtest README описывает уже исправленную блокировку
MQTT-обработчика и visibility 30с, код задаёт 120с:
`deployment-nubes-production.md:3`, `Makefile:4`, `README.md:85`, `config.go:114`.
---
## 2. Анализ B (второй) — итог ~60–65%
**Вердикт:** рабочий MVP / технический прототип, не продакшн-готовый сервис.
### Что хорошо
- Конвейер устройство → EMQX → мост → SQS → consumer → PG (per-tenant)
работает end-to-end; подтверждено HISTORY и нагрузочными прогонами.
- Прогоны: baseline 2390/2390 без потерь/дублей; ACL-нарушения отсекаются;
reconnect-storm прошёл чисто.
- Код небольшой (~2,4 тыс. строк в монолите), аккуратный: graceful shutdown,
асинхронный SQS-диспатчер с ретраями, batch-insert, backoff, constant-time
сравнение паролей.
- Документация подробная: деплой без kubectl, интеграция с SQS, лог ревью и
фиксов, 10 нагрузочных сценариев.
- Auth и ACL в EMQX работают напрямую через PostgreSQL, без зависимости от монолита.
### Что мешает назвать готовым
1. **Нет ни одного Go-теста и нет CI.** Только Python-нагрузочные скрипты;
в `.github/` одни правила.
2. **Изоляция тенантов слабая.** JWT проверяет только `sub` и `exp`; namespace
из URL не сверяется с токеном. Без `JWT_HMAC_SECRET` подпись не проверяется.
Любой «валидный» токен читает устройства и пароли любого namespace.
3. **Пароли устройств в БД открытым текстом** и отдаются через GET. EMQX —
`password_hash_algorithm = plain`.
4. **`AUTH_TEST_MODE=true`** (любой Bearer) был включён на проде для диагностики.
HISTORY §38 — «вернуть false»; закрытия не видно.
5. **`/internal/mqtt/auth` и `/internal/mqtt/acl`** открыты на публичном порту
без авторизации; дублируют PG-auth в EMQX и уже не нужны. ACL выдаёт права
моста любому клиенту с `clientid`, совпадающим с мостовым.
6. **Нерешённая платформенная проблема:** плавающий обрыв ответов >~15–64 КБ и
WebSocket-обрывы ~150с; стабильного воспроизведения нет, разбор с DevOps
идёт. В коде обходится пагинацией.
7. **Отложено:** whitelist тенантских БД (любой namespace создаёт базу), DLQ,
soak 24ч.
8. **Легаси не вычищено:** `cmd/iot-operator`, `controllers/`, `internal/api/`,
`cmd/mqtt-bridge`, `cmd/sqs-consumer`; в `go.mod` 12 строк k8s/controller-runtime;
бинарники в `bin/` и `ws-probe/ws-probe`. `LEGACY.md` обещает убрать после
переписывания хендлеров, а оно сделано.
9. **Дрейф конфигурации:** `cookie = "iot-emqx-cookie-mvp"` захардкожен в
`emqx.conf`; параметры EMQX задаются только через env в deck-UI.
10. **Нет TLS-верификации** к PG (`verify_none`). Нет rate limiting и метрик
(только `/health` и admin-статистика).
### Минимум для продакшна
- привязать namespace к JWT, сделать `JWT_HMAC_SECRET` обязательным;
- убедиться, что `AUTH_TEST_MODE` выключен;
- хешировать пароли устройств или не отдавать повторно;
- удалить `/internal/mqtt/*` и легаси;
- добавить тесты и CI;
- закрыть вопрос обрывов на платформе.
---
## 3. Моё мнение (перепроверено по коду)
Оба анализа компетентны и в главном совпадают. Расхождение 5/10 vs 60–65% —
не в фактах, а в весе, который они дают одним и тем же находкам. Спорные места
я перепроверил сам — оба правы по существу.
### Подтверждается кодом (не предположение)
1. **Изоляция тенантов слабая** — верно. `middleware/auth.go` проверяет JWT
только на `sub`+`exp`; `sub` не сверяется с `{namespace}` (claims не
передаются в обработчики, `router.go` не извлекает `sub`). `exp` необязателен
(`if claims.Exp > 0`), без `JWT_HMAC_SECRET` подпись не проверяется.
2. **Путь потери принятых сообщений** — верно. `processTelemetry` → `InsertTelemetry`
кладёт в in-memory буфер и возвращает nil, после чего consumer безусловно
удаляет сообщение из SQS. `flushAll` подменяет буфер на пустой ДО записи в PG
и при ошибке только логирует. Данные могут молча пропасть.
3. **`/health` зелёный при упавшем pipeline** — верно. `main.go` ошибки
bridge/consumer только логирует; горутина завершается, `healthHandler` всегда
отдаёт `ok`.
4. **Нет Go-тестов и CI** — верно. `*_test.go` отсутствует, `.github/` — правила.
5. **`AUTH_TEST_MODE` и пароли plaintext** — верно и задокументировано
(`password_hash_algorithm=plain`, `AUTH_TEST_MODE=true` для DevOps, §38).
6. **Дрейф доков** — верно: гайд `v0.1.2` vs Makefile `v0.1.13`; loadtest README
30с vs код 120с. Анализ A зафиксировал точнее.
### Где анализ B точнее/дополняет
- Явно оговорил, что сборку/тесты не запускал (ограничение правил — SSH).
- Глубже разобрал открытые `/internal/mqtt/*`, TLS `verify_none`, захардкоженный
`cookie`, невычищенное легаси и k8s-зависимости в `go.mod`.
### Где я бы поправил обоих
- **Проценты условны.** Оба дают один вывод «MVP/прототип, не production-ready»;
цифры — риторика, а не измерение.
- **Приоритет блокеров:** главные технические — потеря сообщений и tenant-граница.
Plaintext-пароли и unauthed `/internal/mqtt/*` — важные, но второго эшелона
(периметр платформы + EMQX уже на PG-auth напрямую снижают остроту).
### Итог
**Рабочий MVP, пригодный для демо/пилота, но не production-ready.** Главные
блокеры: слабая tenant-изоляция, гарантированная потеря сообщений при сбое
flush, «слепой» health, отсутствие Go-тестов/CI и не закрытая платформенная
проблема wss ~150с / MTU. Моя оценка — между A и B, ближе к
«функционально готов, эксплуатационно — нет».
-154
View File
@@ -1,154 +0,0 @@
# План доработок IoT — до production-ready (2026-10-05)
> Основа: два независимых анализа + ревью Опуса (построчные ссылки) + моя
> перепроверка по коду. Сгруппировано в 4 трека, внутри — по приоритету.
> Приоритеты: P0 — потеря/компрометация данных или чужие данные; P1 — важные
> риски эксплуатации; P2 — обвязка/гигиена; ПЛАТФОРМА — вне нашего кода.
---
## Трек 1. Безопасность / tenant-изоляция
### P0-1. Связать JWT `sub` с `{namespace}`
- Сейчас: `validateJWT` проверяет только наличие `sub`/`exp`/подписи, `sub`
никак не сверяется с `{namespace}` из URL (`middleware/auth.go:62`); обработчики
берут ns из пути (`handler/devices.go:91`, `handler/telemetry.go:19`).
- Что: извлекать `sub` из claims, передавать в контекст запроса, в middleware
(`/v1`) сверять с `{namespace}` → 403 при несовпадении.
- Файлы: `middleware/auth.go`, `router.go`, `handler/*.go`.
### P0-2. Сделать проверку подписи обязательной
- Сейчас: при пустом `JWT_HMAC_SECRET` подпись не проверяется — токен
подделывается (`auth.go:70`); дефолт секрета пустой, не в required
(`config.go:118`).
- Что: при `AUTH_TEST_MODE=false` требовать `JWT_HMAC_SECRET` (иначе отказ на
старте); `exp` сделать обязательным.
- Файлы: `config.go`, `auth.go`.
### P0-3. Не отдавать пароль устройства повторно + хешировать
- Сейчас: пароль в БД открытым текстом (`store/open.go:21` mqtt_password TEXT),
отдаётся через `GET /devices/{name}` (`handler/devices.go:189`); EMQX
`password_hash_algorithm name=plain` (`emqx.conf:46`).
- Что: пароль отдавать единожды при создании; в БД хранить hash; EMQX перевести
на алгоритм хэша. Миграция существующих строк.
- Файлы: `store/open.go`, `handler/devices.go`, `emqx/emqx.conf`.
### P1-4. Удалить HTTP-auth/ACL `/internal/mqtt/auth|acl`
- Сейчас: EMQX ходит по PG-auth напрямую (`emqx.conf`, `backend=postgresql`).
HTTP-эндпоинты — неиспользуемый fallback, висят на публичном порту 9090 без
аутентификации (`router.go:50-51`) → перебор паролей устройств.
- Что: удалить роуты и обработчики `MQTTAuth`/`MQTTAcl` (и зависимый код).
- Файлы: `router.go`, `handler/mqtt_auth.go`.
### P1-5. Включить `AUTH_TEST_MODE=false` на проде
- Сейчас: включён для диагностики DevOps (HISTORY §38); любой Bearer проходит.
- Что: выставить false + валидный `JWT_HMAC_SECRET`, убедиться в деплое.
### P2-6. TLS верификация PG и EMQX
- Сейчас: `sslmode=require` (без verify-full) и EMQX `verify=verify_none`
(`emqx.conf:40`) — шифрование без проверки сертификата.
- Что: `sslmode=verify-full` при наличии CA; убрать `verify_none` где возможно.
---
## Трек 2. Надёжность доставки телеметрии
### P0-7. Устранить окно потери сообщения (SQS-удаление до записи в PG)
- Сейчас: `consumer.go:78` удаляет из SQS после `processTelemetry`==nil, но
`InsertTelemetry` при неполном батче лишь кладёт в память и возвращает nil
(`iot_telemetry_store.go:356-376`); flush-ошибка неполного батча только
логируется (`iot_telemetry_store.go:133`). Краш/сбой flush = безвозвратная потеря.
- Что: либо синхронная запись (убрать «возврат nil до записи»), либо явный
acknowledge только после подтверждения flush + при ошибке возвращать сообщение
в очередь (видимость + не удалять).
- Файлы: `consumer/processor.go`, `iotpg/iot_telemetry_store.go`.
### P0-8. Идемпотентность входа (dedup)
- Сейчас: `iot_telemetry` id BIGSERIAL без ключа дедупликации; envelope без
message-id (`bridge/handler.go:19`). At-least-once SQS → молчаливые дубли.
- Что: добавить message-id в envelope, UNIQUE-индекс/ON CONFLICT DO NOTHING по
(namespace, message_id) в таблице телеметрии.
- Файлы: `bridge/handler.go`, `iotpg/iot_telemetry_store.go`, схема БД.
### P1-9. `/health` отражает реальное состояние
- Сейчас: `healthHandler` всегда `{"status":"ok"}` (`router.go:72`), не отражает
bridge/consumer/PG/SQS; liveness зелёный при упавшем consumer (`main.go:106`).
- Что: ready-проверки подсистем; `main().Run` bridge/consumer — не глотать
ошибку, а помечать неготовность; `/healthz` (liveness) отделить от `/ready`
(readiness).
- Файлы: `router.go`, `main.go`.
### P1-10. Bridge: поведение при переполнении и ретраях — осознанно
- Сейчас: дроп при переполнении RAM-очереди (`sender.go:73`) и после 3 ретраев
(`sender.go:112`) — потери без метрик.
- Что: метрики дропов; при исчерпании ретраев — не молчаливый дроп, а
лог+счётчик (или при желании DLQ в shared-sqs).
- Файлы: `bridge/sender.go`.
---
## Трек 3. Тесты, CI, метрики
### P1-11. Go-тесты
- Сейчас: `*_test.go` нет ни одного.
- Что: unit-тесты критичных мест — `validateJWT` (tenant-привязка),
`InsertTelemetry`/`flushAll` (потеря), `processor` (дубли), `devices` (CRUD +
пароль), `mqtt_auth` (constant-time). Приоритет — на путях из P0-1..P0-8.
- Файлы: `*_test.go` рядом с пакетами.
### P1-12. CI
- Сейчас: `.github/workflows` нет; в `.github/` только правила.
- Что: workflow: `go vet` + `go test ./...` + `gofmt` check; сборка образа.
- Файлы: `.github/workflows/ci.yml`.
### P2-13. `/metrics` + rate-limiting
- Сейчас: `/metrics` заявлен в комментарии (`config/defaults.go:6`), но роут не
зарегистрирован в `router.go`; prometheus — только indirect dep. Rate-limit нет.
- Что: зарегистрировать `/metrics` (если решено), добавить rate-limiting на `/v1`.
- Файлы: `router.go`, `middleware/`.
---
## Трек 4. Гигиена репозитория и документов
### P2-14. Вычистить легаси
- Сейчас: `cmd/iot-operator`, `controllers/`, `internal/api/`, `cmd/mqtt-bridge`,
`cmd/sqs-consumer`; k8s/controller-runtime в `go.mod` (12 строк); бинарники в
`bin/`, `ws-probe/ws-probe`. `LEGACY.md` обещает убрать после переписывания
хендлеров — оно сделано.
- Что: удалить легаси-код (после P1-4), `go mod tidy`, убрать бинарники из дерева.
- Файлы: `go.mod`, дерево.
### P2-15. Согласовать доки с кодом (дрейф)
- Сейчас: `doc/deployment-nubes-production.md` — образы `v0.1.2`; Makefile —
`v0.1.13`; `loadtests/README.md` — visibility 30с (в коде 120с) и старая
блокировка MQTT-обработчика (уже исправлена).
- Что: привести версии и параметры в доках к фактическим.
- Файлы: `doc/deployment-nubes-production.md`, `loadtests/README.md`.
### P2-16. Emqx cookie из env
- Сейчас: `cookie = "iot-emqx-cookie-mvp"` захардкожен (`emqx.conf`).
- Что: задавать cookie через env/деплой.
- Файлы: `emqx/emqx.conf`.
---
## ПЛАТФОРМА (вне нашего кода, но блокирует прод)
1. **wss обрывы ~150с** на внешнем шлюзе (детерминированно, доказано с 3 точек).
Тикет Nubes; до фикса — устройства должны авто-переподключаться (MQTT
keepalive ≤60с).
2. **MSS/MTU-баг** — обрыв ответов >~15–64 КБ. Обход уже есть (offset-пагинация,
внутренний SQS endpoint); нужен фикс на стороне Nubes.
---
## Рекомендуемый порядок выполнения
1. P0-1, P0-2 (tenant-граница + подпись) — закрывают горизонтальный доступ.
2. P0-7, P0-8 (доставка + dedup) — закрывают потерю/дубли.
3. P0-5 (выключить AUTH_TEST_MODE) — быстро, на проде.
4. P0-3, P1-4, P1-5 (пароли + лишние эндпоинты).
5. P1-9, P1-10 (health + метрики дропов).
6. P1-11, P1-12 (тесты + CI).
7. P2-13..P2-16 (гигиена).
+7 -21
View File
@@ -1,28 +1,14 @@
# EMQX для IoT на платформе Nubes. # EMQX для IoT на платформе Nubes.
# Образ: gitea.services.ngcloud.ru/nail/iot-emqx (Gitea registry платформы). # Образ: naeel/iot-emqx (Docker Hub, публичный).
# #
# ⚠️ ВАЖНО: платформа Nubes требует РОВНО ОДИН EXPOSE у образа (иначе create падает # БАЗА — официальный emqx/emqx:5.5.1 (проверен на платформе: заводится, логи в
# с «Не указан applicationUsePort, а expose в Dockerfile содержит несколько портов»). # stdout). Добавляем только наш emqx.conf (auth/ACL, listeners, логи).
# База emqx/emqx:5.5.1 имеет 7 EXPOSE (18083/1883/4370/5369/8083/8084/8883), а EXPOSE # ⚠️ У образа 7 EXPOSE-портов (наследие базы) — deck-UI при СОЗДАНИИ требует
# не удаляется из наследуемого образа. Поэтому копируем rootfs базового образа в чистый # один порт; в НАШЕМ кластере используется kubectl set image, вопрос одного
# (EXPOSE НЕ переносится) и выставляем единственный порт 8083. # EXPOSE для чужого кластера — отдельная задача (тикет Nubes).
#
# БАЗА — официальный emqx/emqx:5.5.1. Добавляем только наш emqx.conf (auth/ACL, listeners, логи).
FROM emqx/emqx:5.5.1 AS base FROM emqx/emqx:5.5.1
FROM scratch
COPY --from=base / /
ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
COPY emqx.conf /opt/emqx/etc/emqx.conf COPY emqx.conf /opt/emqx/etc/emqx.conf
EXPOSE 8083 EXPOSE 8083
USER emqx
WORKDIR /opt/emqx
ENTRYPOINT ["/usr/bin/docker-entrypoint.sh"]
CMD ["/opt/emqx/bin/emqx", "foreground"]
+2 -4
View File
@@ -1,9 +1,7 @@
# EMQX-контейнер для IoT (образ gitea.services.ngcloud.ru/nail/iot-emqx). # EMQX-контейнер для IoT (образ naeel/iot-emqx).
# Registry: Gitea (gitea.services.ngcloud.ru) — managed-платформа тянет образы отсюда.
REGISTRY ?= gitea.services.ngcloud.ru/nail
VERSION ?= v0.2.4 VERSION ?= v0.2.4
IMAGE ?= $(REGISTRY)/iot-emqx IMAGE ?= naeel/iot-emqx
.PHONY: docker-build docker-push .PHONY: docker-build docker-push
+2 -6
View File
@@ -40,12 +40,8 @@ authentication = [
verify = verify_none verify = verify_none
} }
password_hash_algorithm { password_hash_algorithm {
name = bcrypt name = plain
## ⚠ salt_rounds НЕ поддерживается схемой 5.5.1 (проверено на образе salt_position = disable
## emqx/emqx:5.5.1: «unknown_fields, unknown => "salt_rounds"» и
## call_hocon_failed). Оставлять его нельзя — контейнер не стартует.
## Роль поля нулевая: EMQX только СВЕРЯЕТ хеш из БД, а cost берётся из
## самого bcrypt-хеша, не из конфига.
} }
query = "SELECT mqtt_password AS password_hash FROM iot_devices WHERE enabled = true AND (namespace || '_' || device_id = ${username} OR (namespace = '__bridge' AND ${username} = device_id)) LIMIT 1" query = "SELECT mqtt_password AS password_hash FROM iot_devices WHERE enabled = true AND (namespace || '_' || device_id = ${username} OR (namespace = '__bridge' AND ${username} = device_id)) LIMIT 1"
} }
+57 -1
View File
@@ -10,14 +10,70 @@ require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1 github.com/gorilla/mux v1.8.1
github.com/lib/pq v1.11.2 github.com/lib/pq v1.11.2
golang.org/x/crypto v0.42.0 k8s.io/api v0.26.0
k8s.io/apimachinery v0.26.0
k8s.io/client-go v0.26.0
sigs.k8s.io/controller-runtime v0.14.1
) )
require ( require (
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/smithy-go v1.24.2 // indirect github.com/aws/smithy-go v1.24.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emicklei/go-restful/v3 v3.9.0 // indirect
github.com/evanphx/json-patch/v5 v5.6.0 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/go-logr/logr v1.2.3 // indirect
github.com/go-logr/zapr v1.2.3 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.20.0 // indirect
github.com/go-openapi/swag v0.19.14 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/gnostic v0.5.7-v3refs // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/google/gofuzz v1.1.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/imdario/mergo v0.3.6 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_golang v1.14.0 // indirect
github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.37.0 // indirect
github.com/prometheus/procfs v0.8.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.24.0 // indirect
golang.org/x/net v0.44.0 // indirect golang.org/x/net v0.44.0 // indirect
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b // indirect
golang.org/x/sync v0.17.0 // indirect golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/term v0.35.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/time v0.3.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.2.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/apiextensions-apiserver v0.26.0 // indirect
k8s.io/component-base v0.26.0 // indirect
k8s.io/klog/v2 v2.80.1 // indirect
k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280 // indirect
k8s.io/utils v0.0.0-20221128185143-99ec85e7a448 // indirect
sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
) )
+614 -2
View File
@@ -1,3 +1,43 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI= github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
@@ -10,19 +50,591 @@ github.com/aws/aws-sdk-go-v2/service/sqs v1.42.25 h1:8Bv3TQ1Cob6HLlpUbAnWxeHhAkY
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.25/go.mod h1:eDstEbM0OEnBUnNQxIA7j74Jy61cCU1S4EMlCtdMwzs= github.com/aws/aws-sdk-go-v2/service/sqs v1.42.25/go.mod h1:eDstEbM0OEnBUnNQxIA7j74Jy61cCU1S4EMlCtdMwzs=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
github.com/emicklei/go-restful/v3 v3.9.0 h1:XwGDlfxEnQZzuopoqxwSEllNcCOM9DhhFyhFIIGKwxE=
github.com/emicklei/go-restful/v3 v3.9.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84=
github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww=
github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0=
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/zapr v1.2.3 h1:a9vnzlIBPQBBkeaR9IuMUfmVOrQlkoC4YfPoFkX3T7A=
github.com/go-logr/zapr v1.2.3/go.mod h1:eIauM6P8qSvTw5o2ez6UEAfGjQKrxQTl5EoK+Qa2oG4=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA=
github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.14 h1:gm3vOOXfiuw5i9p5N9xJvfjvuofpyvLA9Wr6QfK5Fng=
github.com/go-openapi/swag v0.19.14/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/gnostic v0.5.7-v3refs h1:FhTMOKj2VhjpouxvWJAV1TL304uMlb9zcDqkl6cEI54=
github.com/google/gnostic v0.5.7-v3refs/go.mod h1:73MKFl6jIHelAJNaBGFzt3SPtZULs9dYrGFt8OiIsHQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g=
github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28=
github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/matttproud/golang_protobuf_extensions v1.0.2 h1:hAHbPm5IJGijwng3PWk09JkG9WeqChjprR5s9bBZ+OM=
github.com/matttproud/golang_protobuf_extensions v1.0.2/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/onsi/ginkgo/v2 v2.6.0 h1:9t9b9vRUbFq3C4qKFCGkVuq/fIHji802N1nrtkh1mNc=
github.com/onsi/ginkgo/v2 v2.6.0/go.mod h1:63DOGlLAH8+REH8jUGdL3YpCpu7JODesutUjdENfUAc=
github.com/onsi/gomega v1.24.1 h1:KORJXNNTzJXzu4ScJWssJfJMnJ+2QJqhoQSRwNlze9E=
github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw=
github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
github.com/prometheus/common v0.37.0 h1:ccBbHCgIiT9uSoFY0vX8H3zsNR5eLt17/RQLUvn8pXE=
github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b h1:clP8eMhB30EHdc0bd2Twtq6kgU7yl5ub2cQLSdrv1Dg=
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gomodules.xyz/jsonpatch/v2 v2.2.0 h1:4pT439QV83L+G9FkcCriY6EkpcK6r6bK+A5FBUMI7qY=
gomodules.xyz/jsonpatch/v2 v2.2.0/go.mod h1:WXp+iVDkoLQqPudfQ9GBlwB2eZ5DKOnjQZCYdOS8GPY=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201019141844-1ed22bb0c154/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
k8s.io/api v0.26.0 h1:IpPlZnxBpV1xl7TGk/X6lFtpgjgntCg8PJ+qrPHAC7I=
k8s.io/api v0.26.0/go.mod h1:k6HDTaIFC8yn1i6pSClSqIwLABIcLV9l5Q4EcngKnQg=
k8s.io/apiextensions-apiserver v0.26.0 h1:Gy93Xo1eg2ZIkNX/8vy5xviVSxwQulsnUdQ00nEdpDo=
k8s.io/apiextensions-apiserver v0.26.0/go.mod h1:7ez0LTiyW5nq3vADtK6C3kMESxadD51Bh6uz3JOlqWQ=
k8s.io/apimachinery v0.26.0 h1:1feANjElT7MvPqp0JT6F3Ss6TWDwmcjLypwoPpEf7zg=
k8s.io/apimachinery v0.26.0/go.mod h1:tnPmbONNJ7ByJNz9+n9kMjNP8ON+1qoAIIC70lztu74=
k8s.io/client-go v0.26.0 h1:lT1D3OfO+wIi9UFolCrifbjUUgu7CpLca0AD8ghRLI8=
k8s.io/client-go v0.26.0/go.mod h1:I2Sh57A79EQsDmn7F7ASpmru1cceh3ocVT9KlX2jEZg=
k8s.io/component-base v0.26.0 h1:0IkChOCohtDHttmKuz+EP3j3+qKmV55rM9gIFTXA7Vs=
k8s.io/component-base v0.26.0/go.mod h1:lqHwlfV1/haa14F/Z5Zizk5QmzaVf23nQzCwVOQpfC8=
k8s.io/klog/v2 v2.80.1 h1:atnLQ121W371wYYFawwYx1aEY2eUfs4l3J72wtgAwV4=
k8s.io/klog/v2 v2.80.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0=
k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280 h1:+70TFaan3hfJzs+7VK2o+OGxg8HsuBr/5f6tVAjDu6E=
k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280/go.mod h1:+Axhij7bCpeqhklhUTe3xmOn6bWxolyZEeyaFpjGtl4=
k8s.io/utils v0.0.0-20221128185143-99ec85e7a448 h1:KTgPnR10d5zhztWptI952TNtt/4u5h3IzDXkdIMuo2Y=
k8s.io/utils v0.0.0-20221128185143-99ec85e7a448/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
sigs.k8s.io/controller-runtime v0.14.1 h1:vThDes9pzg0Y+UbCPY3Wj34CGIYPgdmspPm2GIpxpzM=
sigs.k8s.io/controller-runtime v0.14.1/go.mod h1:GaRkrY8a7UZF0kqFFbUKG7n9ICiTY5T55P1RiE3UZlU=
sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2 h1:iXTIw73aPyC+oRdyqqvVJuloN1p0AC/kzH07hu3NE+k=
sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
sigs.k8s.io/structured-merge-diff/v4 v4.2.3 h1:PRbqxJClWWYMNV1dhaG4NsibJbArud9kFxnAMREiWFE=
sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E=
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
+27
View File
@@ -0,0 +1,27 @@
// Создано: 2026-04-06
// admin_embed.go — встраивает HTML страницы администратора IoT в бинарник через go:embed.
//
// Страница /iot-admin доступна без JWT — данные не содержит.
// Все данные загружаются через /iot-admin/stats (защищён ADMIN_STATS_TOKEN).
// Почему go:embed: единый деплой, нет отдельных pod-ов, нет nginx drift.
package api
import (
_ "embed"
"net/http"
)
// iotAdminHTML — бинарное содержимое страницы администратора IoT, встроенное при сборке.
//
//go:embed ui/iot-admin.html
var iotAdminHTML []byte
// ServeIoTAdmin обрабатывает GET /iot-admin — отдаёт HTML страницу администратора.
// Auth не нужен для HTML — сама страница ничего не содержит, только UI оболочка.
func ServeIoTAdmin(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(iotAdminHTML)
}
+32
View File
@@ -0,0 +1,32 @@
// Создано: 2026-04-04
// console_embed.go — встраивает HTML-файл IoT консоли в бинарник оператора через go:embed.
//
// Файл ui/iot-console.html встраивается при компиляции и раздаётся по GET /console.
// Путь /console доступен без JWT — это публичная статическая страница.
// Авторизация в UI происходит через Bearer-токен который пользователь вводит сам.
//
// Почему go:embed а не отдельный nginx: нет лишних pod'ов, единый деплой, нет drift.
// Почему /console без auth: HTML файл не содержит секретов, токен вводит пользователь.
package api
import (
_ "embed"
"net/http"
)
// iotConsoleHTML — бинарное содержимое IoT консоли, встроенное при сборке.
// При изменении HTML-файла достаточно пересобрать оператор.
//
//go:embed ui/iot-console.html
var iotConsoleHTML []byte
// ServeIoTConsole обрабатывает GET /console — отдаёт HTML SPA без JWT-проверки.
// Браузер кэширует HTML; API-запросы из JS защищены Bearer-токеном.
func ServeIoTConsole(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// Не кэшировать агрессивно — консоль обновляется вместе с оператором
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(iotConsoleHTML)
}
+63
View File
@@ -0,0 +1,63 @@
// Создано: 2026-04-12
// Handler — контейнер зависимостей для IoT REST handlers.
// Упрощённая версия из sless — только IoT-специфичные поля.
//
// Бизнес-логика:
// - iot_device_handler.go — CRUD устройств + MQTT auth
// - iot_telemetry_handler.go — чтение телеметрии
// - iot_admin_stats_handler.go — админ-статистика
package handler
import (
"encoding/json"
"log/slog"
"net/http"
"github.com/gorilla/mux"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"gitea.services.ngcloud.ru/Nail/IoT/internal/storage/iotpg"
)
// defaultNamespace — fallback namespace для dev/тестов.
const defaultNamespace = "default"
// Handler содержит зависимости для всех IoT REST-обработчиков.
type Handler struct {
K8s client.Client
Scheme *runtime.Scheme
// IoTPG — хранилище IoT телеметрии (per-tenant Postgres). nil если IOT_PG_DSN не задан.
IoTPG *iotpg.IoTPostgresStore
Log *slog.Logger
// BridgeUsername — username внутреннего MQTT bridge (env MQTT_BRIDGE_USERNAME)
BridgeUsername string
// BridgePassword — пароль bridge (env MQTT_BRIDGE_PASSWORD)
BridgePassword string
}
// writeJSON отправляет JSON-ответ с указанным статусом.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// errResp возвращает структуру ошибки для JSON.
func errResp(msg string) map[string]string {
return map[string]string{"error": msg}
}
// pathVar читает переменную пути из gorilla/mux.
func pathVar(r *http.Request, key string) string {
return mux.Vars(r)[key]
}
// namespace читает {namespace} из пути URL.
func namespace(r *http.Request) string {
if ns := mux.Vars(r)["namespace"]; ns != "" {
return ns
}
return defaultNamespace
}
@@ -0,0 +1,205 @@
// Создано: 2026-04-06
// Изменено: 2026-04-12 (замена Kafka lag → SQS queue stats)
// iot_admin_stats_handler.go — handler для страницы администратора IoT.
//
// Endpoints:
// GET /iot-admin/stats — JSON с агрегированной статистикой (защищён ADMIN_STATS_TOKEN)
//
// Источники данных:
// - PostgreSQL (IoTPG): counts per tenant, last 1h/24h, latest rows
// - SQS: approximate message count (ApproximateNumberOfMessages)
// - K8s: статус подов iot-mqtt-bridge и iot-sqs-consumer
//
// Авторизация: Bearer из env ADMIN_STATS_TOKEN.
// Если ADMIN_STATS_TOKEN не задан — endpoint возвращает 503.
package handler
import (
"context"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/sqs"
sqstypes "github.com/aws/aws-sdk-go-v2/service/sqs/types"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
)
// iotAdminPodStatus — краткая информация о k8s pod для страницы администратора.
type iotAdminPodStatus struct {
Name string `json:"name"`
Phase string `json:"phase"`
Ready bool `json:"ready"`
Restarts int32 `json:"restarts"`
Age string `json:"age"`
}
// iotAdminSQSStats — информация о SQS очереди для мониторинга.
type iotAdminSQSStats struct {
ApproximateMessages int64 `json:"approximate_messages"`
ApproximateMessagesNotVisible int64 `json:"approximate_messages_not_visible"`
Error string `json:"error,omitempty"`
}
// AdminStats обрабатывает GET /iot-admin/stats.
// Проверяет Bearer-токен из ADMIN_STATS_TOKEN, затем собирает и возвращает статистику.
func (h *Handler) AdminStats(w http.ResponseWriter, r *http.Request) {
adminToken := os.Getenv("ADMIN_STATS_TOKEN")
if adminToken == "" {
writeJSON(w, http.StatusServiceUnavailable, errResp("admin stats not configured: ADMIN_STATS_TOKEN not set"))
return
}
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") || strings.TrimPrefix(authHeader, "Bearer ") != adminToken {
writeJSON(w, http.StatusUnauthorized, errResp("unauthorized"))
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
result := map[string]any{
"collected_at": time.Now().UTC(),
}
// PostgreSQL: статистика по всем tenant
if h.IoTPG != nil {
pgStats, err := h.IoTPG.GetAdminStats(ctx)
if err != nil {
result["postgres"] = map[string]any{"reachable": false, "error": err.Error()}
} else {
result["postgres"] = pgStats
}
} else {
result["postgres"] = map[string]any{"reachable": false, "error": "IoTPG not configured"}
}
// SQS: approximate message count для очереди iot-telemetry
result["sqs"] = h.collectIotSQSStats(ctx)
// K8s: статус подов bridge и consumer
result["pods"] = h.collectIotPodStatuses(ctx)
writeJSON(w, http.StatusOK, result)
}
// collectIotSQSStats получает ApproximateNumberOfMessages из SQS очереди.
// Использует SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY, SQS_QUEUE_NAME из env.
func (h *Handler) collectIotSQSStats(ctx context.Context) iotAdminSQSStats {
endpoint := os.Getenv("SQS_ENDPOINT")
accessKey := os.Getenv("SQS_ACCESS_KEY")
secretKey := os.Getenv("SQS_SECRET_KEY")
queueName := os.Getenv("SQS_QUEUE_NAME")
if queueName == "" {
queueName = "iot-telemetry"
}
region := os.Getenv("SQS_REGION")
if region == "" {
region = "us-east-1"
}
if endpoint == "" || accessKey == "" || secretKey == "" {
return iotAdminSQSStats{Error: "SQS credentials not configured (SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY)"}
}
sqsClient := sqs.New(sqs.Options{
Region: region,
Credentials: credentials.NewStaticCredentialsProvider(
accessKey, secretKey, "",
),
BaseEndpoint: aws.String(endpoint),
})
// Получаем URL очереди
queueUrlOut, err := sqsClient.GetQueueUrl(ctx, &sqs.GetQueueUrlInput{
QueueName: aws.String(queueName),
})
if err != nil {
return iotAdminSQSStats{Error: fmt.Sprintf("GetQueueUrl: %v", err)}
}
// Запрашиваем атрибуты очереди — approximate message counts
attrsOut, err := sqsClient.GetQueueAttributes(ctx, &sqs.GetQueueAttributesInput{
QueueUrl: queueUrlOut.QueueUrl,
AttributeNames: []sqstypes.QueueAttributeName{
sqstypes.QueueAttributeNameApproximateNumberOfMessages,
sqstypes.QueueAttributeNameApproximateNumberOfMessagesNotVisible,
},
})
if err != nil {
return iotAdminSQSStats{Error: fmt.Sprintf("GetQueueAttributes: %v", err)}
}
approxMsg, _ := strconv.ParseInt(attrsOut.Attributes["ApproximateNumberOfMessages"], 10, 64)
approxNotVisible, _ := strconv.ParseInt(attrsOut.Attributes["ApproximateNumberOfMessagesNotVisible"], 10, 64)
return iotAdminSQSStats{
ApproximateMessages: approxMsg,
ApproximateMessagesNotVisible: approxNotVisible,
}
}
// collectIotPodStatuses собирает статус k8s pods для bridge и consumer по label app={name}.
func (h *Handler) collectIotPodStatuses(ctx context.Context) map[string]any {
result := map[string]any{}
for _, appLabel := range []string{"iot-mqtt-bridge", "iot-sqs-consumer"} {
podList := &corev1.PodList{}
if err := h.K8s.List(ctx, podList,
client.InNamespace("sless"),
client.MatchingLabels{"app": appLabel},
); err != nil {
result[appLabel] = map[string]any{"error": err.Error()}
continue
}
if len(podList.Items) == 0 {
result[appLabel] = map[string]any{"status": "not found"}
continue
}
pod := podList.Items[0]
var restarts int32
for _, cs := range pod.Status.ContainerStatuses {
restarts += cs.RestartCount
}
ready := false
for _, cond := range pod.Status.Conditions {
if cond.Type == corev1.PodReady && cond.Status == corev1.ConditionTrue {
ready = true
}
}
result[appLabel] = iotAdminPodStatus{
Name: pod.Name,
Phase: string(pod.Status.Phase),
Ready: ready,
Restarts: restarts,
Age: iotFormatAge(pod.CreationTimestamp.Time),
}
}
return result
}
// iotFormatAge возвращает человекочитаемый возраст (s/m/h/d) pod-а.
func iotFormatAge(created time.Time) string {
d := time.Since(created)
switch {
case d < time.Minute:
return fmt.Sprintf("%ds", int(d.Seconds()))
case d < time.Hour:
return fmt.Sprintf("%dm", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh", int(d.Hours()))
default:
return fmt.Sprintf("%dd", int(d.Hours()/24))
}
}
+480
View File
@@ -0,0 +1,480 @@
// Создано: 2026-04-04
// iot_device_handler.go — HTTP handlers для IoT-устройств (CRUD) и MQTT auth.
//
// Endpoints:
// POST /internal/mqtt/auth → MQTTAuth (без JWT, для EMQX)
// POST /v1/namespaces/{ns}/iot/devices → CreateIoTDevice
// GET /v1/namespaces/{ns}/iot/devices → ListIoTDevices
// GET /v1/namespaces/{ns}/iot/devices/{name} → GetIoTDevice (включает credentials)
// DELETE /v1/namespaces/{ns}/iot/devices/{name} → DeleteIoTDevice
// PATCH /v1/namespaces/{ns}/iot/devices/{name} → UpdateIoTDevice
//
// MQTTAuth вызывается EMQX при каждом MQTT CONNECT:
// - всегда возвращает HTTP 200 (non-200 = EMQX игнорирует backend)
// - {"result": "allow"|"deny"} в теле
//
// GetIoTDevice — единственный endpoint возвращающий mqtt-password.
// ListIoTDevices — без паролей (security by design).
package handler
import (
"crypto/subtle"
"encoding/json"
"net/http"
"strings"
"time"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
iotv1alpha1 "gitea.services.ngcloud.ru/Nail/IoT/api/v1alpha1"
)
// ——————————————————————————————————————————
// Типы запросов / ответов
// ——————————————————————————————————————————
// iotDeviceCreateRequest — тело POST при создании IoTDevice.
type iotDeviceCreateRequest struct {
// Name — имя k8s объекта IoTDevice (должно быть уникальным в namespace)
Name string `json:"name"`
// DeviceID — идентификатор устройства, используется в MQTT username и имени Secret
DeviceID string `json:"device_id"`
// Enabled — активно ли устройство с момента создания
Enabled *bool `json:"enabled"`
// Metadata — произвольные метаданные (модель, локация и т.д.)
Metadata map[string]string `json:"metadata,omitempty"`
}
// iotDeviceUpdateRequest — тело PATCH при обновлении IoTDevice.
type iotDeviceUpdateRequest struct {
// Enabled — включить/отключить устройство
Enabled *bool `json:"enabled"`
}
// iotDeviceResponse — ответ при чтении одного IoTDevice.
// MQTTPassword заполняется только из GetIoTDevice (чтение из Secret).
type iotDeviceResponse struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
DeviceID string `json:"device_id"`
Enabled bool `json:"enabled"`
Phase iotv1alpha1.IoTDevicePhase `json:"phase"`
MQTTUsername string `json:"mqtt_username,omitempty"`
MQTTPassword string `json:"mqtt_password,omitempty"` // только в GET /devices/{name}
SecretName string `json:"secret_name,omitempty"`
TopicPrefix string `json:"topic_prefix,omitempty"`
LastConnected string `json:"last_connected,omitempty"`
Message string `json:"message,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
}
// mqttAuthRequest — тело запроса от EMQX при MQTT CONNECT.
// EMQX 5.x посылает JSON: username, password, clientid, peerhost.
type mqttAuthRequest struct {
Username string `json:"username"`
Password string `json:"password"`
ClientID string `json:"clientid"`
PeerHost string `json:"peerhost"`
}
// mqttAuthResponse — ответ для EMQX. Всегда HTTP 200.
// result = "allow" | "deny"
// ACL — список правил pub/sub, изолирует топики по устройству.
type mqttAuthResponse struct {
Result string `json:"result"`
ACL []aclRule `json:"acl,omitempty"`
}
// aclRule — одно правило ACL для EMQX HTTP auth plugin.
// permission: "allow" | "deny"
// action: "publish" | "subscribe" | "all"
// topic: точный топик или wildcard (#, +)
type aclRule struct {
Permission string `json:"permission"`
Action string `json:"action"`
Topic string `json:"topic"`
}
// ——————————————————————————————————————————
// Вспомогательные функции
// ——————————————————————————————————————————
// deviceToResponse конвертирует IoTDevice CRD в ответ API.
// password передаётся отдельно — берётся из Secret только в GetIoTDevice.
func deviceToResponse(d *iotv1alpha1.IoTDevice, password string) iotDeviceResponse {
resp := iotDeviceResponse{
Name: d.Name,
Namespace: d.Namespace,
DeviceID: d.Spec.DeviceID,
Enabled: d.Spec.Enabled,
Phase: d.Status.Phase,
MQTTUsername: d.Status.MQTTUsername,
MQTTPassword: password,
SecretName: d.Status.SecretName,
TopicPrefix: d.Status.TopicPrefix,
Message: d.Status.Message,
Metadata: d.Spec.Metadata,
}
if d.Status.LastConnected != nil && !d.Status.LastConnected.IsZero() {
resp.LastConnected = d.Status.LastConnected.UTC().Format(time.RFC3339)
}
if !d.CreationTimestamp.IsZero() {
resp.CreatedAt = d.CreationTimestamp.UTC().Format("2006-01-02 15:04:05 UTC")
}
return resp
}
// ——————————————————————————————————————————
// MQTT Auth — Этап 2
// ——————————————————————————————————————————
// MQTTAuth — POST /internal/mqtt/auth
// Вызывается EMQX при каждом MQTT CONNECT.
// НЕ защищён JWT middleware — доступен только из кластера (путь /internal/).
//
// Логика аутентификации:
// 1. Распарсить username → namespace + deviceId
// 2. Получить Secret iot-{deviceId} в namespace
// 3. Constant-time сравнение пароля (защита от timing attacks)
// 4. Проверить что IoTDevice существует и enabled=true
// 5. Обновить status.lastConnected в IoTDevice
func (h *Handler) MQTTAuth(w http.ResponseWriter, r *http.Request) {
var req mqttAuthRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// Плохой JSON от EMQX — deny, но не 400 (EMQX игнорирует non-200)
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
// Bridge — внутренний MQTT bridge клиент. Проверяем фиксированные credentials
// до парсинга namespace_deviceId.
if h.BridgeUsername != "" && req.Username == h.BridgeUsername {
if subtle.ConstantTimeCompare([]byte(h.BridgePassword), []byte(req.Password)) == 1 {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"})
return
}
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
// Парсим username: "{namespace}_{deviceId}"
// Namespace содержит только [a-z0-9-], первый "_" — разделитель.
idx := strings.Index(req.Username, "_")
if idx < 0 {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
ns := req.Username[:idx]
deviceID := req.Username[idx+1:]
if ns == "" || deviceID == "" {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
// Читаем Secret с MQTT credentials
secretName := "iot-" + deviceID
secret := &corev1.Secret{}
if err := h.K8s.Get(r.Context(), client.ObjectKey{Namespace: ns, Name: secretName}, secret); err != nil {
// Secret не найден или ошибка k8s — deny
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
// Constant-time сравнение пароля — защита от timing attacks
storedPassword := secret.Data["mqtt-password"]
if subtle.ConstantTimeCompare(storedPassword, []byte(req.Password)) != 1 {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
// Ищем IoTDevice по Spec.DeviceID (имя K8s объекта может отличаться от deviceID)
deviceList := &iotv1alpha1.IoTDeviceList{}
if err := h.K8s.List(r.Context(), deviceList, client.InNamespace(ns)); err != nil {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
var device *iotv1alpha1.IoTDevice
for i := range deviceList.Items {
if deviceList.Items[i].Spec.DeviceID == deviceID {
device = &deviceList.Items[i]
break
}
}
if device == nil {
// IoTDevice с таким deviceID не найден — deny
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
if !device.Spec.Enabled {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
// Обновляем lastConnected в статусе устройства (best-effort, ошибка не критична)
now := metav1.NewTime(time.Now().UTC())
device.Status.LastConnected = &now
if err := h.K8s.Status().Update(r.Context(), device); err != nil {
h.Log.Warn("mqtt auth: failed to update lastConnected", "device", deviceID, "err", err)
// Продолжаем — это некритично, устройство всё равно авторизовано
}
// Формируем ACL правила для этого подключения.
// Топик устройства: "{namespace}/telemetry/{deviceId}"
// Это то что строит эмулятор: topicPrefix + "telemetry/" + device_id
// topicPrefix = "{ns}/" → итого "{ns}/telemetry/{deviceId}"
deviceTopic := ns + "/telemetry/" + deviceID
var aclRules []aclRule
if req.ClientID == "sless-iot-bridge" {
// Bridge подписывается на "+/telemetry/+" (все тенанты) — разрешаем
// Bridge НЕ публикует через MQTT — только читает
aclRules = []aclRule{
{Permission: "allow", Action: "subscribe", Topic: "+/telemetry/+"},
{Permission: "deny", Action: "all", Topic: "#"},
}
} else {
// Обычное IoT устройство: только свой топик
aclRules = []aclRule{
{Permission: "allow", Action: "publish", Topic: deviceTopic},
{Permission: "allow", Action: "subscribe", Topic: deviceTopic},
{Permission: "deny", Action: "all", Topic: "#"},
}
}
writeJSON(w, http.StatusOK, mqttAuthResponse{
Result: "allow",
ACL: aclRules,
})
}
// ——————————————————————————————————————————
// IoT Device CRUD — Этап 4
// ——————————————————————————————————————————
// CreateIoTDevice — POST /v1/namespaces/{namespace}/iot/devices
// Создаёт IoTDevice CRD. Контроллер асинхронно сгенерирует MQTT credentials.
// credentials доступны через GET /devices/{name} после reconcile (phase=Active).
func (h *Handler) CreateIoTDevice(w http.ResponseWriter, r *http.Request) {
ns := namespace(r)
var req iotDeviceCreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error()))
return
}
if req.Name == "" {
writeJSON(w, http.StatusBadRequest, errResp("name is required"))
return
}
if req.DeviceID == "" {
writeJSON(w, http.StatusBadRequest, errResp("device_id is required"))
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
device := &iotv1alpha1.IoTDevice{
ObjectMeta: metav1.ObjectMeta{
Name: req.Name,
Namespace: ns,
},
Spec: iotv1alpha1.IoTDeviceSpec{
DeviceID: req.DeviceID,
Enabled: enabled,
Metadata: req.Metadata,
},
}
if err := h.K8s.Create(r.Context(), device); err != nil {
if errors.IsAlreadyExists(err) {
writeJSON(w, http.StatusConflict, errResp("iot device already exists"))
return
}
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
writeJSON(w, http.StatusCreated, deviceToResponse(device, ""))
}
// ListIoTDevices — GET /v1/namespaces/{namespace}/iot/devices
// Возвращает список устройств БЕЗ паролей (security by design).
func (h *Handler) ListIoTDevices(w http.ResponseWriter, r *http.Request) {
ns := namespace(r)
list := &iotv1alpha1.IoTDeviceList{}
if err := h.K8s.List(r.Context(), list, client.InNamespace(ns)); err != nil {
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
result := make([]iotDeviceResponse, 0, len(list.Items))
for i := range list.Items {
result = append(result, deviceToResponse(&list.Items[i], ""))
}
writeJSON(w, http.StatusOK, result)
}
// GetIoTDevice — GET /v1/namespaces/{namespace}/iot/devices/{name}
// Возвращает устройство включая mqtt_password из Secret.
// mqtt_password нужен пользователю для конфигурации физического устройства.
func (h *Handler) GetIoTDevice(w http.ResponseWriter, r *http.Request) {
ns := namespace(r)
name := pathVar(r, "name")
device := &iotv1alpha1.IoTDevice{}
if err := h.K8s.Get(r.Context(), client.ObjectKey{Namespace: ns, Name: name}, device); err != nil {
if errors.IsNotFound(err) {
writeJSON(w, http.StatusNotFound, errResp("iot device not found"))
return
}
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
// Читаем пароль из Secret — если ещё не создан (phase=Pending), password будет пустым
password := ""
if device.Status.SecretName != "" {
secret := &corev1.Secret{}
err := h.K8s.Get(r.Context(), client.ObjectKey{Namespace: ns, Name: device.Status.SecretName}, secret)
if err == nil {
password = string(secret.Data["mqtt-password"])
}
// Если Secret не найден — просто передаём пустой пароль (устройство ещё provisioning)
}
writeJSON(w, http.StatusOK, deviceToResponse(device, password))
}
// DeleteIoTDevice — DELETE /v1/namespaces/{namespace}/iot/devices/{name}
// Удаляет IoTDevice CRD. Контроллер через finalizer удалит Secret каскадно.
func (h *Handler) DeleteIoTDevice(w http.ResponseWriter, r *http.Request) {
ns := namespace(r)
name := pathVar(r, "name")
device := &iotv1alpha1.IoTDevice{}
if err := h.K8s.Get(r.Context(), client.ObjectKey{Namespace: ns, Name: name}, device); err != nil {
if errors.IsNotFound(err) {
writeJSON(w, http.StatusNotFound, errResp("iot device not found"))
return
}
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
if err := h.K8s.Delete(r.Context(), device); err != nil {
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
w.WriteHeader(http.StatusNoContent)
}
// UpdateIoTDevice — PATCH /v1/namespaces/{namespace}/iot/devices/{name}
// Позволяет включить/отключить устройство (spec.enabled).
// Контроллер увидит изменение и обновит status.phase.
func (h *Handler) UpdateIoTDevice(w http.ResponseWriter, r *http.Request) {
ns := namespace(r)
name := pathVar(r, "name")
var req iotDeviceUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error()))
return
}
if req.Enabled == nil {
writeJSON(w, http.StatusBadRequest, errResp("enabled field is required"))
return
}
device := &iotv1alpha1.IoTDevice{}
if err := h.K8s.Get(r.Context(), client.ObjectKey{Namespace: ns, Name: name}, device); err != nil {
if errors.IsNotFound(err) {
writeJSON(w, http.StatusNotFound, errResp("iot device not found"))
return
}
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
device.Spec.Enabled = *req.Enabled
if err := h.K8s.Update(r.Context(), device); err != nil {
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
writeJSON(w, http.StatusOK, deviceToResponse(device, ""))
}
// ——————————————————————————————————————————
// MQTT ACL — авторизация pub/sub
// ——————————————————————————————————————————
// mqttAclRequest — тело запроса от EMQX при каждом publish/subscribe.
type mqttAclRequest struct {
Username string `json:"username"`
ClientID string `json:"clientid"`
Action string `json:"action"` // "publish" | "subscribe"
Topic string `json:"topic"`
}
// MQTTAcl — POST /internal/mqtt/acl
// Вызывается EMQX для каждого pub/sub действия.
// НЕ защищён JWT — доступен только из кластера.
//
// Логика разрешений:
// 1. Bridge clientid "sless-iot-bridge" — subscribe на любой топик (нужен для "+/telemetry/+")
// 2. IoT Device (username "{ns}_{deviceId}") — publish/subscribe на "{ns}/telemetry/{deviceId}"
func (h *Handler) MQTTAcl(w http.ResponseWriter, r *http.Request) {
var req mqttAclRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
// Специальный случай: mqtt-bridge подписывается на "+/telemetry/+" (все тенанты).
// Публикация bridge НЕ разрешена — только чтение.
if req.ClientID == "sless-iot-bridge" {
if req.Action == "subscribe" {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"})
} else {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
}
return
}
// Bridge — внутренний MQTT bridge клиент. Проверяем фиксированные credentials
// до парсинга namespace_deviceId.
// Парсим username → namespace + deviceId (формат: "{ns}_{deviceId}")
// strings.Index находит ПЕРВЫЙ '_' — namespace содержит только дефисы
idx := strings.Index(req.Username, "_")
if idx < 0 {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
ns := req.Username[:idx]
deviceID := req.Username[idx+1:]
if ns == "" || deviceID == "" {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
// Разрешённый топик: "{ns}/telemetry/{deviceId}"
// Это то что эмулятор строит как: topicPrefix + "telemetry/" + device_id
// topicPrefix = "{ns}/" → итого "{ns}/telemetry/{deviceId}"
allowedTopic := ns + "/telemetry/" + deviceID
if req.Topic == allowedTopic {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"})
return
}
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
}
@@ -0,0 +1,55 @@
// Создано: 2026-04-05
// iot_telemetry_handler.go — REST handler для чтения IoT телеметрии.
//
// Endpoint:
// GET /v1/namespaces/{namespace}/iot/telemetry?device={id}&limit={n}
//
// Авторизация: Bearer JWT → namespace validation (как все /v1/ маршруты).
// Данные берутся из per-tenant Postgres DB через IoTPostgresStore.
// Если IoTPG не инициализирован (IOT_PG_DSN не задан) — возвращает 503.
package handler
import (
"net/http"
"strconv"
"github.com/gorilla/mux"
"gitea.services.ngcloud.ru/Nail/IoT/internal/storage/iotpg"
)
// ListIoTTelemetry обрабатывает GET /v1/namespaces/{namespace}/iot/telemetry.
// Параметры: device (опционально), limit (default 50, max 1000).
func (h *Handler) ListIoTTelemetry(w http.ResponseWriter, r *http.Request) {
if h.IoTPG == nil {
writeJSON(w, http.StatusServiceUnavailable, errResp("IoT telemetry storage not configured"))
return
}
ns := mux.Vars(r)["namespace"]
deviceID := r.URL.Query().Get("device_id")
limit := 50
if ls := r.URL.Query().Get("limit"); ls != "" {
if n, err := strconv.Atoi(ls); err == nil && n > 0 {
limit = n
}
}
rows, err := h.IoTPG.QueryTelemetry(r.Context(), ns, deviceID, limit, 0)
if err != nil {
h.Log.Error("query IoT telemetry", "namespace", ns, "device", deviceID, "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to query telemetry"))
return
}
// Возвращаем пустой массив вместо null — удобнее для JS
if rows == nil {
rows = []iotpg.TelemetryRow{}
}
writeJSON(w, http.StatusOK, map[string]any{
"items": rows,
"count": len(rows),
})
}
+165
View File
@@ -0,0 +1,165 @@
// Изменено: 2026-04-05
// Auth middleware — проверяет Bearer JWT-токен из заголовка Authorization.
//
// Архитектура аутентификации:
// - В v1 токен — это JWT облака (nubes), выданный при логине.
// - Оператор НЕ проверяет подпись JWT (публичный ключ nubes недоступен внутри кластера).
// - Проверяется только структура JWT и claim "sub" (не пустой) и "exp" (не истёк).
// - Полная проверка подлинности токена происходит в провайдере terraform через PingNubesAPI.
// - Такой подход называют "trusted perimeter": оператор доступен только внутри кластера,
// внешний доступ — через Ingress, где токен уже проверен на уровне API-шлюза.
//
// TODO v2: получать публичный ключ из nubes JWKS endpoint и проверять подпись RS256.
//
// ──────────────────────────────────────────────────────────────────────────────
// ТЕСТОВЫЙ РЕЖИМ (authTestMode = true):
// Принимается ЛЮБАЯ строка без пробелов — не обязательно JWT.
// Это позволяет тестировать UI/API без реального токена nubes.
// Строка используется как идентификатор пользователя (аналог JWT.sub),
// namespace выводится из неё так же: SHA256 → первые 16 байт hex → "sless-{hex}".
//
// ⚠️ ПЕРЕД ВЫХОДОМ В ПРОД: установить authTestMode = false.
// Для возврата к строгой JWT-валидации: одна строка ниже.
// ──────────────────────────────────────────────────────────────────────────────
package middleware
import (
"encoding/base64"
"encoding/json"
"log/slog"
"net/http"
"strings"
"time"
)
// authTestMode — ТЕСТОВЫЙ РЕЖИМ аутентификации.
//
// true → принимается любая строка без пробелов (не обязательно JWT).
//
// Используется при разработке UI когда реальный токен nubes не нужен.
//
// false → строгая проверка JWT (структура + sub + exp).
//
// Необходимо установить перед деплоем в прод.
//
// Чтобы вернуться к JWT: изменить на false.
const authTestMode = true
// Auth возвращает middleware которое требует заголовок:
//
// Authorization: Bearer <token>
//
// В тестовом режиме (authTestMode=true): принимает любую строку без пробелов.
// В боевом режиме (authTestMode=false): требует валидный JWT (sub + exp).
func Auth(log *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Authorization")
if header == "" {
log.Warn("auth: missing authorization header", "remote", r.RemoteAddr, "path", r.URL.Path)
http.Error(w, `{"error":"authorization required"}`, http.StatusUnauthorized)
return
}
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
log.Warn("auth: invalid authorization format", "remote", r.RemoteAddr)
http.Error(w, `{"error":"invalid authorization format, use Bearer <token>"}`, http.StatusUnauthorized)
return
}
token := parts[1]
// ── ТЕСТОВЫЙ РЕЖИМ ───────────────────────────────────────────────────
// Если authTestMode=true и токен — просто строка без пробелов (не JWT),
// пропускаем JWT-валидацию. Строка обрабатывается как произвольный sub.
// Чтобы вернуть строгую проверку: установить authTestMode = false.
if authTestMode && isPlainToken(token) {
log.Info("auth: test mode — plain token accepted", "remote", r.RemoteAddr, "path", r.URL.Path)
next.ServeHTTP(w, r)
return
}
// ── БОЕВОЙ РЕЖИМ / JWT ───────────────────────────────────────────────
if err := validateJWT(token); err != nil {
log.Warn("auth: invalid token", "remote", r.RemoteAddr, "path", r.URL.Path, "reason", err.Error())
http.Error(w, `{"error":"invalid token"}`, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// isPlainToken возвращает true если токен — непустая строка без пробелов и НЕ является JWT.
// JWT определяется по наличию ровно двух точек (xxx.yyy.zzz).
// Логика: если строка выглядит как JWT — проверять через validateJWT (даже в testMode).
func isPlainToken(token string) bool {
if token == "" || strings.ContainsAny(token, " \t\n\r") {
return false
}
// Если три части через точку — скорее всего JWT, проверять нормально
parts := strings.Split(token, ".")
return len(parts) != 3
}
// validateJWT проверяет структуру JWT и claim "sub" и "exp".
// Подпись НЕ проверяется — см. комментарий к файлу.
func validateJWT(token string) error {
jwtParts := strings.Split(token, ".")
if len(jwtParts) != 3 {
return &jwtError{"not a JWT: expected 3 parts"}
}
payload := jwtParts[1]
// JWT использует base64url без padding
switch len(payload) % 4 {
case 2:
payload += "=="
case 3:
payload += "="
}
decoded, err := base64.URLEncoding.DecodeString(payload)
if err != nil {
decoded, err = base64.StdEncoding.DecodeString(payload)
if err != nil {
return &jwtError{"cannot decode JWT payload"}
}
}
var claims struct {
Sub string `json:"sub"`
Exp int64 `json:"exp"`
}
if err := json.Unmarshal(decoded, &claims); err != nil {
return &jwtError{"cannot parse JWT claims"}
}
if claims.Sub == "" {
return &jwtError{"missing sub claim"}
}
if claims.Exp > 0 && claims.Exp < time.Now().Unix() {
return &jwtError{"token expired"}
}
return nil
}
type jwtError struct{ msg string }
func (e *jwtError) Error() string { return e.msg }
// verifySignature — точка вставки для проверки подписи JWT (v2).
//
// Текущее состояние (v1): подпись НЕ проверяется.
// Причина: публичный ключ nubes недоступен внутри кластера без JWKS endpoint.
// Безопасность обеспечивается "trusted perimeter" — оператор доступен только изнутри кластера.
//
// Когда nubes предоставит JWKS endpoint, реализация:
//
// func verifySignature(token string) error {
// // 1. Получить JWKS: GET {NUBES_JWKS_URL}/.well-known/jwks.json
// // 2. Найти ключ по "kid" из JWT header
// // 3. Проверить подпись RS256/ES256
// // Пример: github.com/lestrrat-go/jwx/v2/jwk + jwt.Parse
// return nil
// }
//
// После реализации добавить вызов в validateJWT после проверки структуры:
//
// if err := verifySignature(token); err != nil {
// return &jwtError{"signature verification failed: " + err.Error()}
// }
+38
View File
@@ -0,0 +1,38 @@
// Изменено: 2026-03-07
// Logging middleware — логирует каждый HTTP запрос через slog.
// Записывает метод, путь, статус и длительность.
package middleware
import (
"log/slog"
"net/http"
"time"
)
// responseWriter оборачивает http.ResponseWriter чтобы захватить статус ответа.
type responseWriter struct {
http.ResponseWriter
status int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.status = code
rw.ResponseWriter.WriteHeader(code)
}
// Logging возвращает middleware которое логирует все запросы через slog.
func Logging(log *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rw, r)
log.Info("http",
"method", r.Method,
"path", r.URL.Path,
"status", rw.status,
"duration_ms", time.Since(start).Milliseconds(),
"remote", r.RemoteAddr,
)
})
}
+67
View File
@@ -0,0 +1,67 @@
// Создано: 2026-04-12
// router.go — регистрация IoT REST-маршрутов.
// Перенесено из sless — только IoT-специфичные маршруты.
// CORS для https://iot.kube5s.ru, JWT auth через middleware.Auth.
package api
import (
"log/slog"
"net/http"
"github.com/gorilla/mux"
"gitea.services.ngcloud.ru/Nail/IoT/internal/api/handler"
"gitea.services.ngcloud.ru/Nail/IoT/internal/api/middleware"
)
// corsMiddleware добавляет CORS-заголовки для IoT Консоли на https://iot.kube5s.ru.
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "https://iot.kube5s.ru")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
w.Header().Set("Access-Control-Max-Age", "600")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// NewRouter собирает gorilla/mux роутер со всеми IoT маршрутами.
func NewRouter(h *handler.Handler, log *slog.Logger) http.Handler {
r := mux.NewRouter()
// IoT Консоль — публичный HTML
r.HandleFunc("/console", ServeIoTConsole).Methods(http.MethodGet)
// IoT Admin — страница администратора
r.HandleFunc("/iot-admin", ServeIoTAdmin).Methods(http.MethodGet)
r.HandleFunc("/iot-admin/stats", h.AdminStats).Methods(http.MethodGet)
// /v1 — API маршруты, защищены JWT
v1 := r.PathPrefix("/v1").Subrouter()
// IoT Devices CRUD
v1.HandleFunc("/namespaces/{namespace}/iot/devices", h.ListIoTDevices).Methods(http.MethodGet)
v1.HandleFunc("/namespaces/{namespace}/iot/devices", h.CreateIoTDevice).Methods(http.MethodPost)
v1.HandleFunc("/namespaces/{namespace}/iot/devices/{name}", h.GetIoTDevice).Methods(http.MethodGet)
v1.HandleFunc("/namespaces/{namespace}/iot/devices/{name}", h.DeleteIoTDevice).Methods(http.MethodDelete)
v1.HandleFunc("/namespaces/{namespace}/iot/devices/{name}", h.UpdateIoTDevice).Methods(http.MethodPatch)
// IoT Telemetry
v1.HandleFunc("/namespaces/{namespace}/iot/telemetry", h.ListIoTTelemetry).Methods(http.MethodGet)
// MQTT Auth — без JWT, вызывается EMQX из кластера
r.HandleFunc("/internal/mqtt/auth", h.MQTTAuth).Methods(http.MethodPost)
r.HandleFunc("/internal/mqtt/acl", h.MQTTAcl).Methods(http.MethodPost)
// JWT auth middleware для /v1/
v1.Use(func(next http.Handler) http.Handler {
return middleware.Auth(log, next)
})
return corsMiddleware(middleware.Logging(log, r))
}
+547
View File
@@ -0,0 +1,547 @@
<!DOCTYPE html>
<!-- Создано: 2026-04-06
iot-admin.html — страница администратора IoT pipeline.
Показывает: PostgreSQL stats per tenant, Kafka consumer lag, K8s pod statuses.
Auth: ADMIN_STATS_TOKEN вводится вручную и хранится в sessionStorage.
Раздаётся по GET /iot-admin (go:embed в бинарнике оператора).
НЕ для конечных пользователей — только для администратора платформы. -->
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nubes IoT Admin</title>
<link rel="icon" href="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/favicon.png">
<style>
/* Nubes brand palette — те же цвета что в iot-console.html */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: #001120; color: #e2ecf6; min-height: 100vh;
}
/* Navbar */
.navbar {
background: #001C34; border-bottom: 1px solid #0b2d50;
padding: 0 28px; height: 58px;
display: flex; align-items: center; gap: 14px;
}
.navbar-logo { display: flex; align-items: center; gap: 10px; text-decoration: none; }
.navbar-logo img { height: 18px; filter: brightness(0) invert(1); }
.navbar-logo-sep { width: 1px; height: 18px; background: #1a4a73; margin: 0 4px; }
.navbar-title { font-size: 15px; font-weight: 600; color: #e2ecf6; }
.navbar-badge {
background: #2d1a00; border: 1px solid #7a3a00; color: #f0a030;
font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 4px;
letter-spacing: 0.5px; text-transform: uppercase;
}
.navbar-spacer { flex: 1; }
.navbar-refresh {
background: #0f3a60; border: 1px solid #1a5a8a; color: #7fc8f8;
padding: 6px 14px; border-radius: 6px; font-size: 13px; cursor: pointer;
transition: background 0.15s;
}
.navbar-refresh:hover { background: #1a5080; }
.navbar-refresh:disabled { opacity: 0.4; cursor: not-allowed; }
/* Layout */
.container { max-width: 1280px; margin: 0 auto; padding: 28px 24px; }
/* Auth box */
.auth-box {
background: #001929; border: 1px solid #0b2d50; border-radius: 12px;
padding: 40px; max-width: 480px; margin: 80px auto;
display: flex; flex-direction: column; gap: 16px;
}
.auth-box h2 { font-size: 20px; font-weight: 600; color: #7fc8f8; }
.auth-box p { font-size: 13px; color: #6b8eaa; }
.auth-input {
background: #001120; border: 1px solid #1a4a73; color: #e2ecf6;
padding: 10px 14px; border-radius: 8px; font-size: 14px; font-family: monospace;
width: 100%; outline: none;
}
.auth-input:focus { border-color: #1a7fd4; }
.auth-btn {
background: #1a7fd4; border: none; color: #fff;
padding: 10px 20px; border-radius: 8px; font-size: 14px; cursor: pointer;
font-weight: 600; transition: background 0.15s;
}
.auth-btn:hover { background: #1a6ab8; }
.auth-error { color: #f87171; font-size: 13px; }
/* Section header */
.section-header {
display: flex; align-items: center; gap: 10px;
margin-bottom: 16px; padding-bottom: 10px;
border-bottom: 1px solid #0b2d50;
}
.section-icon { width: 20px; height: 20px; opacity: 0.7; }
.section-title { font-size: 16px; font-weight: 600; color: #a0c4e8; }
.section { margin-bottom: 32px; }
/* Cards grid */
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
/* Stat card */
.card {
background: #001929; border: 1px solid #0b2d50; border-radius: 10px;
padding: 20px;
}
.card-title { font-size: 12px; color: #6b8eaa; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
.card-value { font-size: 28px; font-weight: 700; color: #e2ecf6; }
.card-sub { font-size: 12px; color: #6b8eaa; margin-top: 4px; }
.card-accent { color: #1a7fd4; }
.card-warn { color: #f59e0b; }
.card-ok { color: #34d399; }
.card-err { color: #f87171; }
/* Pod status card */
.pod-card {
background: #001929; border: 1px solid #0b2d50; border-radius: 10px;
padding: 20px; display: flex; flex-direction: column; gap: 8px;
}
.pod-name { font-size: 13px; font-weight: 600; color: #7fc8f8; font-family: monospace; }
.pod-row { display: flex; justify-content: space-between; font-size: 12px; }
.pod-label { color: #6b8eaa; }
.pod-val { color: #e2ecf6; }
.badge {
display: inline-block; padding: 2px 8px; border-radius: 4px;
font-size: 11px; font-weight: 700;
}
.badge-ok { background: #052e16; color: #34d399; border: 1px solid #064e3b; }
.badge-warn { background: #2d1c00; color: #f59e0b; border: 1px solid #4d3000; }
.badge-err { background: #300; color: #f87171; border: 1px solid #500; }
/* Tenant table */
.tenant-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.tenant-table th {
text-align: left; padding: 8px 12px; color: #6b8eaa;
border-bottom: 1px solid #0b2d50; font-weight: 600; font-size: 11px;
text-transform: uppercase; letter-spacing: 0.3px;
}
.tenant-table td { padding: 10px 12px; border-bottom: 1px solid #071a28; vertical-align: top; }
.tenant-table tr:last-child td { border-bottom: none; }
.tenant-table tr:hover td { background: rgba(26,127,212,0.05); }
.ns-tag {
font-family: monospace; font-size: 12px; color: #7fc8f8;
background: #0b2d50; padding: 2px 6px; border-radius: 4px;
}
.num-big { font-size: 16px; font-weight: 600; color: #e2ecf6; }
.num-small { font-size: 12px; color: #6b8eaa; }
/* Latest msgs mini list */
.latest-list { display: flex; flex-direction: column; gap: 4px; }
.latest-item {
background: #001120; border: 1px solid #0b2d50; border-radius: 6px;
padding: 6px 10px; font-size: 11px;
}
.latest-dev { color: #7fc8f8; font-weight: 600; }
.latest-ts { color: #6b8eaa; margin-left: 6px; }
.latest-payload { color: #a0c4e8; margin-top: 2px; word-break: break-all; font-family: monospace; }
/* Last updated */
.last-updated { font-size: 12px; color: #2d5070; text-align: center; margin-top: 16px; }
/* Status dot */
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
.dot-ok { background: #34d399; }
.dot-warn { background: #f59e0b; }
.dot-err { background: #f87171; }
/* Kafka lag bar */
.lag-bar-wrap { background: #001120; border-radius: 4px; height: 6px; margin-top: 8px; overflow: hidden; }
.lag-bar { height: 100%; border-radius: 4px; transition: width 0.5s; min-width: 2px; }
.lag-bar-ok { background: #34d399; }
.lag-bar-warn { background: #f59e0b; }
/* Spinner */
.spinner {
border: 3px solid #0b2d50; border-top-color: #1a7fd4;
border-radius: 50%; width: 32px; height: 32px;
animation: spin 0.8s linear infinite;
margin: 60px auto;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Error banner */
.error-banner {
background: #1a0000; border: 1px solid #5a0000; color: #f87171;
padding: 12px 16px; border-radius: 8px; font-size: 13px; margin-bottom: 20px;
}
/* Auto-refresh indicator */
.refresh-timer {
font-size: 11px; color: #2d5070; display: flex; align-items: center; gap: 6px;
}
.refresh-progress {
width: 60px; height: 2px; background: #0b2d50; border-radius: 2px; overflow: hidden;
}
.refresh-bar {
height: 100%; background: #1a7fd4; border-radius: 2px;
transition: width 1s linear;
}
</style>
</head>
<body>
<!-- Navbar -->
<nav class="navbar">
<a class="navbar-logo" href="#" aria-label="Nubes">
<img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
</a>
<div class="navbar-logo-sep"></div>
<span class="navbar-title">IoT Admin</span>
<span class="navbar-badge">Admin Only</span>
<div class="navbar-spacer"></div>
<div class="refresh-timer" id="refreshTimer" style="display:none">
<span id="refreshCountdown">30</span>s
<div class="refresh-progress"><div class="refresh-bar" id="refreshBar" style="width:100%"></div></div>
</div>
<button class="navbar-refresh" id="btnRefresh" onclick="loadStats()" disabled>Обновить</button>
</nav>
<!-- Main content -->
<div class="container">
<!-- Auth box (показывается до ввода токена) -->
<div class="auth-box" id="authBox">
<h2>Доступ для администратора</h2>
<p>Введите ADMIN_STATS_TOKEN для просмотра статистики IoT pipeline.</p>
<input class="auth-input" id="tokenInput" type="password"
placeholder="Bearer token..." autocomplete="off"
onkeydown="if(event.key==='Enter') doAuth()">
<button class="auth-btn" onclick="doAuth()">Войти</button>
<div class="auth-error" id="authError" style="display:none"></div>
</div>
<!-- Контент (показывается после авторизации) -->
<div id="mainContent" style="display:none">
<div class="error-banner" id="errorBanner" style="display:none"></div>
<!-- Spinner при загрузке -->
<div class="spinner" id="spinner"></div>
<!-- Данные -->
<div id="dataContent" style="display:none">
<!-- Kafka -->
<div class="section">
<div class="section-header">
<svg class="section-icon" viewBox="0 0 24 24" fill="none" stroke="#7fc8f8" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
</svg>
<span class="section-title">Kafka</span>
</div>
<div class="cards" id="kafkaCards"></div>
</div>
<!-- K8s Pods -->
<div class="section">
<div class="section-header">
<svg class="section-icon" viewBox="0 0 24 24" fill="none" stroke="#7fc8f8" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>
</svg>
<span class="section-title">Pods</span>
</div>
<div class="cards" id="podCards"></div>
</div>
<!-- PostgreSQL per tenant -->
<div class="section">
<div class="section-header">
<svg class="section-icon" viewBox="0 0 24 24" fill="none" stroke="#7fc8f8" stroke-width="2">
<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/>
<path d="M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3"/>
</svg>
<span class="section-title">PostgreSQL — Telemetry</span>
</div>
<div class="cards" style="margin-bottom:16px" id="pgSummaryCards"></div>
<div style="background:#001929;border:1px solid #0b2d50;border-radius:10px;overflow:auto">
<table class="tenant-table" id="tenantTable">
<thead>
<tr>
<th>Namespace</th>
<th>Total</th>
<th>Last 1h</th>
<th>Last 24h</th>
<th>Последние сообщения</th>
</tr>
</thead>
<tbody id="tenantTableBody"></tbody>
</table>
</div>
</div>
<div class="last-updated" id="lastUpdated"></div>
</div>
</div>
</div>
<script>
// ── State ──────────────────────────────────────────────────────────────────
const API_BASE = window.location.origin;
let adminToken = sessionStorage.getItem('iot_admin_token') || '';
let refreshInterval = null;
let refreshCountdown = 30;
// ── Auth ───────────────────────────────────────────────────────────────────
function doAuth() {
const input = document.getElementById('tokenInput').value.trim();
if (!input) return;
adminToken = input;
sessionStorage.setItem('iot_admin_token', adminToken);
document.getElementById('authBox').style.display = 'none';
document.getElementById('mainContent').style.display = 'block';
loadStats();
}
function showAuthError(msg) {
const el = document.getElementById('authError');
el.textContent = msg;
el.style.display = 'block';
// Сбрасываем токен — он не подошёл
adminToken = '';
sessionStorage.removeItem('iot_admin_token');
document.getElementById('authBox').style.display = 'block';
document.getElementById('mainContent').style.display = 'none';
if (refreshInterval) { clearInterval(refreshInterval); refreshInterval = null; }
document.getElementById('refreshTimer').style.display = 'none';
}
// Если токен уже в sessionStorage — пропускаем auth box
if (adminToken) {
document.getElementById('authBox').style.display = 'none';
document.getElementById('mainContent').style.display = 'block';
document.getElementById('spinner').style.display = 'block';
}
// ── Load stats ─────────────────────────────────────────────────────────────
async function loadStats() {
if (!adminToken) return;
document.getElementById('btnRefresh').disabled = true;
document.getElementById('spinner').style.display = 'block';
document.getElementById('dataContent').style.display = 'none';
document.getElementById('errorBanner').style.display = 'none';
resetRefreshTimer();
try {
const resp = await fetch(`${API_BASE}/iot-admin/stats`, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
if (resp.status === 401 || resp.status === 503) {
const body = await resp.json().catch(() => ({}));
showAuthError(body.error || 'Ошибка авторизации');
document.getElementById('spinner').style.display = 'none';
return;
}
if (!resp.ok) {
throw new Error(`HTTP ${resp.status}`);
}
const data = await resp.json();
renderAll(data);
document.getElementById('spinner').style.display = 'none';
document.getElementById('dataContent').style.display = 'block';
document.getElementById('refreshTimer').style.display = 'flex';
document.getElementById('lastUpdated').textContent =
'Обновлено: ' + new Date(data.collected_at).toLocaleTimeString('ru-RU');
setupAutoRefresh();
} catch (e) {
document.getElementById('spinner').style.display = 'none';
showBanner('Ошибка загрузки данных: ' + e.message);
document.getElementById('dataContent').style.display = 'block';
} finally {
document.getElementById('btnRefresh').disabled = false;
}
}
function showBanner(msg) {
const el = document.getElementById('errorBanner');
el.textContent = msg;
el.style.display = 'block';
}
// ── Auto-refresh ───────────────────────────────────────────────────────────
function setupAutoRefresh() {
if (refreshInterval) return; // уже запущен
refreshInterval = setInterval(() => {
refreshCountdown--;
document.getElementById('refreshCountdown').textContent = refreshCountdown;
const pct = (refreshCountdown / 30) * 100;
document.getElementById('refreshBar').style.width = pct + '%';
if (refreshCountdown <= 0) {
clearInterval(refreshInterval);
refreshInterval = null;
loadStats();
}
}, 1000);
}
function resetRefreshTimer() {
if (refreshInterval) { clearInterval(refreshInterval); refreshInterval = null; }
refreshCountdown = 30;
document.getElementById('refreshCountdown').textContent = '30';
document.getElementById('refreshBar').style.width = '100%';
}
// ── Render ─────────────────────────────────────────────────────────────────
function renderAll(data) {
renderKafka(data.kafka || {});
renderPods(data.pods || {});
renderPostgres(data.postgres || {});
}
// Kafka section
function renderKafka(kafka) {
const el = document.getElementById('kafkaCards');
if (kafka.error) {
el.innerHTML = `<div class="card"><div class="card-title">Ошибка</div>
<div class="card-value card-err" style="font-size:14px">${esc(kafka.error)}</div></div>`;
return;
}
const lag = kafka.consumer_lag || 0;
const latest = kafka.latest_offset || 0;
const committed = kafka.committed_offset || 0;
const lagClass = lag === 0 ? 'card-ok' : lag < 100 ? 'card-warn' : 'card-err';
const barClass = lag === 0 ? 'lag-bar-ok' : 'lag-bar-warn';
const barWidth = latest > 0 ? Math.max(2, Math.round((committed / latest) * 100)) : 100;
el.innerHTML = `
<div class="card">
<div class="card-title">Consumer Lag</div>
<div class="card-value ${lagClass}">${lag}</div>
<div class="card-sub">iot-pg-consumer / iot.telemetry</div>
<div class="lag-bar-wrap"><div class="lag-bar ${barClass}" style="width:${barWidth}%"></div></div>
</div>
<div class="card">
<div class="card-title">Latest Offset (всего прошло)</div>
<div class="card-value card-accent">${latest.toLocaleString()}</div>
<div class="card-sub">Kafka log end offset</div>
</div>
<div class="card">
<div class="card-title">Committed Offset</div>
<div class="card-value">${committed.toLocaleString()}</div>
<div class="card-sub">Consumer обработал</div>
</div>`;
}
// Pods section
function renderPods(pods) {
const el = document.getElementById('podCards');
el.innerHTML = '';
const labels = {
'iot-mqtt-bridge': 'MQTT Bridge',
'iot-kafka-consumer': 'Kafka Consumer'
};
for (const [key, pod] of Object.entries(pods)) {
const title = labels[key] || key;
if (pod.error || pod.status === 'not found') {
el.innerHTML += `
<div class="pod-card">
<div class="pod-name">${esc(title)}</div>
<div style="color:#f87171;font-size:12px">${esc(pod.error || 'Pod not found')}</div>
</div>`;
continue;
}
const readyBadge = pod.ready
? '<span class="badge badge-ok">Ready</span>'
: '<span class="badge badge-warn">Not Ready</span>';
const restartColor = pod.restarts > 5 ? 'card-err' : pod.restarts > 0 ? 'card-warn' : 'card-ok';
el.innerHTML += `
<div class="pod-card">
<div class="pod-name">
<span class="dot dot-${pod.ready ? 'ok' : 'warn'}"></span>${esc(title)}
</div>
<div class="pod-row"><span class="pod-label">Pod</span><span class="pod-val" style="font-family:monospace;font-size:11px">${esc(pod.name)}</span></div>
<div class="pod-row"><span class="pod-label">Phase</span><span class="pod-val">${esc(pod.phase)} ${readyBadge}</span></div>
<div class="pod-row"><span class="pod-label">Restarts</span><span class="pod-val ${restartColor}">${pod.restarts}</span></div>
<div class="pod-row"><span class="pod-label">Age</span><span class="pod-val">${esc(pod.age)}</span></div>
</div>`;
}
}
// Postgres section
function renderPostgres(pg) {
const summaryEl = document.getElementById('pgSummaryCards');
const tbodyEl = document.getElementById('tenantTableBody');
if (!pg.reachable) {
summaryEl.innerHTML = `<div class="card"><div class="card-title">Ошибка</div>
<div class="card-value card-err" style="font-size:14px">${esc(pg.error || 'Unreachable')}</div></div>`;
tbodyEl.innerHTML = '';
return;
}
const tenants = pg.tenants || [];
const totalAll = pg.total_all || 0;
summaryEl.innerHTML = `
<div class="card">
<div class="card-title">Всего записей</div>
<div class="card-value card-accent">${totalAll.toLocaleString()}</div>
<div class="card-sub">Все tenant, iot_telemetry</div>
</div>
<div class="card">
<div class="card-title">Tenant-ов</div>
<div class="card-value">${tenants.length}</div>
<div class="card-sub">Активных namespace</div>
</div>`;
tbodyEl.innerHTML = tenants.map(t => {
if (t.error) {
return `<tr><td><span class="ns-tag">${esc(t.namespace)}</span></td>
<td colspan="4" style="color:#f87171">${esc(t.error)}</td></tr>`;
}
const latest = (t.latest || []).slice(0, 3);
const latestHtml = latest.length === 0
? '<span style="color:#2d5070">нет данных</span>'
: `<div class="latest-list">${latest.map(row => `
<div class="latest-item">
<span class="latest-dev">${esc(row.device_id)}</span>
<span class="latest-ts">${formatTs(row.ts)}</span>
<div class="latest-payload">${esc(truncate(JSON.stringify(row.payload), 80))}</div>
</div>`).join('')}</div>`;
return `<tr>
<td><span class="ns-tag">${esc(t.namespace)}</span><br>
<span style="font-size:11px;color:#2d5070">${esc(t.db_name)}</span></td>
<td><span class="num-big">${(t.total||0).toLocaleString()}</span></td>
<td><span class="num-small">${(t.last_1h||0).toLocaleString()}</span></td>
<td><span class="num-small">${(t.last_24h||0).toLocaleString()}</span></td>
<td>${latestHtml}</td>
</tr>`;
}).join('');
}
// ── Utils ──────────────────────────────────────────────────────────────────
function esc(str) {
if (str == null) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function truncate(str, len) {
if (!str) return '';
return str.length > len ? str.slice(0, len) + '…' : str;
}
function formatTs(ts) {
if (!ts) return '';
try {
const d = new Date(ts);
return d.toLocaleTimeString('ru-RU', {hour:'2-digit', minute:'2-digit', second:'2-digit'});
} catch { return ts; }
}
// ── Init ───────────────────────────────────────────────────────────────────
if (adminToken) {
loadStats();
}
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+1 -5
View File
@@ -7,7 +7,6 @@ package handler
import ( import (
"context" "context"
"crypto/subtle"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@@ -20,11 +19,8 @@ func (h *Handler) AdminStats(w http.ResponseWriter, r *http.Request) {
return return
} }
// Constant-time сравнение токена: обычное != давало утечку по времени (ревью 2026-10-10).
authHeader := r.Header.Get("Authorization") authHeader := r.Header.Get("Authorization")
token := strings.TrimPrefix(authHeader, "Bearer ") if !strings.HasPrefix(authHeader, "Bearer ") || strings.TrimPrefix(authHeader, "Bearer ") != h.Cfg.AdminStatsToken {
if !strings.HasPrefix(authHeader, "Bearer ") ||
subtle.ConstantTimeCompare([]byte(token), []byte(h.Cfg.AdminStatsToken)) != 1 {
writeJSON(w, http.StatusUnauthorized, errResp("unauthorized")) writeJSON(w, http.StatusUnauthorized, errResp("unauthorized"))
return return
} }
+40 -163
View File
@@ -2,9 +2,9 @@
// //
// Endpoints: // Endpoints:
// //
// POST /v1/namespaces/{ns}/iot/devices — создать (отдаёт mqtt_password ОДИН раз) // POST /v1/namespaces/{ns}/iot/devices — создать
// GET /v1/namespaces/{ns}/iot/devices — список (без паролей) // GET /v1/namespaces/{ns}/iot/devices — список (без паролей)
// GET /v1/namespaces/{ns}/iot/devices/{name} — одно (без пароля) // GET /v1/namespaces/{ns}/iot/devices/{name} — одно (с паролем)
// DELETE /v1/namespaces/{ns}/iot/devices/{name} — удалить // DELETE /v1/namespaces/{ns}/iot/devices/{name} — удалить
// PATCH /v1/namespaces/{ns}/iot/devices/{name} — enabled // PATCH /v1/namespaces/{ns}/iot/devices/{name} — enabled
package handler package handler
@@ -12,14 +12,12 @@ package handler
import ( import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json"
"errors" "errors"
"net/http" "net/http"
"regexp"
"strconv" "strconv"
"time" "time"
"golang.org/x/crypto/bcrypt"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/store" "gitea.services.ngcloud.ru/Nail/IoT/internal/service/store"
) )
@@ -31,11 +29,9 @@ type iotDeviceCreateRequest struct {
Metadata map[string]string `json:"metadata,omitempty"` Metadata map[string]string `json:"metadata,omitempty"`
} }
// iotDeviceUpdateRequest — тело PATCH. Хотя бы одно поле обязательно: // iotDeviceUpdateRequest — тело PATCH.
// enabled и/или metadata (metadata раньше нельзя было обновить — ревью 2026-10-10).
type iotDeviceUpdateRequest struct { type iotDeviceUpdateRequest struct {
Enabled *bool `json:"enabled,omitempty"` Enabled *bool `json:"enabled"`
Metadata map[string]string `json:"metadata,omitempty"`
} }
// iotDeviceResponse — ответ API. MQTTPassword заполняется ТОЛЬКО при создании // iotDeviceResponse — ответ API. MQTTPassword заполняется ТОЛЬКО при создании
@@ -50,14 +46,8 @@ type iotDeviceResponse struct {
MQTTUsername string `json:"mqtt_username,omitempty"` MQTTUsername string `json:"mqtt_username,omitempty"`
MQTTPassword string `json:"mqtt_password,omitempty"` MQTTPassword string `json:"mqtt_password,omitempty"`
TopicPrefix string `json:"topic_prefix,omitempty"` TopicPrefix string `json:"topic_prefix,omitempty"`
// TelemetryTopic — полный MQTT-топик телеметрии устройства «ns/telemetry/device».
// Явное поле (в отличие от topic_prefix = «ns/»): Terraform должен знать
// точный топик для публикации/подписки (ревью 2026-10-10).
TelemetryTopic string `json:"telemetry_topic"`
LastConnected string `json:"last_connected,omitempty"` LastConnected string `json:"last_connected,omitempty"`
// Metadata — без omitempty: всегда присутствует в ответе. Пустой → «{}», Metadata map[string]string `json:"metadata,omitempty"`
// иначе Terraform видел бы drift «{}» против null (ревью 2026-10-10).
Metadata map[string]string `json:"metadata"`
CreatedAt string `json:"created_at,omitempty"` CreatedAt string `json:"created_at,omitempty"`
} }
@@ -65,14 +55,6 @@ type iotDeviceResponse struct {
// password отдаётся только если непустая — только на создании, при чтении // password отдаётся только если непустая — только на создании, при чтении
// передаётся пустая строка (пароль повторно не показывается). // передаётся пустая строка (пароль повторно не показывается).
func deviceToResponse(d *store.Device, password string) iotDeviceResponse { func deviceToResponse(d *store.Device, password string) iotDeviceResponse {
// Пустой metadata нормализуем в «{}» (а не null/omit): иначе Terraform видел
// бы drift между «{}» и отсутствующим полем. nil-карту не мутируем — подменяем
// только для сериализации ответа.
metadata := d.Metadata
if metadata == nil {
metadata = map[string]string{}
}
resp := iotDeviceResponse{ resp := iotDeviceResponse{
Name: d.Name, Name: d.Name,
Namespace: d.Namespace, Namespace: d.Namespace,
@@ -82,16 +64,13 @@ func deviceToResponse(d *store.Device, password string) iotDeviceResponse {
MQTTUsername: d.Namespace + "_" + d.DeviceID, MQTTUsername: d.Namespace + "_" + d.DeviceID,
MQTTPassword: password, MQTTPassword: password,
TopicPrefix: d.Namespace + "/", TopicPrefix: d.Namespace + "/",
TelemetryTopic: d.Namespace + "/telemetry/" + d.DeviceID, Metadata: d.Metadata,
Metadata: metadata,
} }
if d.LastConnected != nil && !d.LastConnected.IsZero() { if d.LastConnected != nil && !d.LastConnected.IsZero() {
resp.LastConnected = d.LastConnected.UTC().Format(time.RFC3339) resp.LastConnected = d.LastConnected.UTC().Format(time.RFC3339)
} }
if !d.CreatedAt.IsZero() { if !d.CreatedAt.IsZero() {
// RFC3339 — как last_connected: единый формат для Terraform (drift между resp.CreatedAt = d.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC")
// POST/GET из-за формата «2006-01-02 15:04:05 UTC» устранён).
resp.CreatedAt = d.CreatedAt.UTC().Format(time.RFC3339)
} }
return resp return resp
} }
@@ -112,68 +91,24 @@ func generateMQTTPassword() (string, error) {
return "", lastErr return "", lastErr
} }
// hashPassword — bcrypt-хэш пароля устройства. EMQX (postgres-auth) проверяет
// bcrypt (см. emqx/emqx.conf password_hash_algorithm). В БД хранится ТОЛЬКО хэш;
// открытый пароль отдаётся один раз при создании устройства.
func hashPassword(plain string) (string, error) {
h, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(h), nil
}
// identRe — допустимый charset идентификаторов (namespace/name/device_id):
// только [a-zA-Z0-9] и дефисы, не с дефиса. Исключает _, +, #, /, пробелы, юникод.
// Причины запретов (ревью 2026-10-10):
// - _ — username = «namespace_device_id»; _ делает его неоднозначным
// (auth-запрос EMQX: namespace || '_' || device_id, LIMIT 1 без ORDER BY
// → может сматчить чужое устройство);
// - +/# — wildcard MQTT: в ACL-топике даст подписку/публикацию чужих топиков;
// - / — ломает топик «ns/telemetry/device» (лишний уровень).
var identRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]*$`)
// validateIdentifier возвращает текст ошибки (или "") для идентификатора value.
// what — имя поля для сообщения, maxLen — максимальная длина (namespace ≤ 56:
// имя БД tenant_<ns> усекается PG до 63 байт).
func validateIdentifier(value, what string, maxLen int) string {
if value == "" {
return what + " is required"
}
if len(value) > maxLen {
return what + " is too long (max " + strconv.Itoa(maxLen) + " chars)"
}
if !identRe.MatchString(value) {
return what + " must match [a-zA-Z0-9][a-zA-Z0-9-]* (no _, +, #, /, spaces)"
}
return ""
}
// CreateIoTDevice — POST /v1/namespaces/{ns}/iot/devices. // CreateIoTDevice — POST /v1/namespaces/{ns}/iot/devices.
func (h *Handler) CreateIoTDevice(w http.ResponseWriter, r *http.Request) { func (h *Handler) CreateIoTDevice(w http.ResponseWriter, r *http.Request) {
// Порядок важен: сначала разбор тела (400 «invalid JSON»), потом валидация ns := pathVar(r, "namespace")
// идентификаторов — так и было до вынесения общей проверки. if ns == "" {
ns := namespaceFromPath(r) ns = "default"
}
var req iotDeviceCreateRequest var req iotDeviceCreateRequest
if err := decodeJSON(w, r, &req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error())) writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error()))
return return
} }
// Валидация идентификаторов до записи в БД: единый charset и лимиты для if req.Name == "" {
// namespace (из URL), name и device_id. Критично для MQTT (username/топик) writeJSON(w, http.StatusBadRequest, errResp("name is required"))
// и PG (имя БД tenant_<ns>): плохие символы ломают auth/ACL или дают чужой
// доступ (см. identRe). Ошибка 400 с понятным текстом — для Terraform.
if msg := validateIdentifier(ns, "namespace", 56); msg != "" {
writeJSON(w, http.StatusBadRequest, errResp(msg))
return return
} }
if msg := validateIdentifier(req.Name, "name", 64); msg != "" { if req.DeviceID == "" {
writeJSON(w, http.StatusBadRequest, errResp(msg)) writeJSON(w, http.StatusBadRequest, errResp("device_id is required"))
return
}
if msg := validateIdentifier(req.DeviceID, "device_id", 64); msg != "" {
writeJSON(w, http.StatusBadRequest, errResp(msg))
return return
} }
@@ -189,15 +124,6 @@ func (h *Handler) CreateIoTDevice(w http.ResponseWriter, r *http.Request) {
return return
} }
// В БД храним bcrypt-хэш (EMQX проверяет bcrypt — emqx/emqx.conf). Открытый
// пароль отдаём ОДИН раз в ответе 201 (иначе его негде взять).
hash, err := hashPassword(password)
if err != nil {
h.Log.Error("hash device password", "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to generate credentials"))
return
}
phase := "Active" phase := "Active"
if !enabled { if !enabled {
phase = "Disabled" phase = "Disabled"
@@ -208,16 +134,12 @@ func (h *Handler) CreateIoTDevice(w http.ResponseWriter, r *http.Request) {
Name: req.Name, Name: req.Name,
DeviceID: req.DeviceID, DeviceID: req.DeviceID,
Enabled: enabled, Enabled: enabled,
MQTTPassword: hash, MQTTPassword: password,
Metadata: req.Metadata, Metadata: req.Metadata,
Phase: phase, Phase: phase,
} }
if err := h.Devices.Create(r.Context(), device); err != nil { if err := h.Devices.Create(r.Context(), device); err != nil {
if errors.Is(err, store.ErrDeviceIDExists) {
writeJSON(w, http.StatusConflict, errResp("device_id already exists in this namespace"))
return
}
if errors.Is(err, store.ErrAlreadyExists) { if errors.Is(err, store.ErrAlreadyExists) {
writeJSON(w, http.StatusConflict, errResp("iot device already exists")) writeJSON(w, http.StatusConflict, errResp("iot device already exists"))
return return
@@ -227,51 +149,19 @@ func (h *Handler) CreateIoTDevice(w http.ResponseWriter, r *http.Request) {
return return
} }
// Перечитываем устройство из БД: после INSERT нужны БД-поля created_at writeJSON(w, http.StatusCreated, deviceToResponse(device, ""))
// (и phase из БД), которых нет в in-memory структуре. Иначе POST отдал бы
// пустой created_at → drift между POST и GET (ревью 2026-10-10, «Что
// стабилизировать в REST для Terraform»).
created, err := h.Devices.GetByName(r.Context(), ns, req.Name)
if err != nil {
// Устройство УЖЕ создано (INSERT прошёл): не отвечаем 500 — иначе пароль
// (отдаётся один раз) потерялся бы, а повтор дал бы 409. Отдаём созданный
// объект из памяти (created_at будет нулевым, уточнится следующим GET).
h.Log.Error("reload device after create (возвращаю in-memory без created_at)", "namespace", ns, "name", req.Name, "err", err)
writeJSON(w, http.StatusCreated, deviceToResponse(device, password))
return
}
// Пароль отдаётся ТОЛЬКО здесь, один раз в ответе 201. Дальше (GET/PATCH/
// LIST) он не возвращается — иначе пароль утекал бы через чтение и в
// историю/логи. Это блокер для Terraform: без пароля устройство нельзя
// подключить к MQTT, значит ресурс не создать. Именно поэтому пароль
// пробрасывается в deviceToResponse только в этой точке (GetByName его
// не отдаёт — см. P0-3 ч.2).
writeJSON(w, http.StatusCreated, deviceToResponse(created, password))
} }
// ListIoTDevices — GET /v1/namespaces/{ns}/iot/devices (без паролей). // ListIoTDevices — GET /v1/namespaces/{ns}/iot/devices (без паролей).
// Пагинация: ?limit=N&offset=M. limit — дефолт 100, потолок 1000 (защита от // Пагинация: ?limit=N&offset=M (0 = без ограничения).
// безлимитного запроса при ошибке парсинга).
func (h *Handler) ListIoTDevices(w http.ResponseWriter, r *http.Request) { func (h *Handler) ListIoTDevices(w http.ResponseWriter, r *http.Request) {
ns, ok := requireNamespace(w, r) ns := pathVar(r, "namespace")
if !ok { if ns == "" {
return ns = "default"
} }
// limit: дефолт 100, потолок 1000 — иначе ошибка парсинга/омиссия = безлимит limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
// (ревью 2026-10-10). offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
limit := 100
if v, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && v > 0 {
limit = v
}
if limit > 1000 {
limit = 1000
}
offset := 0
if v, err := strconv.Atoi(r.URL.Query().Get("offset")); err == nil && v > 0 {
offset = v
}
devices, err := h.Devices.List(r.Context(), ns, limit, offset) devices, err := h.Devices.List(r.Context(), ns, limit, offset)
if err != nil { if err != nil {
@@ -289,9 +179,9 @@ func (h *Handler) ListIoTDevices(w http.ResponseWriter, r *http.Request) {
// GetIoTDevice — GET /v1/namespaces/{ns}/iot/devices/{name} (с паролем). // GetIoTDevice — GET /v1/namespaces/{ns}/iot/devices/{name} (с паролем).
func (h *Handler) GetIoTDevice(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetIoTDevice(w http.ResponseWriter, r *http.Request) {
ns, ok := requireNamespace(w, r) ns := pathVar(r, "namespace")
if !ok { if ns == "" {
return ns = "default"
} }
name := pathVar(r, "name") name := pathVar(r, "name")
@@ -311,9 +201,9 @@ func (h *Handler) GetIoTDevice(w http.ResponseWriter, r *http.Request) {
// DeleteIoTDevice — DELETE /v1/namespaces/{ns}/iot/devices/{name}. // DeleteIoTDevice — DELETE /v1/namespaces/{ns}/iot/devices/{name}.
func (h *Handler) DeleteIoTDevice(w http.ResponseWriter, r *http.Request) { func (h *Handler) DeleteIoTDevice(w http.ResponseWriter, r *http.Request) {
ns, ok := requireNamespace(w, r) ns := pathVar(r, "namespace")
if !ok { if ns == "" {
return ns = "default"
} }
name := pathVar(r, "name") name := pathVar(r, "name")
@@ -333,45 +223,32 @@ func (h *Handler) DeleteIoTDevice(w http.ResponseWriter, r *http.Request) {
// UpdateIoTDevice — PATCH /v1/namespaces/{ns}/iot/devices/{name} (enabled). // UpdateIoTDevice — PATCH /v1/namespaces/{ns}/iot/devices/{name} (enabled).
func (h *Handler) UpdateIoTDevice(w http.ResponseWriter, r *http.Request) { func (h *Handler) UpdateIoTDevice(w http.ResponseWriter, r *http.Request) {
ns, ok := requireNamespace(w, r) ns := pathVar(r, "namespace")
if !ok { if ns == "" {
return ns = "default"
} }
name := pathVar(r, "name") name := pathVar(r, "name")
var req iotDeviceUpdateRequest var req iotDeviceUpdateRequest
if err := decodeJSON(w, r, &req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error())) writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error()))
return return
} }
if req.Enabled == nil && req.Metadata == nil { if req.Enabled == nil {
writeJSON(w, http.StatusBadRequest, errResp("enabled или metadata обязательны")) writeJSON(w, http.StatusBadRequest, errResp("enabled field is required"))
return return
} }
// Обновляем только переданные поля (enabled и/или metadata). err := h.Devices.UpdateEnabled(r.Context(), ns, name, *req.Enabled)
if req.Enabled != nil { if err != nil {
if err := h.Devices.UpdateEnabled(r.Context(), ns, name, *req.Enabled); err != nil {
if errors.Is(err, store.ErrNotFound) { if errors.Is(err, store.ErrNotFound) {
writeJSON(w, http.StatusNotFound, errResp("iot device not found")) writeJSON(w, http.StatusNotFound, errResp("iot device not found"))
return return
} }
h.Log.Error("update device enabled", "namespace", ns, "name", name, "err", err) h.Log.Error("update device", "namespace", ns, "name", name, "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to update device")) writeJSON(w, http.StatusInternalServerError, errResp("failed to update device"))
return return
} }
}
if req.Metadata != nil {
if err := h.Devices.UpdateMetadata(r.Context(), ns, name, req.Metadata); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeJSON(w, http.StatusNotFound, errResp("iot device not found"))
return
}
h.Log.Error("update device metadata", "namespace", ns, "name", name, "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to update device"))
return
}
}
device, err := h.Devices.GetByName(r.Context(), ns, name) device, err := h.Devices.GetByName(r.Context(), ns, name)
if err != nil { if err != nil {
@@ -1,42 +0,0 @@
// devices_test.go — тесты валидации идентификаторов (validateIdentifier).
//
// Покрывают charset/лимиты, критичные для MQTT (username/topic) и PG (имя БД):
// запрет _, +, #, /, ведущего дефиса, пробелов и превышения длины.
package handler
import (
"strings"
"testing"
)
func TestValidateIdentifier(t *testing.T) {
cases := []struct {
name string
value string
maxLen int
wantOK bool
}{
{"простое имя", "boiler", 64, true},
{"дефис внутри", "boiler-1", 64, true},
{"одна буква", "a", 64, true},
{"цифры", "dev001", 64, true},
{"подчёркивание запрещено", "a_b", 64, false},
{"плюс запрещён (wildcard MQTT)", "a+b", 64, false},
{"решётка запрещена (wildcard MQTT)", "a#b", 64, false},
{"слэш запрещён (уровень топика)", "a/b", 64, false},
{"ведущий дефис", "-abc", 64, false},
{"пробел", "abc def", 64, false},
{"пусто", "", 64, false},
{"юникод", "имя", 64, false},
{"ровно максимум", strings.Repeat("a", 56), 56, true},
{"больше максимума", strings.Repeat("a", 57), 56, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
gotOK := validateIdentifier(c.value, "x", c.maxLen) == ""
if gotOK != c.wantOK {
t.Errorf("validateIdentifier(%q, max=%d) ok=%v, want %v", c.value, c.maxLen, gotOK, c.wantOK)
}
})
}
}
-43
View File
@@ -38,21 +38,6 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
_ = json.NewEncoder(w).Encode(v) _ = json.NewEncoder(w).Encode(v)
} }
// maxRequestBodyBytes — максимум размера тела запроса (устройства/метаданные
// небольшие; 64 КиБ с запасом).
const maxRequestBodyBytes = 64 * 1024
// decodeJSON читает JSON-тело с лимитом размера и запретом неизвестных полей
// (ревью 2026-10-10). MaxBytesReader защищает от раздутых тел;
// DisallowUnknownFields ловит опечатки в полях — критично для Terraform (лучше
// 400 с понятной ошибкой, чем молча проигнорированное поле).
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) error {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
return dec.Decode(v)
}
// errResp возвращает структуру ошибки для JSON. // errResp возвращает структуру ошибки для JSON.
func errResp(msg string) map[string]string { func errResp(msg string) map[string]string {
return map[string]string{"error": msg} return map[string]string{"error": msg}
@@ -62,31 +47,3 @@ func errResp(msg string) map[string]string {
func pathVar(r *http.Request, key string) string { func pathVar(r *http.Request, key string) string {
return mux.Vars(r)[key] return mux.Vars(r)[key]
} }
// defaultNamespace — namespace, когда в пути он не задан.
const defaultNamespace = "default"
// namespaceFromPath возвращает {namespace} из URL (пусто → "default").
func namespaceFromPath(r *http.Request) string {
ns := pathVar(r, "namespace")
if ns == "" {
ns = defaultNamespace
}
return ns
}
// requireNamespace — namespace из URL + его валидация. При невалидном значении
// отвечает 400 и возвращает ok=false (вызывающий обязан выйти).
//
// Зачем единая точка: раньше проверка была ТОЛЬКО в CreateIoTDevice, а
// GET/DELETE/PATCH/telemetry пропускали любой namespace дальше (тест 2026-10-11:
// `GET /v1/namespaces/bad_ns/iot/devices` → 200, а чтение телеметрии с таким ns
// падало уже в хранилище с 500). Валидируем на входе у всех — единообразный 400.
func requireNamespace(w http.ResponseWriter, r *http.Request) (string, bool) {
ns := namespaceFromPath(r)
if msg := validateIdentifier(ns, "namespace", 56); msg != "" {
writeJSON(w, http.StatusBadRequest, errResp(msg))
return "", false
}
return ns, true
}
+167
View File
@@ -0,0 +1,167 @@
// mqtt_auth.go — HTTP-auth и ACL для EMQX (MQTT CONNECT / pub-sub).
//
// Endpoints (без JWT, вызываются EMQX внутри платформы):
//
// POST /internal/mqtt/auth — аутентификация: всегда HTTP 200, {"result":"allow"|"deny"}
// POST /internal/mqtt/acl — авторизация pub/sub
//
// Источник устройств — таблица iot_devices (не k8s Secrets).
package handler
import (
"crypto/subtle"
"encoding/json"
"errors"
"net/http"
"strings"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/store"
)
// mqttAuthRequest — тело запроса от EMQX 5.x (username, password, clientid, peerhost).
type mqttAuthRequest struct {
Username string `json:"username"`
Password string `json:"password"`
ClientID string `json:"clientid"`
PeerHost string `json:"peerhost"`
}
// mqttAuthResponse — ответ для EMQX (всегда HTTP 200).
type mqttAuthResponse struct {
Result string `json:"result"`
ACL []aclRule `json:"acl,omitempty"`
}
// aclRule — правило ACL для EMQX HTTP auth.
type aclRule struct {
Permission string `json:"permission"`
Action string `json:"action"`
Topic string `json:"topic"`
}
// mqttAclRequest — тело запроса ACL от EMQX.
type mqttAclRequest struct {
Username string `json:"username"`
ClientID string `json:"clientid"`
Action string `json:"action"`
Topic string `json:"topic"`
}
// MQTTAuth — POST /internal/mqtt/auth.
//
// Логика:
// 1. bridge-креды (MQTT_USERNAME/MQTT_PASSWORD из env) — проверяются до парсинга;
// 2. username "{ns}_{deviceId}" → поиск устройства в iot_devices;
// 3. constant-time сравнение пароля; проверка enabled;
// 4. best-effort last_connected; ACL по топику устройства.
func (h *Handler) MQTTAuth(w http.ResponseWriter, r *http.Request) {
var req mqttAuthRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
// Внутренний bridge-клиент: фиксированные креды, ACL выдаётся ниже.
if h.Cfg.MQTTUsername != "" && req.Username == h.Cfg.MQTTUsername {
if subtle.ConstantTimeCompare([]byte(h.Cfg.MQTTPassword), []byte(req.Password)) == 1 {
writeJSON(w, http.StatusOK, mqttAuthResponse{
Result: "allow",
ACL: bridgeACLRules(),
})
return
}
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
// Парсим username: "{namespace}_{deviceId}" (первый '_' — разделитель).
idx := strings.Index(req.Username, "_")
if idx <= 0 || idx == len(req.Username)-1 {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
ns := req.Username[:idx]
deviceID := req.Username[idx+1:]
device, err := h.Devices.GetByDeviceID(r.Context(), ns, deviceID)
if err != nil {
if !errors.Is(err, store.ErrNotFound) {
h.Log.Error("mqtt auth: device lookup", "namespace", ns, "device", deviceID, "err", err)
}
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
if !device.Enabled {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
if subtle.ConstantTimeCompare([]byte(device.MQTTPassword), []byte(req.Password)) != 1 {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
// best-effort: обновляем время последнего подключения
h.Devices.TouchLastConnected(r.Context(), ns, deviceID)
writeJSON(w, http.StatusOK, mqttAuthResponse{
Result: "allow",
ACL: deviceACLRules(ns, deviceID),
})
}
// MQTTAcl — POST /internal/mqtt/acl.
//
// Bridge (clientID из env MQTT_CLIENT_ID): только subscribe.
// Устройство ({ns}_{deviceId}): pub/sub в своём топике.
func (h *Handler) MQTTAcl(w http.ResponseWriter, r *http.Request) {
var req mqttAclRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
if req.ClientID == h.Cfg.MQTTClientID {
if req.Action == "subscribe" {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"})
return
}
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
idx := strings.Index(req.Username, "_")
if idx <= 0 || idx == len(req.Username)-1 {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
ns := req.Username[:idx]
deviceID := req.Username[idx+1:]
deviceTopic := ns + "/telemetry/" + deviceID
switch {
case req.Action == "publish" && req.Topic == deviceTopic:
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"})
case req.Action == "subscribe" && (req.Topic == deviceTopic || req.Topic == "#"):
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"})
default:
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
}
}
// bridgeACLRules — ACL для внутреннего bridge: subscribe на все telemetry-топики.
func bridgeACLRules() []aclRule {
return []aclRule{
{Permission: "allow", Action: "subscribe", Topic: "+/telemetry/+"},
{Permission: "deny", Action: "all", Topic: "#"},
}
}
// deviceACLRules — ACL для устройства: только свой топик телеметрии.
func deviceACLRules(ns, deviceID string) []aclRule {
topic := ns + "/telemetry/" + deviceID
return []aclRule{
{Permission: "allow", Action: "publish", Topic: topic},
{Permission: "allow", Action: "subscribe", Topic: topic},
{Permission: "deny", Action: "all", Topic: "#"},
}
}
@@ -1,78 +0,0 @@
// namespace_test.go — проверяет, что ВСЕ эндпоинты с {namespace} валидируют его
// одинаково (400), а не пропускают «мусор» до хранилища.
//
// Найдено e2e-прогоном 2026-10-11: валидация была только в CreateIoTDevice,
// поэтому GET-список с `bad_ns` отдавал 200, а чтение телеметрии падало уже в
// хранилище (safeNamespaceRe) с 500. Здесь — регрессия на все обработчики.
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gorilla/mux"
"gitea.services.ngcloud.ru/Nail/IoT/internal/storage/iotpg"
)
// badNamespaces — значения, которые ломают MQTT-топик/username или имя БД
// tenant_<ns>: подчёркивание (коллизия с дефисом), wildcard, слэш, пробел,
// ведущий дефис, юникод, пустой.
var badNamespaces = []string{"bad_ns", "a+b", "a#b", "a/b", "-abc", "abc def", "имя", ""}
func newReq(t *testing.T, ns string) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/v1/namespaces/x/iot/devices", nil)
return mux.SetURLVars(req, map[string]string{"namespace": ns, "name": "dev1"})
}
func TestHandlersRejectInvalidNamespace(t *testing.T) {
// Пустой IoTPG-стаб: до хранилища дело дойти не должно (валидация раньше),
// поэтому nil-зависимости Devices/IoTPG не используются.
h := &Handler{IoTPG: &iotpg.IoTPostgresStore{}}
handlers := map[string]func(http.ResponseWriter, *http.Request){
"ListIoTDevices": h.ListIoTDevices,
"GetIoTDevice": h.GetIoTDevice,
"DeleteIoTDevice": h.DeleteIoTDevice,
"UpdateIoTDevice": h.UpdateIoTDevice,
"ListIoTTelemetry": h.ListIoTTelemetry,
}
for name, fn := range handlers {
for _, ns := range badNamespaces {
if ns == "" {
continue // пустой namespace = "default" (валидный) — проверяется отдельно
}
t.Run(name+"/"+ns, func(t *testing.T) {
rec := httptest.NewRecorder()
fn(rec, newReq(t, ns))
if rec.Code != http.StatusBadRequest {
t.Fatalf("код %d, ожидался 400 (body=%s)", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "namespace") {
t.Errorf("в теле нет упоминания namespace: %s", rec.Body.String())
}
})
}
}
}
func TestEmptyNamespaceMeansDefault(t *testing.T) {
// Пустой {namespace} в пути → "default" (валидное значение), поэтому
// проверка должна пройти дальше валидации (здесь упадёт на nil Devices —
// это ожидаемо, ловим панику и убеждаемся, что это НЕ 400).
defer func() {
if r := recover(); r == nil {
t.Log("обработчик дошёл до зависимостей без паники")
}
}()
rec := httptest.NewRecorder()
h := &Handler{}
h.ListIoTDevices(rec, newReq(t, ""))
if rec.Code == http.StatusBadRequest {
t.Fatalf("пустой namespace не должен давать 400 (body=%s)", rec.Body.String())
}
}
+1 -4
View File
@@ -17,10 +17,7 @@ func (h *Handler) ListIoTTelemetry(w http.ResponseWriter, r *http.Request) {
return return
} }
ns, ok := requireNamespace(w, r) ns := pathVar(r, "namespace")
if !ok {
return
}
deviceID := r.URL.Query().Get("device_id") deviceID := r.URL.Query().Get("device_id")
limit := 50 limit := 50
if ls := r.URL.Query().Get("limit"); ls != "" { if ls := r.URL.Query().Get("limit"); ls != "" {
+8 -25
View File
@@ -19,27 +19,23 @@ import (
// - false — проверка JWT: sub + exp; если задан hmacSecret — обязательна // - false — проверка JWT: sub + exp; если задан hmacSecret — обязательна
// HS256-подпись (HMAC-SHA256, env JWT_HMAC_SECRET). Без секрета — // HS256-подпись (HMAC-SHA256, env JWT_HMAC_SECRET). Без секрета —
// структурная проверка (периметр обеспечивает платформа). // структурная проверка (периметр обеспечивает платформа).
//
// Тест-режим игнорируется при заданном hmacSecret (иначе обходил бы подпись).
func Auth(authTestMode bool, hmacSecret string, log *slog.Logger, next http.Handler) http.Handler { func Auth(authTestMode bool, hmacSecret string, log *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Authorization") header := r.Header.Get("Authorization")
if header == "" { if header == "" {
log.Warn("auth: missing authorization header", "remote", r.RemoteAddr, "path", r.URL.Path) log.Warn("auth: missing authorization header", "remote", r.RemoteAddr, "path", r.URL.Path)
authErr(w, http.StatusUnauthorized, "authorization required") http.Error(w, `{"error":"authorization required"}`, http.StatusUnauthorized)
return return
} }
parts := strings.SplitN(header, " ", 2) parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") { if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
log.Warn("auth: invalid authorization format", "remote", r.RemoteAddr) log.Warn("auth: invalid authorization format", "remote", r.RemoteAddr)
authErr(w, http.StatusUnauthorized, "invalid authorization format, use Bearer <token>") http.Error(w, `{"error":"invalid authorization format, use Bearer <token>"}`, http.StatusUnauthorized)
return return
} }
token := parts[1] token := parts[1]
// Тест-режим только без секрета: при заданном JWT_HMAC_SECRET он обходил if authTestMode && isPlainToken(token) {
// бы проверку подписи (ревью 2026-10-10).
if authTestMode && hmacSecret == "" && isPlainToken(token) {
log.Info("auth: test mode — plain token accepted", "remote", r.RemoteAddr, "path", r.URL.Path) log.Info("auth: test mode — plain token accepted", "remote", r.RemoteAddr, "path", r.URL.Path)
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
@@ -49,16 +45,14 @@ func Auth(authTestMode bool, hmacSecret string, log *slog.Logger, next http.Hand
// токен можно подделать — отказываем в обслуживании, а не проверяем «структурно». // токен можно подделать — отказываем в обслуживании, а не проверяем «структурно».
if !authTestMode && hmacSecret == "" { if !authTestMode && hmacSecret == "" {
log.Error("auth: JWT_HMAC_SECRET не задан при AUTH_TEST_MODE=false — отказ (fail-closed)") log.Error("auth: JWT_HMAC_SECRET не задан при AUTH_TEST_MODE=false — отказ (fail-closed)")
authErr(w, http.StatusInternalServerError, "auth misconfigured") http.Error(w, `{"error":"auth misconfigured"}`, http.StatusInternalServerError)
return return
} }
sub, err := validateJWT(token, hmacSecret) sub, err := validateJWT(token, hmacSecret)
if err != nil { if err != nil {
// 401, а не 403: невалидный/просроченный токен — не аутентифицирован
// (клиент/Terraform должны отличать «протух» от «чужой namespace»).
log.Warn("auth: invalid token", "remote", r.RemoteAddr, "path", r.URL.Path, "reason", err.Error()) log.Warn("auth: invalid token", "remote", r.RemoteAddr, "path", r.URL.Path, "reason", err.Error())
authErr(w, http.StatusUnauthorized, "invalid token") http.Error(w, `{"error":"invalid token"}`, http.StatusForbidden)
return return
} }
@@ -66,7 +60,7 @@ func Auth(authTestMode bool, hmacSecret string, log *slog.Logger, next http.Hand
// Иначе любой валидный токен читал/писал бы любой namespace (горизонтальный доступ). // Иначе любой валидный токен читал/писал бы любой namespace (горизонтальный доступ).
if ns := mux.Vars(r)["namespace"]; ns != "" && ns != sub { if ns := mux.Vars(r)["namespace"]; ns != "" && ns != sub {
log.Warn("auth: namespace mismatch — forbidden", "remote", r.RemoteAddr, "sub", sub, "namespace", ns, "path", r.URL.Path) log.Warn("auth: namespace mismatch — forbidden", "remote", r.RemoteAddr, "sub", sub, "namespace", ns, "path", r.URL.Path)
authErr(w, http.StatusForbidden, "forbidden") http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return return
} }
@@ -74,14 +68,6 @@ func Auth(authTestMode bool, hmacSecret string, log *slog.Logger, next http.Hand
}) })
} }
// authErr — единый JSON-ответ об ошибке аутентификации (middleware раньше отдавал
// text/plain через http.Error, а хендлеры — JSON; ревью 2026-10-10 — единый контракт).
func authErr(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_, _ = w.Write([]byte(`{"error":"` + msg + `"}`))
}
// isPlainToken — непустая строка без пробелов, не похожая на JWT (три части через точку). // isPlainToken — непустая строка без пробелов, не похожая на JWT (три части через точку).
func isPlainToken(token string) bool { func isPlainToken(token string) bool {
if token == "" || strings.ContainsAny(token, " \t\n\r") { if token == "" || strings.ContainsAny(token, " \t\n\r") {
@@ -151,13 +137,10 @@ func validateJWT(token, secret string) (string, error) {
if claims.Sub == "" { if claims.Sub == "" {
return "", &jwtError{"JWT missing sub claim"} return "", &jwtError{"JWT missing sub claim"}
} }
// exp ОБЯЗАТЕЛЕН: без него токен вечный. Раньше проверка шла только при if claims.Exp > 0 {
// claims.Exp > 0, т.е. отсутствие exp = бессрочный токен (ревью 2026-10-10).
if claims.Exp == 0 {
return "", &jwtError{"JWT missing exp claim"}
}
if now := float64(timeNow().Unix()); claims.Exp < now { if now := float64(timeNow().Unix()); claims.Exp < now {
return "", &jwtError{"JWT expired"} return "", &jwtError{"JWT expired"}
} }
}
return claims.Sub, nil return claims.Sub, nil
} }
+1 -8
View File
@@ -24,14 +24,7 @@ func Logging(log *slog.Logger, next http.Handler) http.Handler {
start := time.Now() start := time.Now()
rw := &responseWriter{ResponseWriter: w, status: http.StatusOK} rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rw, r) next.ServeHTTP(rw, r)
// Пробы здоровья логируем на Debug: платформа опрашивает их часто, а log.Info("http",
// Info-строка на каждую засоряет лог (ревью 2026-10-10).
level := slog.LevelInfo
switch r.URL.Path {
case "/health", "/healthz", "/ready":
level = slog.LevelDebug
}
log.Log(r.Context(), level, "http",
"method", r.Method, "method", r.Method,
"path", r.URL.Path, "path", r.URL.Path,
"status", rw.status, "status", rw.status,
+229 -417
View File
@@ -1,12 +1,11 @@
<!DOCTYPE html> <!DOCTYPE html>
<!-- Создано: 2026-04-06 <!-- Создано: 2026-04-06
iot-admin.html — страница администратора IoT pipeline. iot-admin.html — страница администратора IoT pipeline.
Показывает: PostgreSQL stats per tenant, очередь shared-SQS, рантайм процесса. Показывает: PostgreSQL stats per tenant, Kafka consumer lag, K8s pod statuses.
Auth: ADMIN_STATS_TOKEN вводится вручную и хранится в sessionStorage. Auth: ADMIN_STATS_TOKEN вводится вручную и хранится в sessionStorage.
Раздаётся по GET /iot-admin (go:embed в бинарнике iot-service). Раздаётся по GET /iot-admin (go:embed в бинарнике оператора).
НЕ для конечных пользователей — только для администратора платформы. --> НЕ для конечных пользователей — только для администратора платформы. -->
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -15,388 +14,178 @@
<link rel="icon" href="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/favicon.png"> <link rel="icon" href="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/favicon.png">
<style> <style>
/* Nubes brand palette — те же цвета что в iot-console.html */ /* Nubes brand palette — те же цвета что в iot-console.html */
*, *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: #001120; background: #001120; color: #e2ecf6; min-height: 100vh;
color: #e2ecf6;
min-height: 100vh;
} }
/* Navbar */ /* Navbar */
.navbar { .navbar {
background: #001C34; background: #001C34; border-bottom: 1px solid #0b2d50;
border-bottom: 1px solid #0b2d50; padding: 0 28px; height: 58px;
padding: 0 28px; display: flex; align-items: center; gap: 14px;
height: 58px;
display: flex;
align-items: center;
gap: 14px;
} }
.navbar-logo { display: flex; align-items: center; gap: 10px; text-decoration: none; }
.navbar-logo { .navbar-logo img { height: 18px; filter: brightness(0) invert(1); }
display: flex; .navbar-logo-sep { width: 1px; height: 18px; background: #1a4a73; margin: 0 4px; }
align-items: center; .navbar-title { font-size: 15px; font-weight: 600; color: #e2ecf6; }
gap: 10px;
text-decoration: none;
}
.navbar-logo img {
height: 18px;
filter: brightness(0) invert(1);
}
.navbar-logo-sep {
width: 1px;
height: 18px;
background: #1a4a73;
margin: 0 4px;
}
.navbar-title {
font-size: 15px;
font-weight: 600;
color: #e2ecf6;
}
.navbar-badge { .navbar-badge {
background: #2d1a00; background: #2d1a00; border: 1px solid #7a3a00; color: #f0a030;
border: 1px solid #7a3a00; font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 4px;
color: #f0a030; letter-spacing: 0.5px; text-transform: uppercase;
font-size: 10px;
font-weight: 700;
padding: 2px 7px;
border-radius: 4px;
letter-spacing: 0.5px;
text-transform: uppercase;
} }
.navbar-spacer { flex: 1; }
.navbar-spacer {
flex: 1;
}
.navbar-refresh { .navbar-refresh {
background: #0f3a60; background: #0f3a60; border: 1px solid #1a5a8a; color: #7fc8f8;
border: 1px solid #1a5a8a; padding: 6px 14px; border-radius: 6px; font-size: 13px; cursor: pointer;
color: #7fc8f8;
padding: 6px 14px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: background 0.15s; transition: background 0.15s;
} }
.navbar-refresh:hover { background: #1a5080; }
.navbar-refresh:hover { .navbar-refresh:disabled { opacity: 0.4; cursor: not-allowed; }
background: #1a5080;
}
.navbar-refresh:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Layout */ /* Layout */
.container { .container { max-width: 1280px; margin: 0 auto; padding: 28px 24px; }
max-width: 1280px;
margin: 0 auto;
padding: 28px 24px;
}
/* Auth box */ /* Auth box */
.auth-box { .auth-box {
background: #001929; background: #001929; border: 1px solid #0b2d50; border-radius: 12px;
border: 1px solid #0b2d50; padding: 40px; max-width: 480px; margin: 80px auto;
border-radius: 12px; display: flex; flex-direction: column; gap: 16px;
padding: 40px;
max-width: 480px;
margin: 80px auto;
display: flex;
flex-direction: column;
gap: 16px;
} }
.auth-box h2 { font-size: 20px; font-weight: 600; color: #7fc8f8; }
.auth-box h2 { .auth-box p { font-size: 13px; color: #6b8eaa; }
font-size: 20px;
font-weight: 600;
color: #7fc8f8;
}
.auth-box p {
font-size: 13px;
color: #6b8eaa;
}
.auth-input { .auth-input {
background: #001120; background: #001120; border: 1px solid #1a4a73; color: #e2ecf6;
border: 1px solid #1a4a73; padding: 10px 14px; border-radius: 8px; font-size: 14px; font-family: monospace;
color: #e2ecf6; width: 100%; outline: none;
padding: 10px 14px;
border-radius: 8px;
font-size: 14px;
font-family: monospace;
width: 100%;
outline: none;
} }
.auth-input:focus { border-color: #1a7fd4; }
.auth-input:focus {
border-color: #1a7fd4;
}
.auth-btn { .auth-btn {
background: #1a7fd4; background: #1a7fd4; border: none; color: #fff;
border: none; padding: 10px 20px; border-radius: 8px; font-size: 14px; cursor: pointer;
color: #fff; font-weight: 600; transition: background 0.15s;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
font-weight: 600;
transition: background 0.15s;
}
.auth-btn:hover {
background: #1a6ab8;
}
.auth-error {
color: #f87171;
font-size: 13px;
} }
.auth-btn:hover { background: #1a6ab8; }
.auth-error { color: #f87171; font-size: 13px; }
/* Section header */ /* Section header */
.section-header { .section-header {
display: flex; display: flex; align-items: center; gap: 10px;
align-items: center; margin-bottom: 16px; padding-bottom: 10px;
gap: 10px;
margin-bottom: 16px;
padding-bottom: 10px;
border-bottom: 1px solid #0b2d50; border-bottom: 1px solid #0b2d50;
} }
.section-icon { width: 20px; height: 20px; opacity: 0.7; }
.section-icon { .section-title { font-size: 16px; font-weight: 600; color: #a0c4e8; }
width: 20px; .section { margin-bottom: 32px; }
height: 20px;
opacity: 0.7;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #a0c4e8;
}
.section {
margin-bottom: 32px;
}
/* Cards grid */ /* Cards grid */
.cards { .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
/* Stat card */ /* Stat card */
.card { .card {
background: #001929; background: #001929; border: 1px solid #0b2d50; border-radius: 10px;
border: 1px solid #0b2d50;
border-radius: 10px;
padding: 20px; padding: 20px;
} }
.card-title { font-size: 12px; color: #6b8eaa; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
.card-value { font-size: 28px; font-weight: 700; color: #e2ecf6; }
.card-sub { font-size: 12px; color: #6b8eaa; margin-top: 4px; }
.card-accent { color: #1a7fd4; }
.card-warn { color: #f59e0b; }
.card-ok { color: #34d399; }
.card-err { color: #f87171; }
.card-title { /* Pod status card */
font-size: 12px; .pod-card {
color: #6b8eaa; background: #001929; border: 1px solid #0b2d50; border-radius: 10px;
text-transform: uppercase; padding: 20px; display: flex; flex-direction: column; gap: 8px;
letter-spacing: 0.5px;
margin-bottom: 8px;
} }
.pod-name { font-size: 13px; font-weight: 600; color: #7fc8f8; font-family: monospace; }
.card-value { .pod-row { display: flex; justify-content: space-between; font-size: 12px; }
font-size: 28px; .pod-label { color: #6b8eaa; }
font-weight: 700; .pod-val { color: #e2ecf6; }
color: #e2ecf6; .badge {
} display: inline-block; padding: 2px 8px; border-radius: 4px;
font-size: 11px; font-weight: 700;
.card-sub {
font-size: 12px;
color: #6b8eaa;
margin-top: 4px;
}
.card-accent {
color: #1a7fd4;
}
.card-warn {
color: #f59e0b;
}
.card-ok {
color: #34d399;
}
.card-err {
color: #f87171;
} }
.badge-ok { background: #052e16; color: #34d399; border: 1px solid #064e3b; }
.badge-warn { background: #2d1c00; color: #f59e0b; border: 1px solid #4d3000; }
.badge-err { background: #300; color: #f87171; border: 1px solid #500; }
/* Tenant table */ /* Tenant table */
.tenant-table { .tenant-table { width: 100%; border-collapse: collapse; font-size: 13px; }
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.tenant-table th { .tenant-table th {
text-align: left; text-align: left; padding: 8px 12px; color: #6b8eaa;
padding: 8px 12px; border-bottom: 1px solid #0b2d50; font-weight: 600; font-size: 11px;
color: #6b8eaa; text-transform: uppercase; letter-spacing: 0.3px;
border-bottom: 1px solid #0b2d50;
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.3px;
} }
.tenant-table td { padding: 10px 12px; border-bottom: 1px solid #071a28; vertical-align: top; }
.tenant-table td { .tenant-table tr:last-child td { border-bottom: none; }
padding: 10px 12px; .tenant-table tr:hover td { background: rgba(26,127,212,0.05); }
border-bottom: 1px solid #071a28;
vertical-align: top;
}
.tenant-table tr:last-child td {
border-bottom: none;
}
.tenant-table tr:hover td {
background: rgba(26, 127, 212, 0.05);
}
.ns-tag { .ns-tag {
font-family: monospace; font-family: monospace; font-size: 12px; color: #7fc8f8;
font-size: 12px; background: #0b2d50; padding: 2px 6px; border-radius: 4px;
color: #7fc8f8;
background: #0b2d50;
padding: 2px 6px;
border-radius: 4px;
}
.num-big {
font-size: 16px;
font-weight: 600;
color: #e2ecf6;
}
.num-small {
font-size: 12px;
color: #6b8eaa;
} }
.num-big { font-size: 16px; font-weight: 600; color: #e2ecf6; }
.num-small { font-size: 12px; color: #6b8eaa; }
/* Latest msgs mini list */ /* Latest msgs mini list */
.latest-list { .latest-list { display: flex; flex-direction: column; gap: 4px; }
display: flex;
flex-direction: column;
gap: 4px;
}
.latest-item { .latest-item {
background: #001120; background: #001120; border: 1px solid #0b2d50; border-radius: 6px;
border: 1px solid #0b2d50; padding: 6px 10px; font-size: 11px;
border-radius: 6px;
padding: 6px 10px;
font-size: 11px;
}
.latest-dev {
color: #7fc8f8;
font-weight: 600;
}
.latest-ts {
color: #6b8eaa;
margin-left: 6px;
}
.latest-payload {
color: #a0c4e8;
margin-top: 2px;
word-break: break-all;
font-family: monospace;
} }
.latest-dev { color: #7fc8f8; font-weight: 600; }
.latest-ts { color: #6b8eaa; margin-left: 6px; }
.latest-payload { color: #a0c4e8; margin-top: 2px; word-break: break-all; font-family: monospace; }
/* Last updated */ /* Last updated */
.last-updated { .last-updated { font-size: 12px; color: #2d5070; text-align: center; margin-top: 16px; }
font-size: 12px;
color: #2d5070; /* Status dot */
text-align: center; .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
margin-top: 16px; .dot-ok { background: #34d399; }
} .dot-warn { background: #f59e0b; }
.dot-err { background: #f87171; }
/* Kafka lag bar */
.lag-bar-wrap { background: #001120; border-radius: 4px; height: 6px; margin-top: 8px; overflow: hidden; }
.lag-bar { height: 100%; border-radius: 4px; transition: width 0.5s; min-width: 2px; }
.lag-bar-ok { background: #34d399; }
.lag-bar-warn { background: #f59e0b; }
/* Spinner */ /* Spinner */
.spinner { .spinner {
border: 3px solid #0b2d50; border: 3px solid #0b2d50; border-top-color: #1a7fd4;
border-top-color: #1a7fd4; border-radius: 50%; width: 32px; height: 32px;
border-radius: 50%;
width: 32px;
height: 32px;
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
margin: 60px auto; margin: 60px auto;
} }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Error banner */ /* Error banner */
.error-banner { .error-banner {
background: #1a0000; background: #1a0000; border: 1px solid #5a0000; color: #f87171;
border: 1px solid #5a0000; padding: 12px 16px; border-radius: 8px; font-size: 13px; margin-bottom: 20px;
color: #f87171;
padding: 12px 16px;
border-radius: 8px;
font-size: 13px;
margin-bottom: 20px;
} }
/* Auto-refresh indicator */ /* Auto-refresh indicator */
.refresh-timer { .refresh-timer {
font-size: 11px; font-size: 11px; color: #2d5070; display: flex; align-items: center; gap: 6px;
color: #2d5070;
display: flex;
align-items: center;
gap: 6px;
} }
.refresh-progress { .refresh-progress {
width: 60px; width: 60px; height: 2px; background: #0b2d50; border-radius: 2px; overflow: hidden;
height: 2px;
background: #0b2d50;
border-radius: 2px;
overflow: hidden;
} }
.refresh-bar { .refresh-bar {
height: 100%; height: 100%; background: #1a7fd4; border-radius: 2px;
background: #1a7fd4;
border-radius: 2px;
transition: width 1s linear; transition: width 1s linear;
} }
</style> </style>
</head> </head>
<body> <body>
<!-- Navbar --> <!-- Navbar -->
<nav class="navbar"> <nav class="navbar">
<a class="navbar-logo" href="#" aria-label="Nubes"> <a class="navbar-logo" href="#" aria-label="Nubes">
<img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes"> <img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
</a> </a>
@@ -406,20 +195,19 @@
<div class="navbar-spacer"></div> <div class="navbar-spacer"></div>
<div class="refresh-timer" id="refreshTimer" style="display:none"> <div class="refresh-timer" id="refreshTimer" style="display:none">
<span id="refreshCountdown">30</span>s <span id="refreshCountdown">30</span>s
<div class="refresh-progress"> <div class="refresh-progress"><div class="refresh-bar" id="refreshBar" style="width:100%"></div></div>
<div class="refresh-bar" id="refreshBar" style="width:100%"></div>
</div>
</div> </div>
<button class="navbar-refresh" id="btnRefresh" onclick="loadStats()" disabled>Обновить</button> <button class="navbar-refresh" id="btnRefresh" onclick="loadStats()" disabled>Обновить</button>
</nav> </nav>
<!-- Main content --> <!-- Main content -->
<div class="container"> <div class="container">
<!-- Auth box (показывается до ввода токена) --> <!-- Auth box (показывается до ввода токена) -->
<div class="auth-box" id="authBox"> <div class="auth-box" id="authBox">
<h2>Доступ для администратора</h2> <h2>Доступ для администратора</h2>
<p>Введите ADMIN_STATS_TOKEN для просмотра статистики IoT pipeline.</p> <p>Введите ADMIN_STATS_TOKEN для просмотра статистики IoT pipeline.</p>
<input class="auth-input" id="tokenInput" type="password" placeholder="Bearer token..." autocomplete="off" <input class="auth-input" id="tokenInput" type="password"
placeholder="Bearer token..." autocomplete="off"
onkeydown="if(event.key==='Enter') doAuth()"> onkeydown="if(event.key==='Enter') doAuth()">
<button class="auth-btn" onclick="doAuth()">Войти</button> <button class="auth-btn" onclick="doAuth()">Войти</button>
<div class="auth-error" id="authError" style="display:none"></div> <div class="auth-error" id="authError" style="display:none"></div>
@@ -435,38 +223,34 @@
<!-- Данные --> <!-- Данные -->
<div id="dataContent" style="display:none"> <div id="dataContent" style="display:none">
<!-- shared-SQS: очередь телеметрии --> <!-- Kafka -->
<div class="section"> <div class="section">
<div class="section-header"> <div class="section-header">
<svg class="section-icon" viewBox="0 0 24 24" fill="none" stroke="#7fc8f8" stroke-width="2"> <svg class="section-icon" viewBox="0 0 24 24" fill="none" stroke="#7fc8f8" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z" /> <path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg> </svg>
<span class="section-title">Очередь shared-SQS</span> <span class="section-title">Kafka</span>
</div> </div>
<div class="cards" id="sqsCards"></div> <div class="cards" id="kafkaCards"></div>
</div> </div>
<!-- Рантайм процесса --> <!-- K8s Pods -->
<div class="section"> <div class="section">
<div class="section-header"> <div class="section-header">
<svg class="section-icon" viewBox="0 0 24 24" fill="none" stroke="#7fc8f8" stroke-width="2"> <svg class="section-icon" viewBox="0 0 24 24" fill="none" stroke="#7fc8f8" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" /> <rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>
<path d="M8 21h8M12 17v4" />
</svg> </svg>
<span class="section-title">Рантайм</span> <span class="section-title">Pods</span>
</div> </div>
<div class="cards" id="runtimeCards"></div> <div class="cards" id="podCards"></div>
</div> </div>
<!-- PostgreSQL per tenant --> <!-- PostgreSQL per tenant -->
<div class="section"> <div class="section">
<div class="section-header"> <div class="section-header">
<svg class="section-icon" viewBox="0 0 24 24" fill="none" stroke="#7fc8f8" stroke-width="2"> <svg class="section-icon" viewBox="0 0 24 24" fill="none" stroke="#7fc8f8" stroke-width="2">
<ellipse cx="12" cy="5" rx="9" ry="3" /> <ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/>
<path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5" /> <path d="M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3"/>
<path d="M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3" />
</svg> </svg>
<span class="section-title">PostgreSQL — Telemetry</span> <span class="section-title">PostgreSQL — Telemetry</span>
</div> </div>
@@ -490,17 +274,17 @@
<div class="last-updated" id="lastUpdated"></div> <div class="last-updated" id="lastUpdated"></div>
</div> </div>
</div> </div>
</div> </div>
<script> <script>
// ── State ────────────────────────────────────────────────────────────────── // ── State ──────────────────────────────────────────────────────────────────
const API_BASE = window.location.origin; const API_BASE = window.location.origin;
let adminToken = sessionStorage.getItem('iot_admin_token') || ''; let adminToken = sessionStorage.getItem('iot_admin_token') || '';
let refreshInterval = null; let refreshInterval = null;
let refreshCountdown = 30; let refreshCountdown = 30;
// ── Auth ─────────────────────────────────────────────────────────────────── // ── Auth ───────────────────────────────────────────────────────────────────
function doAuth() { function doAuth() {
const input = document.getElementById('tokenInput').value.trim(); const input = document.getElementById('tokenInput').value.trim();
if (!input) return; if (!input) return;
adminToken = input; adminToken = input;
@@ -508,9 +292,9 @@
document.getElementById('authBox').style.display = 'none'; document.getElementById('authBox').style.display = 'none';
document.getElementById('mainContent').style.display = 'block'; document.getElementById('mainContent').style.display = 'block';
loadStats(); loadStats();
} }
function showAuthError(msg) { function showAuthError(msg) {
const el = document.getElementById('authError'); const el = document.getElementById('authError');
el.textContent = msg; el.textContent = msg;
el.style.display = 'block'; el.style.display = 'block';
@@ -521,17 +305,17 @@
document.getElementById('mainContent').style.display = 'none'; document.getElementById('mainContent').style.display = 'none';
if (refreshInterval) { clearInterval(refreshInterval); refreshInterval = null; } if (refreshInterval) { clearInterval(refreshInterval); refreshInterval = null; }
document.getElementById('refreshTimer').style.display = 'none'; document.getElementById('refreshTimer').style.display = 'none';
} }
// Если токен уже в sessionStorage — пропускаем auth box // Если токен уже в sessionStorage — пропускаем auth box
if (adminToken) { if (adminToken) {
document.getElementById('authBox').style.display = 'none'; document.getElementById('authBox').style.display = 'none';
document.getElementById('mainContent').style.display = 'block'; document.getElementById('mainContent').style.display = 'block';
document.getElementById('spinner').style.display = 'block'; document.getElementById('spinner').style.display = 'block';
} }
// ── Load stats ───────────────────────────────────────────────────────────── // ── Load stats ─────────────────────────────────────────────────────────────
async function loadStats() { async function loadStats() {
if (!adminToken) return; if (!adminToken) return;
document.getElementById('btnRefresh').disabled = true; document.getElementById('btnRefresh').disabled = true;
document.getElementById('spinner').style.display = 'block'; document.getElementById('spinner').style.display = 'block';
@@ -571,16 +355,16 @@
} finally { } finally {
document.getElementById('btnRefresh').disabled = false; document.getElementById('btnRefresh').disabled = false;
} }
} }
function showBanner(msg) { function showBanner(msg) {
const el = document.getElementById('errorBanner'); const el = document.getElementById('errorBanner');
el.textContent = msg; el.textContent = msg;
el.style.display = 'block'; el.style.display = 'block';
} }
// ── Auto-refresh ─────────────────────────────────────────────────────────── // ── Auto-refresh ───────────────────────────────────────────────────────────
function setupAutoRefresh() { function setupAutoRefresh() {
if (refreshInterval) return; // уже запущен if (refreshInterval) return; // уже запущен
refreshInterval = setInterval(() => { refreshInterval = setInterval(() => {
refreshCountdown--; refreshCountdown--;
@@ -593,69 +377,98 @@
loadStats(); loadStats();
} }
}, 1000); }, 1000);
} }
function resetRefreshTimer() { function resetRefreshTimer() {
if (refreshInterval) { clearInterval(refreshInterval); refreshInterval = null; } if (refreshInterval) { clearInterval(refreshInterval); refreshInterval = null; }
refreshCountdown = 30; refreshCountdown = 30;
document.getElementById('refreshCountdown').textContent = '30'; document.getElementById('refreshCountdown').textContent = '30';
document.getElementById('refreshBar').style.width = '100%'; document.getElementById('refreshBar').style.width = '100%';
} }
// ── Render ───────────────────────────────────────────────────────────────── // ── Render ─────────────────────────────────────────────────────────────────
function renderAll(data) { function renderAll(data) {
renderSQS(data.sqs || {}); renderKafka(data.kafka || {});
renderRuntime(data.runtime || {}); renderPods(data.pods || {});
renderPostgres(data.postgres || {}); renderPostgres(data.postgres || {});
} }
// Очередь shared-SQS // Kafka section
function renderSQS(sqs) { function renderKafka(kafka) {
const el = document.getElementById('sqsCards'); const el = document.getElementById('kafkaCards');
if (sqs.error) { if (kafka.error) {
el.innerHTML = `<div class="card"><div class="card-title">Ошибка</div> el.innerHTML = `<div class="card"><div class="card-title">Ошибка</div>
<div class="card-value card-err" style="font-size:14px">${esc(sqs.error)}</div></div>`; <div class="card-value card-err" style="font-size:14px">${esc(kafka.error)}</div></div>`;
return; return;
} }
const pending = sqs.approximate_messages || 0;
const inFlight = sqs.approximate_messages_not_visible || 0; const lag = kafka.consumer_lag || 0;
const pendingClass = pending === 0 ? 'card-ok' : pending < 100 ? 'card-warn' : 'card-err'; const latest = kafka.latest_offset || 0;
const committed = kafka.committed_offset || 0;
const lagClass = lag === 0 ? 'card-ok' : lag < 100 ? 'card-warn' : 'card-err';
const barClass = lag === 0 ? 'lag-bar-ok' : 'lag-bar-warn';
const barWidth = latest > 0 ? Math.max(2, Math.round((committed / latest) * 100)) : 100;
el.innerHTML = ` el.innerHTML = `
<div class="card"> <div class="card">
<div class="card-title">Ожидают обработки</div> <div class="card-title">Consumer Lag</div>
<div class="card-value ${pendingClass}">${pending.toLocaleString()}</div> <div class="card-value ${lagClass}">${lag}</div>
<div class="card-sub">ApproximateNumberOfMessages</div> <div class="card-sub">iot-pg-consumer / iot.telemetry</div>
<div class="lag-bar-wrap"><div class="lag-bar ${barClass}" style="width:${barWidth}%"></div></div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title">В обработке</div> <div class="card-title">Latest Offset (всего прошло)</div>
<div class="card-value card-accent">${inFlight.toLocaleString()}</div> <div class="card-value card-accent">${latest.toLocaleString()}</div>
<div class="card-sub">ApproximateNumberOfMessagesNotVisible</div> <div class="card-sub">Kafka log end offset</div>
</div>`;
}
// Рантайм процесса
function renderRuntime(rt) {
const el = document.getElementById('runtimeCards');
const uptime = rt.uptime_sec || 0;
const h = Math.floor(uptime / 3600);
const m = Math.floor((uptime % 3600) / 60);
const s = uptime % 60;
const uptimeStr = h > 0 ? `${h}ч ${m}м` : m > 0 ? `${m}м ${s}с` : `${s}с`;
el.innerHTML = `
<div class="card">
<div class="card-title">Версия</div>
<div class="card-value" style="font-size:20px">${esc(rt.version || '—')}</div>
<div class="card-sub">iot-service</div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title">Uptime</div> <div class="card-title">Committed Offset</div>
<div class="card-value card-accent">${uptimeStr}</div> <div class="card-value">${committed.toLocaleString()}</div>
<div class="card-sub">с ${esc(rt.started_at || '—')}</div> <div class="card-sub">Consumer обработал</div>
</div>`; </div>`;
}
// Pods section
function renderPods(pods) {
const el = document.getElementById('podCards');
el.innerHTML = '';
const labels = {
'iot-mqtt-bridge': 'MQTT Bridge',
'iot-kafka-consumer': 'Kafka Consumer'
};
for (const [key, pod] of Object.entries(pods)) {
const title = labels[key] || key;
if (pod.error || pod.status === 'not found') {
el.innerHTML += `
<div class="pod-card">
<div class="pod-name">${esc(title)}</div>
<div style="color:#f87171;font-size:12px">${esc(pod.error || 'Pod not found')}</div>
</div>`;
continue;
} }
// Postgres section const readyBadge = pod.ready
function renderPostgres(pg) { ? '<span class="badge badge-ok">Ready</span>'
: '<span class="badge badge-warn">Not Ready</span>';
const restartColor = pod.restarts > 5 ? 'card-err' : pod.restarts > 0 ? 'card-warn' : 'card-ok';
el.innerHTML += `
<div class="pod-card">
<div class="pod-name">
<span class="dot dot-${pod.ready ? 'ok' : 'warn'}"></span>${esc(title)}
</div>
<div class="pod-row"><span class="pod-label">Pod</span><span class="pod-val" style="font-family:monospace;font-size:11px">${esc(pod.name)}</span></div>
<div class="pod-row"><span class="pod-label">Phase</span><span class="pod-val">${esc(pod.phase)} ${readyBadge}</span></div>
<div class="pod-row"><span class="pod-label">Restarts</span><span class="pod-val ${restartColor}">${pod.restarts}</span></div>
<div class="pod-row"><span class="pod-label">Age</span><span class="pod-val">${esc(pod.age)}</span></div>
</div>`;
}
}
// Postgres section
function renderPostgres(pg) {
const summaryEl = document.getElementById('pgSummaryCards'); const summaryEl = document.getElementById('pgSummaryCards');
const tbodyEl = document.getElementById('tenantTableBody'); const tbodyEl = document.getElementById('tenantTableBody');
@@ -699,38 +512,37 @@
return `<tr> return `<tr>
<td><span class="ns-tag">${esc(t.namespace)}</span><br> <td><span class="ns-tag">${esc(t.namespace)}</span><br>
<span style="font-size:11px;color:#2d5070">${esc(t.db_name)}</span></td> <span style="font-size:11px;color:#2d5070">${esc(t.db_name)}</span></td>
<td><span class="num-big">${(t.total || 0).toLocaleString()}</span></td> <td><span class="num-big">${(t.total||0).toLocaleString()}</span></td>
<td><span class="num-small">${(t.last_1h || 0).toLocaleString()}</span></td> <td><span class="num-small">${(t.last_1h||0).toLocaleString()}</span></td>
<td><span class="num-small">${(t.last_24h || 0).toLocaleString()}</span></td> <td><span class="num-small">${(t.last_24h||0).toLocaleString()}</span></td>
<td>${latestHtml}</td> <td>${latestHtml}</td>
</tr>`; </tr>`;
}).join(''); }).join('');
} }
// ── Utils ────────────────────────────────────────────────────────────────── // ── Utils ──────────────────────────────────────────────────────────────────
function esc(str) { function esc(str) {
if (str == null) return ''; if (str == null) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
} }
function truncate(str, len) { function truncate(str, len) {
if (!str) return ''; if (!str) return '';
return str.length > len ? str.slice(0, len) + '…' : str; return str.length > len ? str.slice(0, len) + '…' : str;
} }
function formatTs(ts) { function formatTs(ts) {
if (!ts) return ''; if (!ts) return '';
try { try {
const d = new Date(ts); const d = new Date(ts);
return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); return d.toLocaleTimeString('ru-RU', {hour:'2-digit', minute:'2-digit', second:'2-digit'});
} catch { return ts; } } catch { return ts; }
} }
// ── Init ─────────────────────────────────────────────────────────────────── // ── Init ───────────────────────────────────────────────────────────────────
if (adminToken) { if (adminToken) {
loadStats(); loadStats();
} }
</script> </script>
</body> </body>
</html> </html>
File diff suppressed because it is too large Load Diff
+16 -45
View File
@@ -19,26 +19,12 @@ import (
// telemetryTopicFilter — все namespace, все устройства. // telemetryTopicFilter — все namespace, все устройства.
const telemetryTopicFilter = "+/telemetry/+" const telemetryTopicFilter = "+/telemetry/+"
// subscribeRetryInterval — пауза между попытками подписки при сбое. // Run — бесконечный цикл bridge: подключение → подписка → ожидание сигнала завершения.
// Подписка обязательна (иначе bridge «глохнет»: соединение есть, а топик не слушается). func Run(ctx context.Context, cfg *config.Config, sqsClient *sqs.Client, log *slog.Logger) error {
const subscribeRetryInterval = 2 * time.Second
// setSubscribed — nil-safe вызов колбэка состояния подписки.
func setSubscribed(fn func(bool), v bool) {
if fn != nil {
fn(v)
}
}
// Run — бесконечный цикл bridge. onSubscribed — колбэк состояния подписки
// (true после успешного SUBSCRIBE, false при потере соединения/сбое подписки):
// /ready должен отражать реальную подписку, а не факт запуска горутины
// (находка #3: «соединён, но не подписан» молча терял телеметрию при /ready=200).
func Run(ctx context.Context, cfg *config.Config, sqsClient *sqs.Client, log *slog.Logger, onSubscribed func(bool)) error {
log.Info("bridge: starting", "broker", cfg.MQTTBrokerURL, "client_id", cfg.MQTTClientID) log.Info("bridge: starting", "broker", cfg.MQTTBrokerURL, "client_id", cfg.MQTTClientID)
// URL очереди получаем один раз при старте (как старый consumer). // URL очереди получаем один раз при старте (как старый consumer).
queueURL, err := sqsclient.ResolveQueueURLWithRetry(ctx, sqsClient, cfg.SQSQueueName) queueURL, err := sqsclient.ResolveQueueURL(ctx, sqsClient, cfg.SQSQueueName)
if err != nil { if err != nil {
return err return err
} }
@@ -52,11 +38,11 @@ func Run(ctx context.Context, cfg *config.Config, sqsClient *sqs.Client, log *sl
// Отправка в SQS — через диспатчер (канал + worker-пул): колбэк MQTT // Отправка в SQS — через диспатчер (канал + worker-пул): колбэк MQTT
// не блокируется сетью (фикс CRITICAL из ревью 2026-08-16). // не блокируется сетью (фикс CRITICAL из ревью 2026-08-16).
dispatcher := newSQSDispatcher(sqsClient, queueURL, log) dispatcher := newSQSDispatcher(sqsClient, queueURL, log)
dispatcher.start() dispatcher.start(ctx)
handler := newMessageHandler(dispatcher, log) handler := newMessageHandler(dispatcher, log)
client, err := connectMQTT(ctx, cfg, log, handler, onSubscribed) client, err := connectMQTT(ctx, cfg, log, handler)
if err != nil { if err != nil {
dispatcher.closeAndWait() dispatcher.closeAndWait()
return err return err
@@ -80,7 +66,7 @@ func (e errBridge) Error() string { return string(e) }
// connectMQTT устанавливает подключение к EMQX с автореконнектом. // connectMQTT устанавливает подключение к EMQX с автореконнектом.
// handler передаётся в OnConnectHandler: подписка повторяется при каждом подключении. // handler передаётся в OnConnectHandler: подписка повторяется при каждом подключении.
func connectMQTT(ctx context.Context, cfg *config.Config, log *slog.Logger, handler mqtt.MessageHandler, onSubscribed func(bool)) (mqtt.Client, error) { func connectMQTT(ctx context.Context, cfg *config.Config, log *slog.Logger, handler mqtt.MessageHandler) (mqtt.Client, error) {
opts := mqtt.NewClientOptions() opts := mqtt.NewClientOptions()
opts.AddBroker(cfg.MQTTBrokerURL) opts.AddBroker(cfg.MQTTBrokerURL)
opts.SetClientID(cfg.MQTTClientID) opts.SetClientID(cfg.MQTTClientID)
@@ -89,44 +75,29 @@ func connectMQTT(ctx context.Context, cfg *config.Config, log *slog.Logger, hand
opts.SetAutoReconnect(true) opts.SetAutoReconnect(true)
opts.SetConnectRetry(true) opts.SetConnectRetry(true)
opts.SetConnectRetryInterval(5 * time.Second) opts.SetConnectRetryInterval(5 * time.Second)
// MaxReconnectInterval по умолчанию ~10 минут — слишком долго для потоковой
// телеметрии; 30с (ревью 2026-10-10).
opts.SetMaxReconnectInterval(30 * time.Second)
opts.SetKeepAlive(30 * time.Second) opts.SetKeepAlive(30 * time.Second)
opts.SetCleanSession(false) opts.SetCleanSession(false)
opts.SetConnectionLostHandler(func(_ mqtt.Client, err error) { opts.SetConnectionLostHandler(func(_ mqtt.Client, err error) {
log.Warn("bridge: MQTT connection lost, reconnecting...", "err", err) log.Warn("bridge: MQTT connection lost, reconnecting...", "err", err)
setSubscribed(onSubscribed, false)
}) })
opts.SetReconnectingHandler(func(_ mqtt.Client, _ *mqtt.ClientOptions) { opts.SetReconnectingHandler(func(_ mqtt.Client, _ *mqtt.ClientOptions) {
log.Info("bridge: MQTT reconnecting...") log.Info("bridge: MQTT reconnecting...")
}) })
opts.SetOnConnectHandler(func(c mqtt.Client) { opts.SetOnConnectHandler(func(c mqtt.Client) {
log.Info("bridge: MQTT connected") log.Info("bridge: MQTT connected")
// Подписка обязательна. Ретраим пока не удастся (или не оборвётся ctx/
// соединение). НЕ вызываем c.Disconnect(): в paho это ЯВНОЕ отключение,
// после которого автореконнект НЕ срабатывает (ревью 2026-10-10, 🔴6).
// Раньше было 3 попытки и ожидание следующего реконнекта — но при живом
// соединении OnConnectHandler больше не вызовется, и bridge оставался бы
// без подписки навсегда (ревью 2026-10-11).
for {
if ctx.Err() != nil || !c.IsConnected() {
return
}
token := c.Subscribe(telemetryTopicFilter, 1, handler) token := c.Subscribe(telemetryTopicFilter, 1, handler)
if token.WaitTimeout(10*time.Second) && token.Error() == nil { if !token.WaitTimeout(10 * time.Second) {
log.Error("bridge: resubscribe timeout", "filter", telemetryTopicFilter)
c.Disconnect(100) // принудительный реконнект (подписка обязательна)
return
}
if token.Error() != nil {
log.Error("bridge: resubscribe failed", "filter", telemetryTopicFilter, "err", token.Error())
c.Disconnect(100) // принудительный реконнект (подписка обязательна)
return
}
log.Info("bridge: subscribed", "filter", telemetryTopicFilter) log.Info("bridge: subscribed", "filter", telemetryTopicFilter)
setSubscribed(onSubscribed, true)
return
}
log.Error("bridge: subscribe failed, retrying", "filter", telemetryTopicFilter, "err", token.Error())
select {
case <-ctx.Done():
return
case <-time.After(subscribeRetryInterval):
}
}
}) })
client := mqtt.NewClient(opts) client := mqtt.NewClient(opts)
+3 -25
View File
@@ -5,15 +5,13 @@
package bridge package bridge
import ( import (
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"log/slog" "log/slog"
"regexp"
"strings" "strings"
"time" "time"
mqtt "github.com/eclipse/paho.mqtt.golang" mqtt "github.com/eclipse/paho.mqtt.golang"
"github.com/google/uuid"
) )
// TelemetryEnvelope — сообщение, перекладываемое в SQS (разбирается consumer). // TelemetryEnvelope — сообщение, перекладываемое в SQS (разбирается consumer).
@@ -44,13 +42,6 @@ func newMessageHandler(dispatcher *sqsDispatcher, log *slog.Logger) mqtt.Message
ns := parts[0] ns := parts[0]
deviceID := parts[2] deviceID := parts[2]
// Defense-in-depth: EMQX ACL — основной барьер, но при его ошибке/отключении
// бридж принял бы произвольный namespace. Дополнительно проверяем формат.
if !identRe.MatchString(ns) || !identRe.MatchString(deviceID) {
log.Warn("bridge: invalid namespace/device in topic, skipping", "topic", topic)
return
}
// Не-JSON payload оборачиваем в строку. // Не-JSON payload оборачиваем в строку.
rawPayload := json.RawMessage(payload) rawPayload := json.RawMessage(payload)
if !json.Valid(payload) { if !json.Valid(payload) {
@@ -63,21 +54,8 @@ func newMessageHandler(dispatcher *sqsDispatcher, log *slog.Logger) mqtt.Message
DeviceID: deviceID, DeviceID: deviceID,
Topic: topic, Topic: topic,
Payload: rawPayload, Payload: rawPayload,
ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano), ReceivedAt: time.Now().UTC().Format(time.RFC3339),
MessageID: messageIDFor(ns, deviceID, topic, payload), MessageID: uuid.NewString(),
}) })
} }
} }
// identRe — безопасный идентификатор (namespace/device_id): буквы/цифры/дефис,
// не с дефиса, ≤64 символов. Defense-in-depth поверх EMQX ACL.
var identRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]{0,63}$`)
// messageIDFor — детерминированный id сообщения для дедупа.
// Повторная QoS1-доставка того же (device, topic, payload) даёт тот же id,
// поэтому ON CONFLICT (device_id, message_id) в БД дедуплицирует дубликаты
// (раньше uuid на каждый колбэк — дедуп на повторе не срабатывал).
func messageIDFor(ns, deviceID, topic string, payload []byte) string {
h := sha256.Sum256([]byte(ns + "|" + deviceID + "|" + topic + "|" + string(payload)))
return hex.EncodeToString(h[:])
}
+19 -90
View File
@@ -12,7 +12,6 @@ package bridge
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"log/slog" "log/slog"
"sync" "sync"
"sync/atomic" "sync/atomic"
@@ -20,7 +19,6 @@ import (
"github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/sqs" "github.com/aws/aws-sdk-go-v2/service/sqs"
"github.com/aws/smithy-go"
) )
// Константы диспатчера. // Константы диспатчера.
@@ -29,18 +27,8 @@ const (
dispatcherQueueSize = 10000 dispatcherQueueSize = 10000
// dispatcherWorkers — горутин, отправляющих в SQS. // dispatcherWorkers — горутин, отправляющих в SQS.
dispatcherWorkers = 4 dispatcherWorkers = 4
// sendMaxAttempts — попыток SendMessage на одно сообщение.
// sendAttemptTimeout — таймаут ОДНОЙ попытки SendMessage. Без него зависшее sendMaxAttempts = 3
// соединение (SQS висит, но не отвечает) блокировало бы воркера навсегда и
// забило бы весь пул (ревью 🔴4).
sendAttemptTimeout = 10 * time.Second
// sendMaxBackoff — потолок паузы между попытками (экспоненциальный backoff).
sendMaxBackoff = 8 * time.Second
// bridgeFlushTimeout — сколько ждать слива остатка буфера в SQS при shutdown.
// Если SQS лежит и ретраи не завершаются, по этому таймауту ctx отменяется,
// воркеры обрывают отправку (остаток логируется как дропнутый).
bridgeFlushTimeout = 10 * time.Second
// maxSQSBodyBytes — потолок размера envelope для SQS. // maxSQSBodyBytes — потолок размера envelope для SQS.
// SQS (и shared-sqs) режут сообщения >256KB — запас на оверхед. // SQS (и shared-sqs) режут сообщения >256KB — запас на оверхед.
@@ -55,54 +43,31 @@ type sqsDispatcher struct {
log *slog.Logger log *slog.Logger
wg sync.WaitGroup wg sync.WaitGroup
dropped atomic.Int64 dropped atomic.Int64
// mu защищает закрытие канала: не даёт enqueue писать в уже закрытый ch
// (паника «send on closed channel» от позднего колбэка paho — ревью 🔴3).
mu sync.Mutex
closed bool
// ctx для отправки НЕ связан с сигнальным ctx bridge: иначе при shutdown
// воркеры сразу вернули бы «context canceled» и дропнули остаток буфера
// (ревью 🔴3). Отменяется в closeAndWait по bridgeFlushTimeout, если воркеры
// не успели слить остаток.
ctx context.Context
cancel context.CancelFunc
} }
// newSQSDispatcher создаёт диспатчер. start() запускает worker-пул. // newSQSDispatcher создаёт диспатчер. start() запускает worker-пул.
func newSQSDispatcher(client *sqs.Client, queueURL string, log *slog.Logger) *sqsDispatcher { func newSQSDispatcher(client *sqs.Client, queueURL string, log *slog.Logger) *sqsDispatcher {
// ctx независимый от сигнала bridge: см. поле ctx. Отменяется только в
// closeAndWait (по таймауту), чтобы при shutdown воркеры успели слить буфер.
ctx, cancel := context.WithCancel(context.Background())
return &sqsDispatcher{ return &sqsDispatcher{
ch: make(chan TelemetryEnvelope, dispatcherQueueSize), ch: make(chan TelemetryEnvelope, dispatcherQueueSize),
client: client, client: client,
queueURL: queueURL, queueURL: queueURL,
log: log, log: log,
ctx: ctx,
cancel: cancel,
} }
} }
// start запускает worker-пул. Воркеры используют d.ctx (независимый от сигнала). // start запускает worker-пул.
func (d *sqsDispatcher) start() { func (d *sqsDispatcher) start(ctx context.Context) {
for i := 0; i < dispatcherWorkers; i++ { for i := 0; i < dispatcherWorkers; i++ {
d.wg.Add(1) d.wg.Add(1)
go func(wid int) { go func(wid int) {
defer d.wg.Done() defer d.wg.Done()
d.worker(wid) d.worker(ctx, wid)
}(i) }(i)
} }
} }
// enqueue кладёт envelope в канал НЕблокирующе. false = буфер полон (дроп) или // enqueue кладёт envelope в канал НЕблокирующе. false = буфер полон (дроп).
// канал уже закрыт (shutdown). Под mu, чтобы не писать в закрытый ch.
func (d *sqsDispatcher) enqueue(e TelemetryEnvelope) bool { func (d *sqsDispatcher) enqueue(e TelemetryEnvelope) bool {
d.mu.Lock()
defer d.mu.Unlock()
if d.closed {
return false
}
select { select {
case d.ch <- e: case d.ch <- e:
return true return true
@@ -117,25 +82,15 @@ func (d *sqsDispatcher) enqueue(e TelemetryEnvelope) bool {
} }
} }
// closeAndWait закрывает канал и ждёт, пока воркеры сольют остаток буфера в SQS. // closeAndWait закрывает канал и ждёт завершения worker'ов (flush).
// Закрытие — под mu (см. поле mu): после него поздний колбэк paho не упадёт на
// «send on closed channel». Флаш ограничен bridgeFlushTimeout: если SQS лежит,
// отменяем d.ctx, чтобы sendWithRetry оборвался и wg.Wait() вернулся.
func (d *sqsDispatcher) closeAndWait() { func (d *sqsDispatcher) closeAndWait() {
d.mu.Lock()
d.closed = true
close(d.ch) close(d.ch)
d.mu.Unlock()
t := time.AfterFunc(bridgeFlushTimeout, d.cancel)
d.wg.Wait() d.wg.Wait()
t.Stop()
d.log.Info("bridge: SQS dispatcher stopped", "dropped_total", d.dropped.Load()) d.log.Info("bridge: SQS dispatcher stopped", "dropped_total", d.dropped.Load())
} }
// worker — цикл отправки из канала с ретраями. Использует d.ctx (не сигнальный), // worker — цикл отправки из канала с ограниченными ретраями.
// чтобы при shutdown остаток буфера успел сойти в SQS. func (d *sqsDispatcher) worker(ctx context.Context, wid int) {
func (d *sqsDispatcher) worker(wid int) {
for e := range d.ch { for e := range d.ch {
body, err := json.Marshal(e) body, err := json.Marshal(e)
if err != nil { if err != nil {
@@ -153,7 +108,7 @@ func (d *sqsDispatcher) worker(wid int) {
"limit_bytes", maxSQSBodyBytes) "limit_bytes", maxSQSBodyBytes)
continue continue
} }
if err := d.sendWithRetry(d.ctx, string(body)); err != nil { if err := d.sendWithRetry(ctx, string(body)); err != nil {
d.log.Error("bridge: SQS send failed after retries, dropping", d.log.Error("bridge: SQS send failed after retries, dropping",
"worker", wid, "namespace", e.Namespace, "worker", wid, "namespace", e.Namespace,
"device", e.DeviceID, "err", err) "device", e.DeviceID, "err", err)
@@ -161,56 +116,30 @@ func (d *sqsDispatcher) worker(wid int) {
} }
} }
// sendWithRetry — SendMessage с ретраями до остановки (ctx) и таймаутом на // sendWithRetry — SendMessage с backoff 1с/2с/4с.
// попытку. НЕ дропаем после фиксированного числа попыток (было 3 ≈ 7с): буфер
// на dispatcherQueueSize рассчитан на поглощение временной недоступности SQS,
// поэтому ретраим до ctx.Done() (shutdown) или успеха — иначе потеря была бы
// безвозвратной (ревью 🔴4).
func (d *sqsDispatcher) sendWithRetry(ctx context.Context, body string) error { func (d *sqsDispatcher) sendWithRetry(ctx context.Context, body string) error {
var lastErr error
backoff := time.Second backoff := time.Second
for { for attempt := 1; attempt <= sendMaxAttempts; attempt++ {
attemptCtx, cancel := context.WithTimeout(ctx, sendAttemptTimeout) _, err := d.client.SendMessage(ctx, &sqs.SendMessageInput{
_, err := d.client.SendMessage(attemptCtx, &sqs.SendMessageInput{
QueueUrl: aws.String(d.queueURL), QueueUrl: aws.String(d.queueURL),
MessageBody: aws.String(body), MessageBody: aws.String(body),
}) })
cancel()
if err == nil { if err == nil {
return nil return nil
} }
// Permanent ошибки (неверный параметр/размер/нет очереди/доступ) — ретрай lastErr = err
// бессмыслен: вернуть сразу, иначе воркер залип бы на poison-сообщении и
// заполнил буфер (дроп остальных).
if isPermanentSQSErr(err) {
return err
}
if ctx.Err() != nil { if ctx.Err() != nil {
// shutdown: ctx отменён, ретраить бессмысленно. return lastErr
return err
} }
if attempt < sendMaxAttempts {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return err return lastErr
case <-time.After(backoff): case <-time.After(backoff):
} }
if backoff < sendMaxBackoff {
backoff *= 2 backoff *= 2
} }
} }
} return lastErr
// isPermanentSQSErr — true для ошибок SQS, которые не пройдут ни при каком
// ретрае (валидация, размер, отсутствие очереди, доступ). Остальное (throttling,
// 5xx, сеть) — retryable.
func isPermanentSQSErr(err error) bool {
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
switch apiErr.ErrorCode() {
case "InvalidParameterValue", "InvalidMessageContents", "MessageTooBig",
"QueueDoesNotExist", "InvalidAddress", "InvalidSecurity",
"AccessDenied", "InvalidBatchEntryId":
return true
}
}
return false
} }
-45
View File
@@ -1,45 +0,0 @@
// sender_test.go — тест безопасности enqueue ↔ closeAndWait (фикс 🔴3).
//
// Раньше поздний колбэк paho мог упасть «send on closed channel» при закрытии
// канала во время enqueue. Мьютекс + closed-флаг должны это исключать.
package bridge
import (
"io"
"log/slog"
"sync"
"testing"
)
func TestDispatcherEnqueueCloseNoPanic(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil))
// client nil — enqueue/closeAndWait его не трогают (только worker'ы, которых не запускаем).
d := newSQSDispatcher(nil, "http://queue", log)
// Параллельный enqueue, пока основной поток закрывает канал.
var wg sync.WaitGroup
stop := make(chan struct{})
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
d.enqueue(TelemetryEnvelope{Namespace: "ns", DeviceID: "d"})
}
}
}()
}
d.closeAndWait()
close(stop)
wg.Wait()
// После закрытия enqueue обязан вернуть false (канал закрыт), а не паниковать.
if d.enqueue(TelemetryEnvelope{Namespace: "ns", DeviceID: "d"}) {
t.Error("enqueue после close должен вернуть false")
}
}
+1 -8
View File
@@ -123,17 +123,13 @@ func Load() (*Config, error) {
LogLevel: parseLogLevel(os.Getenv("LOG_LEVEL"), slog.LevelInfo), LogLevel: parseLogLevel(os.Getenv("LOG_LEVEL"), slog.LevelInfo),
} }
// MQTT broker URL: либо полный MQTT_BROKER_URL, либо из HOST/PORT/WS_PATH. // MQTT broker URL: либо полный MQTT_BROKER_URL, либо из HOST/PORT/WS_PATH
// MQTT_HOST обязателен: EMQX в другом namespace платформы, дефолта нет
// (см. DefaultMQTTHost).
cfg.MQTTBrokerURL = os.Getenv("MQTT_BROKER_URL") cfg.MQTTBrokerURL = os.Getenv("MQTT_BROKER_URL")
if cfg.MQTTBrokerURL == "" { if cfg.MQTTBrokerURL == "" {
host := getEnv("MQTT_HOST", DefaultMQTTHost) host := getEnv("MQTT_HOST", DefaultMQTTHost)
port := getEnv("MQTT_PORT", DefaultMQTTPort) port := getEnv("MQTT_PORT", DefaultMQTTPort)
path := getEnv("MQTT_WS_PATH", DefaultMQTTWSPath) path := getEnv("MQTT_WS_PATH", DefaultMQTTWSPath)
if host != "" {
cfg.MQTTBrokerURL = "ws://" + host + ":" + port + path cfg.MQTTBrokerURL = "ws://" + host + ":" + port + path
}
} else if !strings.HasPrefix(cfg.MQTTBrokerURL, "ws://") && } else if !strings.HasPrefix(cfg.MQTTBrokerURL, "ws://") &&
!strings.HasPrefix(cfg.MQTTBrokerURL, "wss://") { !strings.HasPrefix(cfg.MQTTBrokerURL, "wss://") {
return nil, fmt.Errorf("MQTT_BROKER_URL must start with ws:// or wss://, got %q", cfg.MQTTBrokerURL) return nil, fmt.Errorf("MQTT_BROKER_URL must start with ws:// or wss://, got %q", cfg.MQTTBrokerURL)
@@ -143,9 +139,6 @@ func Load() (*Config, error) {
if cfg.IOTPGDSN == "" { if cfg.IOTPGDSN == "" {
missing = append(missing, "IOT_PG_DSN") missing = append(missing, "IOT_PG_DSN")
} }
if cfg.MQTTBrokerURL == "" {
missing = append(missing, "MQTT_HOST (или MQTT_BROKER_URL)")
}
if cfg.SQSAccessKey == "" { if cfg.SQSAccessKey == "" {
missing = append(missing, "SQS_ACCESS_KEY") missing = append(missing, "SQS_ACCESS_KEY")
} }
+6 -8
View File
@@ -6,9 +6,9 @@ const (
// DefaultAPIPort — единственный порт контейнера: API + /health + /metrics. // DefaultAPIPort — единственный порт контейнера: API + /health + /metrics.
DefaultAPIPort = "9090" DefaultAPIPort = "9090"
// DefaultSQSEndpoint — публичный endpoint shared-sqs (ВМ 213), внешний доступ // DefaultSQSEndpoint — внутренний адрес shared-sqs в том же кластере Nubes.
// обязателен: shared-sqs живёт НЕ в кластере Nubes. Переопределяется SQS_ENDPOINT. // Обходит внешний шлюз платформы (таймауты 31-33с, MSS 1448, ~150с).
DefaultSQSEndpoint = "https://sqs.sinefo.ru" DefaultSQSEndpoint = "http://containerk8s.f1ffb134-7d16-45bd-8bef-69f6ec8ab33c.svc.cluster.local:4100"
// DefaultSQSQueueName — очередь телеметрии IoT. // DefaultSQSQueueName — очередь телеметрии IoT.
DefaultSQSQueueName = "iot-telemetry" DefaultSQSQueueName = "iot-telemetry"
@@ -21,11 +21,9 @@ const (
// DefaultSQSRegion — регион для self-hosted SQS (не важен, оставлен для SDK). // DefaultSQSRegion — регион для self-hosted SQS (не важен, оставлен для SDK).
DefaultSQSRegion = "us-east-1" DefaultSQSRegion = "us-east-1"
// DefaultMQTTHost — пусто: вменяемого дефолта нет, EMQX живёт в ДРУГОМ // DefaultMQTTHost — внутреннее имя EMQX-контейнера на платформе.
// namespace платформы и доступен только по FQDN // Реальный UUID namespace EMQX задаётся в deck-UI (MQTT_HOST) при создании.
// containerk8s.<instanceUid EMQX>.svc.cluster.local. Поэтому MQTT_HOST обязателен DefaultMQTTHost = "emqx"
// (задаётся Terraform'ом из state EMQX), иначе config.Load вернёт ошибку.
DefaultMQTTHost = ""
// DefaultMQTTPort — WebSocket-листенер EMQX (wss внутри платформы). // DefaultMQTTPort — WebSocket-листенер EMQX (wss внутри платформы).
DefaultMQTTPort = "8083" DefaultMQTTPort = "8083"
+30 -117
View File
@@ -8,12 +8,10 @@ import (
"context" "context"
"errors" "errors"
"log/slog" "log/slog"
"strconv"
"time" "time"
"github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/sqs" "github.com/aws/aws-sdk-go-v2/service/sqs"
sqstypes "github.com/aws/aws-sdk-go-v2/service/sqs/types"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/config" "gitea.services.ngcloud.ru/Nail/IoT/internal/service/config"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/sqsclient" "gitea.services.ngcloud.ru/Nail/IoT/internal/service/sqsclient"
@@ -26,37 +24,18 @@ const errorBackoff = 5 * time.Second
// maxProcessBackoff — потолок backoff после ошибок обработки (PG). // maxProcessBackoff — потолок backoff после ошибок обработки (PG).
const maxProcessBackoff = 60 * time.Second const maxProcessBackoff = 60 * time.Second
// maxReceiveCount — после стольких приёмов «ядовитое» сообщение дропается, если // Run — бесконечный long-poll цикл потребителя.
// DLQ недоступна: иначе оно крутилось бы на основной очереди бесконечно func Run(ctx context.Context, cfg *config.Config, sqsClient *sqs.Client, store *iotpg.IoTPostgresStore, log *slog.Logger) error {
// (находка #2 — без чтения ApproximateReceiveCount). При доступной DLQ битое queueURL, err := sqsclient.ResolveQueueURL(ctx, sqsClient, cfg.SQSQueueName)
// уходит туда на первом же приёме.
const maxReceiveCount = 5
// Run — бесконечный long-poll цикл потребителя. onReady — колбэк готовности:
// true после успешного ReceiveMessage, false при ошибке SQS (отражается в /ready).
func Run(ctx context.Context, cfg *config.Config, sqsClient *sqs.Client, store *iotpg.IoTPostgresStore, log *slog.Logger, onReady func(bool)) error {
queueURL, err := sqsclient.ResolveQueueURLWithRetry(ctx, sqsClient, cfg.SQSQueueName)
if err != nil { if err != nil {
return err return err
} }
// DLQ резолвим с ретраями: временная ошибка ResolveQueueURL НЕ должна навсегда // DLQ присутствует не всегда: резолвим лениво, отказ не фатален (битые
// отключить DLQ — иначе битые сообщения подтверждались бы и терялись без следа // сообщения тогда просто НЕ подтверждаются и оседают после ретраев).
// (ревью 2026-10-10, 🔴7). При окончательном провале dlqURL остаётся "" — dlqURL, dlqErr := sqsclient.ResolveQueueURL(ctx, sqsClient, cfg.SQSDLQName)
// битые сообщения тогда НЕ подтверждаются (см. обработку errInvalidEnvelope). if dlqErr != nil {
dlqURL := "" log.Warn("consumer: DLQ не найдена, битые сообщения будут только логироваться", "dlq", cfg.SQSDLQName, "err", dlqErr)
for attempt := 1; attempt <= 3; attempt++ { dlqURL = ""
u, err := sqsclient.ResolveQueueURL(ctx, sqsClient, cfg.SQSDLQName)
if err == nil {
dlqURL = u
break
}
log.Warn("consumer: DLQ resolve failed", "dlq", cfg.SQSDLQName, "attempt", attempt, "err", err)
if !sleepCtx(ctx, errorBackoff) {
break
}
}
if dlqURL == "" {
log.Warn("consumer: DLQ недоступна — битые сообщения НЕ будут подтверждаться (останутся в очереди)", "dlq", cfg.SQSDLQName)
} }
log.Info("consumer: SQS queue resolved", "queue", cfg.SQSQueueName, "dlq", cfg.SQSDLQName) log.Info("consumer: SQS queue resolved", "queue", cfg.SQSQueueName, "dlq", cfg.SQSDLQName)
@@ -70,24 +49,17 @@ func Run(ctx context.Context, cfg *config.Config, sqsClient *sqs.Client, store *
MaxNumberOfMessages: 10, MaxNumberOfMessages: 10,
WaitTimeSeconds: int32(cfg.SQSLongPollSeconds), WaitTimeSeconds: int32(cfg.SQSLongPollSeconds),
VisibilityTimeout: int32(cfg.SQSVisibilityTimeout), VisibilityTimeout: int32(cfg.SQSVisibilityTimeout),
// Просим ApproximateReceiveCount — по нему ограничиваем повторы
// «ядовитого» сообщения (см. maxReceiveCount, находка #2).
MessageSystemAttributeNames: []sqstypes.MessageSystemAttributeName{
sqstypes.MessageSystemAttributeNameApproximateReceiveCount,
},
}) })
if err != nil { if err != nil {
if ctx.Err() != nil { if ctx.Err() != nil {
break break
} }
log.Error("consumer: ReceiveMessage failed", "err", err) log.Error("consumer: ReceiveMessage failed", "err", err)
setReady(onReady, false)
if !sleepCtx(ctx, errorBackoff) { if !sleepCtx(ctx, errorBackoff) {
break break
} }
continue continue
} }
setReady(onReady, true)
processBackoff := time.Second processBackoff := time.Second
// Копим подтверждения, но НЕ удаляем из SQS сразу: InsertTelemetry лишь // Копим подтверждения, но НЕ удаляем из SQS сразу: InsertTelemetry лишь
@@ -100,58 +72,27 @@ func Run(ctx context.Context, cfg *config.Config, sqsClient *sqs.Client, store *
} }
var pending []pendingAck var pending []pendingAck
for _, msg := range resp.Messages { for _, msg := range resp.Messages {
// nil/пустое тело не подтверждаем молча: прогоняем через processTelemetry, if msg.Body == nil || *msg.Body == "" {
// который вернёт errInvalidEnvelope → DLQ (ревью 2026-10-10: пустое тело log.Warn("consumer: empty message body", "message_id", aws.ToString(msg.MessageId))
// подтверждалось без DLQ). pending = append(pending, pendingAck{aws.ToString(msg.ReceiptHandle), aws.ToString(msg.MessageId)})
body := "" continue
if msg.Body != nil {
body = *msg.Body
} }
if err := processTelemetry(ctx, body, store, log); err != nil { if err := processTelemetry(ctx, *msg.Body, store, log); err != nil {
// Битый envelope → DLQ (а не бесконечный ретрай и не потеря): // Битый envelope → DLQ (а не бесконечный ретрай и не потеря):
// кладём тело в DLQ, затем подтверждаем основное сообщение. // кладём тело в DLQ, затем подтверждаем основное сообщение.
if errors.Is(err, errInvalidEnvelope) { if errors.Is(err, errInvalidEnvelope) {
// Битый envelope → DLQ. Подтверждаем только если DLQ реально
// приняла сообщение (иначе битый теряется — 🔴7). Если DLQ
// недоступна/сбой — ограничиваем число повторов, чтобы не
// зациклиться навсегда (находка #2): после maxReceiveCount
// дропаем битое с громким логом.
if dlqURL != "" { if dlqURL != "" {
if derr := sendToDLQ(ctx, sqsClient, dlqURL, body, log); derr == nil { if derr := sendToDLQ(ctx, sqsClient, dlqURL, *msg.Body, log); derr != nil {
pending = append(pending, pendingAck{aws.ToString(msg.ReceiptHandle), aws.ToString(msg.MessageId)})
continue
} else {
log.Error("consumer: send to DLQ failed", "err", derr, "message_id", aws.ToString(msg.MessageId)) log.Error("consumer: send to DLQ failed", "err", derr, "message_id", aws.ToString(msg.MessageId))
} }
}
recv := receiveCount(msg)
if recv >= maxReceiveCount {
log.Error("consumer: invalid envelope dropped after maxReceiveCount (DLQ недоступна)",
"message_id", aws.ToString(msg.MessageId), "receive_count", recv, "max", maxReceiveCount)
pending = append(pending, pendingAck{aws.ToString(msg.ReceiptHandle), aws.ToString(msg.MessageId)})
} else { } else {
log.Warn("consumer: invalid envelope deferred (DLQ недоступна, будет повтор)", log.Warn("consumer: invalid envelope dropped (DLQ отсутствует)", "message_id", aws.ToString(msg.MessageId))
"message_id", aws.ToString(msg.MessageId), "receive_count", recv)
} }
// Подтверждаем: DLQ-отправка (или логирование) — терминальная обработка.
pending = append(pending, pendingAck{aws.ToString(msg.ReceiptHandle), aws.ToString(msg.MessageId)})
continue continue
} }
log.Error("consumer: process telemetry", "err", err, "message_id", aws.ToString(msg.MessageId)) log.Error("consumer: process telemetry", "err", err, "message_id", aws.ToString(msg.MessageId))
// Ошибка обработки (не envelope, напр. PG). Применяем тот же лимит
// повторов, что и для битых envelope — иначе постоянная ошибка крутила
// бы сообщение бесконечно (visibility timeout → повтор → снова ошибка).
recv := receiveCount(msg)
if recv >= maxReceiveCount {
log.Error("consumer: dropped after maxReceiveCount",
"message_id", aws.ToString(msg.MessageId), "receive_count", recv, "err", err)
if dlqURL != "" {
if derr := sendToDLQ(ctx, sqsClient, dlqURL, body, log); derr == nil {
pending = append(pending, pendingAck{aws.ToString(msg.ReceiptHandle), aws.ToString(msg.MessageId)})
}
} else {
pending = append(pending, pendingAck{aws.ToString(msg.ReceiptHandle), aws.ToString(msg.MessageId)})
}
continue
}
// Сообщение НЕ подтверждаем — вернётся после visibility timeout. // Сообщение НЕ подтверждаем — вернётся после visibility timeout.
// Backoff с jitter — не долбить упавший PG в цикле // Backoff с jitter — не долбить упавший PG в цикле
// (фикс HIGH из ревью 2026-08-16: лавина ретраев). // (фикс HIGH из ревью 2026-08-16: лавина ретраев).
@@ -176,32 +117,9 @@ func Run(ctx context.Context, cfg *config.Config, sqsClient *sqs.Client, store *
log.Error("consumer: flush failed — сообщения НЕ удалены, вернутся после visibility timeout", "err", err) log.Error("consumer: flush failed — сообщения НЕ удалены, вернутся после visibility timeout", "err", err)
continue continue
} }
// DeleteMessageBatch: до 10 записей за вызов (было по одному DeleteMessage for _, p := range pending {
// на сообщение — лишние round-trip, ревью 2026-10-10). if err := deleteMessage(ctx, sqsClient, queueURL, p.handle, log); err != nil {
for i := 0; i < len(pending); i += 10 { log.Error("consumer: DeleteMessage failed", "err", err, "message_id", p.id)
end := i + 10
if end > len(pending) {
end = len(pending)
}
chunk := pending[i:end]
entries := make([]sqstypes.DeleteMessageBatchRequestEntry, 0, len(chunk))
for j := range chunk {
entries = append(entries, sqstypes.DeleteMessageBatchRequestEntry{
Id: aws.String(strconv.Itoa(j)),
ReceiptHandle: aws.String(chunk[j].handle),
})
}
out, err := sqsClient.DeleteMessageBatch(ctx, &sqs.DeleteMessageBatchInput{
QueueUrl: aws.String(queueURL),
Entries: entries,
})
if err != nil {
log.Error("consumer: DeleteMessageBatch failed", "err", err, "count", len(chunk))
continue
}
for _, f := range out.Failed {
log.Error("consumer: DeleteMessageBatch entry failed",
"id", aws.ToString(f.Id), "code", aws.ToString(f.Code), "msg", aws.ToString(f.Message))
} }
} }
} }
@@ -210,6 +128,15 @@ func Run(ctx context.Context, cfg *config.Config, sqsClient *sqs.Client, store *
return nil return nil
} }
// deleteMessage удаляет обработанное сообщение из очереди.
func deleteMessage(ctx context.Context, client *sqs.Client, queueURL string, receiptHandle string, log *slog.Logger) error {
_, err := client.DeleteMessage(ctx, &sqs.DeleteMessageInput{
QueueUrl: aws.String(queueURL),
ReceiptHandle: aws.String(receiptHandle),
})
return err
}
// sendToDLQ — кладёт битое неразборчивое сообщение в DLQ для диагностики. // sendToDLQ — кладёт битое неразборчивое сообщение в DLQ для диагностики.
func sendToDLQ(ctx context.Context, client *sqs.Client, dlqURL, body string, log *slog.Logger) error { func sendToDLQ(ctx context.Context, client *sqs.Client, dlqURL, body string, log *slog.Logger) error {
_, err := client.SendMessage(ctx, &sqs.SendMessageInput{ _, err := client.SendMessage(ctx, &sqs.SendMessageInput{
@@ -219,20 +146,6 @@ func sendToDLQ(ctx context.Context, client *sqs.Client, dlqURL, body string, log
return err return err
} }
// receiveCount — ApproximateReceiveCount сообщения (число приёмов из очереди).
// 0, если атрибут не запрошен/не задан.
func receiveCount(msg sqstypes.Message) int {
n, _ := strconv.Atoi(msg.Attributes[string(sqstypes.MessageSystemAttributeNameApproximateReceiveCount)])
return n
}
// setReady — nil-safe вызов колбэка готовности.
func setReady(fn func(bool), v bool) {
if fn != nil {
fn(v)
}
}
// sleepCtx — ожидание с прерыванием по ctx. false = ctx завершён. // sleepCtx — ожидание с прерыванием по ctx. false = ctx завершён.
func sleepCtx(ctx context.Context, d time.Duration) bool { func sleepCtx(ctx context.Context, d time.Duration) bool {
select { select {
+4 -24
View File
@@ -26,31 +26,13 @@ func processTelemetry(ctx context.Context, body string, store *iotpg.IoTPostgres
return errInvalidEnvelope return errInvalidEnvelope
} }
// Семантическая валидация envelope ДО записи в БД: пустые namespace/device_id
// или пустой payload — битое сообщение, ретраем не исправится. Пустой
// namespace → EnsureTenantDB создал бы БД «tenant_», пустой payload → NULL
// (23502) или битый JSONB. Такое шлём в DLQ (errInvalidEnvelope), а не
// бесконечно ретраим (ревью 2026-10-10, 🔴2).
if envelope.Namespace == "" || envelope.DeviceID == "" || len(envelope.Payload) == 0 {
log.Warn("consumer: invalid envelope (empty namespace/device_id/payload), routing to DLQ",
"namespace", envelope.Namespace, "device", envelope.DeviceID)
return errInvalidEnvelope
}
// EnsureTenantDB идемпотентен, кэшируется после первого вызова. // EnsureTenantDB идемпотентен, кэшируется после первого вызова.
if err := store.EnsureTenantDB(ctx, envelope.Namespace); err != nil { if err := store.EnsureTenantDB(ctx, envelope.Namespace); err != nil {
if errors.Is(err, iotpg.ErrInvalidNamespace) {
// Небезопасный namespace — постоянная ошибка → в DLQ (не ретраить).
log.Warn("consumer: invalid namespace, routing to DLQ", "namespace", envelope.Namespace)
return errInvalidEnvelope
}
return err return err
} }
// Время приёма сохраняем в ts (порядок телеметрии). RFC3339Nano — субсекундная // Время приёма сохраняем в ts (порядок телеметрии). Битый/пустой received_at
// точность (очередь non-FIFO: несколько показаний в секунду не должны терять // → zero, стор подставит now().
// порядок). Parse RFC3339Nano принимает и старые RFC3339 (доля секунды опциональна). receivedAt, perr := time.Parse(time.RFC3339, envelope.ReceivedAt)
// Битый/пустой received_at → zero, стор подставит now().
receivedAt, perr := time.Parse(time.RFC3339Nano, envelope.ReceivedAt)
if perr != nil { if perr != nil {
receivedAt = time.Time{} receivedAt = time.Time{}
} }
@@ -58,9 +40,7 @@ func processTelemetry(ctx context.Context, body string, store *iotpg.IoTPostgres
return err return err
} }
// Debug: данные ещё только буферизованы (не записаны), Info на каждое log.Info("consumer: telemetry saved",
// сообщение засорял бы лог (ревью 2026-10-10).
log.Debug("consumer: telemetry buffered",
"namespace", envelope.Namespace, "namespace", envelope.Namespace,
"device", envelope.DeviceID, "device", envelope.DeviceID,
) )
-27
View File
@@ -7,7 +7,6 @@ package sqsclient
import ( import (
"context" "context"
"fmt" "fmt"
"time"
"github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/credentials"
@@ -37,29 +36,3 @@ func ResolveQueueURL(ctx context.Context, client *sqs.Client, queueName string)
} }
return aws.ToString(out.QueueUrl), nil return aws.ToString(out.QueueUrl), nil
} }
// ResolveQueueURLWithRetry — ResolveQueueURL с ретраями на старте. Временная
// ошибка GetQueueUrl (SQS ещё поднимается, сетевой сбой) не должна фатально
// ронять bridge/consumer без ретрая (ревью 2026-10-10, 🔴5). После исчерпания
// попыток возвращает ошибку — вызывающий должен завершить процесс ≠ 0 (платформа
// перезапустит контейнер).
func ResolveQueueURLWithRetry(ctx context.Context, client *sqs.Client, queueName string) (string, error) {
const attempts = 10
const backoff = 2 * time.Second
var lastErr error
for attempt := 1; attempt <= attempts; attempt++ {
url, err := ResolveQueueURL(ctx, client, queueName)
if err == nil {
return url, nil
}
lastErr = err
if attempt < attempts {
select {
case <-ctx.Done():
return "", lastErr
case <-time.After(backoff):
}
}
}
return "", lastErr
}
+2 -10
View File
@@ -3,8 +3,6 @@ package store
import ( import (
"context" "context"
"fmt" "fmt"
"golang.org/x/crypto/bcrypt"
) )
// bridgeNamespace — служебный namespace строки бриджа в iot_devices. // bridgeNamespace — служебный namespace строки бриджа в iot_devices.
@@ -19,20 +17,14 @@ const bridgeNamespace = "__bridge"
// Нужно для PG-auth EMQX (хвост 3): EMQX зависит только от PostgreSQL, // Нужно для PG-auth EMQX (хвост 3): EMQX зависит только от PostgreSQL,
// монолит при старте гарантирует наличие строки бриджа. // монолит при старте гарантирует наличие строки бриджа.
func (s *DeviceStore) EnsureBridgeDevice(ctx context.Context, username, password string) error { func (s *DeviceStore) EnsureBridgeDevice(ctx context.Context, username, password string) error {
// Пароль бриджа храним bcrypt-хэшем (EMQX проверяет bcrypt), как и пароли _, err := s.db.ExecContext(ctx, `
// устройств.
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("store: hash bridge password: %w", err)
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO iot_devices (namespace, name, device_id, enabled, mqtt_password, metadata, phase) INSERT INTO iot_devices (namespace, name, device_id, enabled, mqtt_password, metadata, phase)
VALUES ($1, $2, $3, true, $4, '{}'::jsonb, 'Active') VALUES ($1, $2, $3, true, $4, '{}'::jsonb, 'Active')
ON CONFLICT (namespace, name) DO UPDATE ON CONFLICT (namespace, name) DO UPDATE
SET device_id = EXCLUDED.device_id, SET device_id = EXCLUDED.device_id,
enabled = true, enabled = true,
mqtt_password = EXCLUDED.mqtt_password`, mqtt_password = EXCLUDED.mqtt_password`,
bridgeNamespace, "iot-bridge", username, string(hash)) bridgeNamespace, "iot-bridge", username, password)
if err != nil { if err != nil {
return fmt.Errorf("store: ensure bridge device: %w", err) return fmt.Errorf("store: ensure bridge device: %w", err)
} }
-4
View File
@@ -15,10 +15,6 @@ var ErrNotFound = errors.New("device not found")
// ErrAlreadyExists — конфликт уникальности (namespace, name) или (namespace, device_id). // ErrAlreadyExists — конфликт уникальности (namespace, name) или (namespace, device_id).
var ErrAlreadyExists = errors.New("device already exists") var ErrAlreadyExists = errors.New("device already exists")
// ErrDeviceIDExists — конфликт именно по (namespace, device_id) (отличается от
// конфликта по name для понятной ошибки API/Terraform, ревью 2026-10-10).
var ErrDeviceIDExists = errors.New("device_id already exists")
// Device — устройство IoT из таблицы iot_devices. // Device — устройство IoT из таблицы iot_devices.
type Device struct { type Device struct {
Namespace string Namespace string
+3 -35
View File
@@ -6,9 +6,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings"
"github.com/lib/pq"
) )
// Create вставляет новое устройство. Конфликт (namespace, name) или // Create вставляет новое устройство. Конфликт (namespace, name) или
@@ -18,24 +15,13 @@ func (s *DeviceStore) Create(ctx context.Context, d *Device) error {
if err != nil { if err != nil {
return fmt.Errorf("store: marshal metadata: %w", err) return fmt.Errorf("store: marshal metadata: %w", err)
} }
// phase пишем ЯВНО через phaseOf(enabled): колонка имеет DEFAULT 'Active',
// поэтому без явной записи устройство с enabled=false лежало бы в БД как
// 'Active', а POST-ответ считал бы 'Disabled' → GET отдал бы другое значение
// (ревью 2026-10-10, 🟡 store). Согласовано с UpdateEnabled (тот же phaseOf).
_, err = s.db.ExecContext(ctx, ` _, err = s.db.ExecContext(ctx, `
INSERT INTO iot_devices (namespace, name, device_id, enabled, mqtt_password, metadata, phase) INSERT INTO iot_devices (namespace, name, device_id, enabled, mqtt_password, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7)`, VALUES ($1, $2, $3, $4, $5, $6)`,
d.Namespace, d.Name, d.DeviceID, d.Enabled, d.MQTTPassword, metadata, phaseOf(d.Enabled), d.Namespace, d.Name, d.DeviceID, d.Enabled, d.MQTTPassword, metadata,
) )
if err != nil { if err != nil {
if isUniqueViolation(err) { if isUniqueViolation(err) {
// Различаем конфликт name и device_id по имени constraint (ревью
// 2026-10-10): PK — iot_devices_pkey (namespace,name), уникальный
// индекс — idx_iot_devices_ns_device (namespace,device_id).
var pqErr *pq.Error
if errors.As(err, &pqErr) && strings.Contains(pqErr.Constraint, "ns_device") {
return ErrDeviceIDExists
}
return ErrAlreadyExists return ErrAlreadyExists
} }
return fmt.Errorf("store: insert device: %w", err) return fmt.Errorf("store: insert device: %w", err)
@@ -107,24 +93,6 @@ func (s *DeviceStore) UpdateEnabled(ctx context.Context, namespace, name string,
return nil return nil
} }
// UpdateMetadata обновляет metadata устройства.
func (s *DeviceStore) UpdateMetadata(ctx context.Context, namespace, name string, metadata map[string]string) error {
b, err := json.Marshal(metadata)
if err != nil {
return fmt.Errorf("store: marshal metadata: %w", err)
}
res, err := s.db.ExecContext(ctx,
`UPDATE iot_devices SET metadata = $3 WHERE namespace = $1 AND name = $2`,
namespace, name, b)
if err != nil {
return fmt.Errorf("store: update metadata: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// Delete удаляет устройство. // Delete удаляет устройство.
func (s *DeviceStore) Delete(ctx context.Context, namespace, name string) error { func (s *DeviceStore) Delete(ctx context.Context, namespace, name string) error {
res, err := s.db.ExecContext(ctx, res, err := s.db.ExecContext(ctx,
-91
View File
@@ -1,91 +0,0 @@
// dsn_test.go — тесты replaceDSNDatabase и safeNamespaceRe.
//
// Критичны для изоляции тенантов: неверная замена БД = телеметрия всех тенантов
// уходит в одну БД; небезопасный namespace = коллизия имени БД/username.
package iotpg
import (
"strings"
"testing"
)
func TestReplaceDSNDatabase(t *testing.T) {
cases := []struct {
name string
dsn string
newDB string
want string
wantErr bool
}{
{
name: "обычный URL-DSN",
dsn: "postgresql://u:p@h:5432/iot_platform?sslmode=require",
newDB: "tenant_abc",
want: "postgresql://u:p@h:5432/tenant_abc?sslmode=require",
},
{
name: "sslrootcert со слэшем в query не ломает замену",
dsn: "postgresql://u:p@h:5432/iot_platform?sslmode=require&sslrootcert=/etc/ssl/ca.pem",
newDB: "tenant_abc",
want: "postgresql://u:p@h:5432/tenant_abc?sslmode=require&sslrootcert=/etc/ssl/ca.pem",
},
{
name: "DSN без query",
dsn: "postgresql://u:p@h:5432/iot_platform",
newDB: "tenant_x",
want: "postgresql://u:p@h:5432/tenant_x",
},
{
name: "key=value DSN — ошибка (иначе смешение тенантов)",
dsn: "host=h user=u dbname=x sslmode=disable",
wantErr: true,
},
{
name: "URL без имени БД — ошибка",
dsn: "postgresql://u:p@h:5432",
wantErr: true,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := replaceDSNDatabase(c.dsn, c.newDB)
if c.wantErr {
if err == nil {
t.Fatalf("ожидалась ошибка, получено %q", got)
}
return
}
if err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
if got != c.want {
t.Errorf("got %q, want %q", got, c.want)
}
})
}
}
func TestSafeNamespaceRe(t *testing.T) {
cases := []struct {
ns string
want bool
}{
{"myhome", true},
{"sless-abc123", true},
{"a", true},
{"tenant", true},
{"a_b", false}, // _
{"a+b", false}, // +
{"a#b", false}, // #
{"a/b", false}, // /
{"", false}, // пусто
{"-x", false}, // ведущий дефис
{strings.Repeat("a", 56), true},
{strings.Repeat("a", 57), false},
}
for _, c := range cases {
if got := safeNamespaceRe.MatchString(c.ns); got != c.want {
t.Errorf("safeNamespaceRe(%q) = %v, want %v", c.ns, got, c.want)
}
}
}
+26 -113
View File
@@ -24,7 +24,6 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
"regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -44,18 +43,9 @@ type IoTPostgresStore struct {
// батчинг вставок (фикс MEDIUM ревью 2026-08-16: 1000 msg/s = 1000 INSERT) // батчинг вставок (фикс MEDIUM ревью 2026-08-16: 1000 msg/s = 1000 INSERT)
mu sync.Mutex mu sync.Mutex
// flushMu сериализует фактическую запись (grab буфера + INSERT) между
// фоновым flusher'ом, Flush() из consumer и синхронной записью полного
// батча в InsertTelemetry. Без него Flush видел бы пустой буфер и возвращал
// nil, пока INSERT ещё в полёте → подтверждение SQS до записи (потеря).
flushMu sync.Mutex
batches map[string][]telemetryInsert batches map[string][]telemetryInsert
stopFlush chan struct{} stopFlush chan struct{}
flushWG sync.WaitGroup flushWG sync.WaitGroup
// closeOnce — Close идемпотентен: повторный вызов не паникует на close(stopFlush)
// (ревью 2026-10-10).
closeOnce sync.Once
} }
// telemetryInsert — строка для батч-вставки. // telemetryInsert — строка для батч-вставки.
@@ -72,14 +62,6 @@ const telemetryBatchSize = 100
// telemetryFlushInterval — период фонового flush неполных батчей. // telemetryFlushInterval — период фонового flush неполных батчей.
const telemetryFlushInterval = 200 * time.Millisecond const telemetryFlushInterval = 200 * time.Millisecond
// ErrInvalidNamespace — namespace небезопасен для имени БД tenant_<ns>/username.
var ErrInvalidNamespace = errors.New("invalid namespace")
// safeNamespaceRe — безопасный namespace: [a-zA-Z0-9-], не с дефиса, ≤56 символов
// (имя БД tenant_<ns> ≤ 63 байт PG). Совпадает с валидацией API (identRe).
// Defense-in-depth: основной барьер — валидация при создании устройства.
var safeNamespaceRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]{0,55}$`)
// TelemetryRow — одна запись телеметрии из таблицы iot_telemetry. // TelemetryRow — одна запись телеметрии из таблицы iot_telemetry.
type TelemetryRow struct { type TelemetryRow struct {
ID int64 `json:"id"` ID int64 `json:"id"`
@@ -143,12 +125,6 @@ func (s *IoTPostgresStore) startFlusher() {
// в буфер и будут повторены. Иначе фоновый flusher мог бы молча потерять // в буфер и будут повторены. Иначе фоновый flusher мог бы молча потерять
// телеметрию, а consumer, увидев пустой буфер в Flush, подтвердил бы SQS-сообщение. // телеметрию, а consumer, увидев пустой буфер в Flush, подтвердил бы SQS-сообщение.
func (s *IoTPostgresStore) flushAll(ctx context.Context) error { func (s *IoTPostgresStore) flushAll(ctx context.Context) error {
// flushMu: см. поле. Сериализует запись целиком, чтобы Flush() из consumer
// дождался in-flight INSERT фонового flusher'а и вернул РЕАЛЬНУЮ ошибку
// (или nil только если всё записано), а не «буфер пуст, пока идёт запись».
s.flushMu.Lock()
defer s.flushMu.Unlock()
s.mu.Lock() s.mu.Lock()
if len(s.batches) == 0 { if len(s.batches) == 0 {
s.mu.Unlock() s.mu.Unlock()
@@ -162,21 +138,19 @@ func (s *IoTPostgresStore) flushAll(ctx context.Context) error {
for ns, rows := range batches { for ns, rows := range batches {
if err := s.flushTenant(ctx, ns, rows); err != nil { if err := s.flushTenant(ctx, ns, rows); err != nil {
s.log.Error("iotpg: flush batch", "namespace", ns, "rows", len(rows), "err", err) s.log.Error("iotpg: flush batch", "namespace", ns, "rows", len(rows), "err", err)
if isRetryableFlushErr(err) {
if firstErr == nil { if firstErr == nil {
firstErr = err firstErr = err
} }
if isRetryableFlushErr(err) {
// Сеть/PG временно недоступен — возвращаем строки в буфер, повторим. // Сеть/PG временно недоступен — возвращаем строки в буфер, повторим.
s.mu.Lock() s.mu.Lock()
s.batches[ns] = append(s.batches[ns], rows...) s.batches[ns] = append(s.batches[ns], rows...)
s.mu.Unlock() s.mu.Unlock()
} else { } else {
// Постоянная ошибка: валидные строки уже вставлены построчным // Постоянная ошибка (неверные данные/constraint) — повтор бессмыслен:
// fallback'ом (см. flushTenant), битые отброшены с логом. Ошибку // ВЫБРАСЫВАЕМ батч, иначе он зависнет в буфере навсегда и заблокирует
// НЕ возвращаем и строки в буфер НЕ возвращаем — данные обработаны, // весь namespace (вечные 200мс-ретраи). Логируем подробно.
// иначе consumer увидел бы ошибку и бесконечно гонял poison-батч s.log.Error("iotpg: permanent flush error, batch dropped", "namespace", ns, "rows", len(rows), "err", err)
// через visibility timeout (ревью 🔴).
s.log.Error("iotpg: permanent flush error (валидные строки — построчно, битые отброшены)", "namespace", ns, "rows", len(rows), "err", err)
} }
} }
} }
@@ -224,40 +198,9 @@ func (s *IoTPostgresStore) flushTenant(ctx context.Context, ns string, rows []te
} }
sb.WriteString(" ON CONFLICT DO NOTHING") sb.WriteString(" ON CONFLICT DO NOTHING")
_, err = tenantDB.ExecContext(ctx, sb.String(), args...) _, err = tenantDB.ExecContext(ctx, sb.String(), args...)
if err != nil && !isRetryableFlushErr(err) {
// Построчный fallback: одна «ядовитая» строка (NULL/пустой payload,
// \u0000 в JSONB, битый JSON) роняет весь multi-VALUES батч (до
// telemetryBatchSize валидных строк). Вставляем по одной — валидные
// сохраняются, битая логируется, namespace не блокируется (ревью 🔴2).
s.flushRowsIndividually(ctx, tenantDB, ns, rows)
}
return err return err
} }
// flushRowsIndividually — построчная вставка при ПОСТОЯННОЙ ошибке батча.
// Вызывается только из flushTenant, когда ошибка НЕ retryable (т.е. это данные,
// а не сеть/PG). Каждая строка вставляется отдельным INSERT ... ON CONFLICT DO
// NOTHING: валидные сохраняются, битая — логируется и пропускается (не роняет
// соседние и не блокирует namespace вечными ретраями).
func (s *IoTPostgresStore) flushRowsIndividually(ctx context.Context, tenantDB *sql.DB, ns string, rows []telemetryInsert) {
for _, r := range rows {
var mid any
if r.messageID != "" {
mid = r.messageID
}
ts := r.ts
if ts.IsZero() {
ts = time.Now().UTC()
}
_, err := tenantDB.ExecContext(ctx,
`INSERT INTO iot_telemetry (device_id, payload, message_id, ts) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`,
r.deviceID, r.payload, mid, ts)
if err != nil {
s.log.Error("iotpg: poison row dropped", "namespace", ns, "device", r.deviceID, "err", err)
}
}
}
// NewFromEnv создаёт store из env var IOT_PG_DSN. // NewFromEnv создаёт store из env var IOT_PG_DSN.
// Возвращает (nil, nil) если переменная не задана — IoT Postgres опционален. // Возвращает (nil, nil) если переменная не задана — IoT Postgres опционален.
func NewFromEnv(log *slog.Logger) (*IoTPostgresStore, error) { func NewFromEnv(log *slog.Logger) (*IoTPostgresStore, error) {
@@ -284,10 +227,6 @@ created_at TIMESTAMPTZ DEFAULT now()
// EnsureTenantDB создаёт DATABASE, USER и таблицу iot_telemetry для namespace. // EnsureTenantDB создаёт DATABASE, USER и таблицу iot_telemetry для namespace.
// Идемпотентен — повторный вызов безопасен (кэш ensured пропускает проверки). // Идемпотентен — повторный вызов безопасен (кэш ensured пропускает проверки).
func (s *IoTPostgresStore) EnsureTenantDB(ctx context.Context, namespace string) error { func (s *IoTPostgresStore) EnsureTenantDB(ctx context.Context, namespace string) error {
// Guard (defense-in-depth): небезопасный namespace → постоянная ошибка.
if !safeNamespaceRe.MatchString(namespace) {
return fmt.Errorf("%w: %q", ErrInvalidNamespace, namespace)
}
if _, ok := s.ensured.Load(namespace); ok { if _, ok := s.ensured.Load(namespace); ok {
return nil return nil
} }
@@ -403,22 +342,15 @@ func (s *IoTPostgresStore) InsertTelemetry(ctx context.Context, namespace, devic
s.mu.Unlock() s.mu.Unlock()
if full { if full {
// Синхронная запись полного батча — под тем же flushMu, что и фоновый if err := s.flushTenant(ctx, namespace, rows); err != nil {
// flusher и Flush: чтобы не писать параллельно (см. поле flushMu).
s.flushMu.Lock()
err := s.flushTenant(ctx, namespace, rows)
if err != nil {
if isRetryableFlushErr(err) { if isRetryableFlushErr(err) {
// Не теряем строки: возвращаем в буфер (сеть/PG временно лежит). // Не теряем строки: возвращаем в буфер (сеть/PG временно лежит).
s.mu.Lock() s.mu.Lock()
s.batches[namespace] = append(s.batches[namespace], rows...) s.batches[namespace] = append(s.batches[namespace], rows...)
s.mu.Unlock() s.mu.Unlock()
} else { } else {
s.log.Error("iotpg: permanent error (валидные строки — построчно, битые отброшены)", "namespace", namespace, "rows", len(rows), "err", err) s.log.Error("iotpg: permanent error, batch dropped", "namespace", namespace, "rows", len(rows), "err", err)
} }
}
s.flushMu.Unlock()
if err != nil {
return err return err
} }
} }
@@ -475,13 +407,13 @@ func (s *IoTPostgresStore) QueryTelemetry(ctx context.Context, namespace, device
if deviceID != "" { if deviceID != "" {
rows, err = tenantDB.QueryContext(ctx, rows, err = tenantDB.QueryContext(ctx,
`SELECT id, device_id, ts, payload FROM iot_telemetry `SELECT id, device_id, ts, payload FROM iot_telemetry
WHERE device_id = $1 ORDER BY ts DESC, id DESC LIMIT $2 OFFSET $3`, WHERE device_id = $1 ORDER BY ts DESC LIMIT $2 OFFSET $3`,
deviceID, limit, offset, deviceID, limit, offset,
) )
} else { } else {
rows, err = tenantDB.QueryContext(ctx, rows, err = tenantDB.QueryContext(ctx,
`SELECT id, device_id, ts, payload FROM iot_telemetry `SELECT id, device_id, ts, payload FROM iot_telemetry
ORDER BY ts DESC, id DESC LIMIT $1 OFFSET $2`, ORDER BY ts DESC LIMIT $1 OFFSET $2`,
limit, offset, limit, offset,
) )
} }
@@ -608,10 +540,7 @@ func isDBNotExistErr(err error) bool {
} }
// Close останавливает flusher, дописывает остаток и закрывает все подключения. // Close останавливает flusher, дописывает остаток и закрывает все подключения.
// Идемпотентен (closeOnce): повторный вызов не паникует на close(stopFlush).
func (s *IoTPostgresStore) Close() error { func (s *IoTPostgresStore) Close() error {
var err error
s.closeOnce.Do(func() {
close(s.stopFlush) close(s.stopFlush)
s.flushWG.Wait() s.flushWG.Wait()
_ = s.flushAll(context.Background()) _ = s.flushAll(context.Background())
@@ -621,22 +550,13 @@ func (s *IoTPostgresStore) Close() error {
} }
return true return true
}) })
err = s.adminDB.Close() return s.adminDB.Close()
})
return err
} }
// getTenantDB возвращает *sql.DB для tenant DB из кэша или открывает новый. // getTenantDB возвращает *sql.DB для tenant DB из кэша или открывает новый.
// Открытие дедуплицируется per-namespace мьютексом (гонка Load→LoadOrStore // Открытие дедуплицируется per-namespace мьютексом (гонка Load→LoadOrStore
// из ревью 2026-08-16). // из ревью 2026-08-16).
func (s *IoTPostgresStore) getTenantDB(ctx context.Context, namespace string) (*sql.DB, error) { func (s *IoTPostgresStore) getTenantDB(ctx context.Context, namespace string) (*sql.DB, error) {
// Валидация namespace на ВСЕХ путях (чтение/запись) — defense-in-depth.
// Основной барьер — валидация при создании устройства, но JWT с sub,
// прошедшим namespace==sub в middleware, не должен читать чужую БД через
// подмену дефисов (a_b ↔ a-b дают одно имя tenant_a_b) — см. tenantDBName.
if !safeNamespaceRe.MatchString(namespace) {
return nil, fmt.Errorf("%w: %q", ErrInvalidNamespace, namespace)
}
if cached, ok := s.tenants.Load(namespace); ok { if cached, ok := s.tenants.Load(namespace); ok {
return cached.(*sql.DB), nil return cached.(*sql.DB), nil
} }
@@ -647,10 +567,7 @@ func (s *IoTPostgresStore) getTenantDB(ctx context.Context, namespace string) (*
if cached, ok := s.tenants.Load(namespace); ok { if cached, ok := s.tenants.Load(namespace); ok {
return cached.(*sql.DB), nil return cached.(*sql.DB), nil
} }
dsn, err := replaceDSNDatabase(s.adminDSN, tenantDBName(namespace)) dsn := replaceDSNDatabase(s.adminDSN, tenantDBName(namespace))
if err != nil {
return nil, err
}
db, err := sql.Open("postgres", dsn) db, err := sql.Open("postgres", dsn)
if err != nil { if err != nil {
return nil, fmt.Errorf("iotpg: open tenant DB %s: %w", tenantDBName(namespace), err) return nil, fmt.Errorf("iotpg: open tenant DB %s: %w", tenantDBName(namespace), err)
@@ -673,30 +590,26 @@ func (s *IoTPostgresStore) getTenantDB(ctx context.Context, namespace string) (*
return db, nil return db, nil
} }
// replaceDSNDatabase заменяет имя базы данных в URL-DSN. // replaceDSNDatabase заменяет имя базы данных в DSN.
// Вход: "postgresql://user:pass@host:5432/iot_platform?sslmode=require" // Вход: "postgresql://user:pass@host:5432/iot_platform?sslmode=disable"
// Выход: "postgresql://user:pass@host:5432/tenant_abc?sslmode=require" // Выход: "postgresql://user:pass@host:5432/tenant_abc?sslmode=disable"
// func replaceDSNDatabase(dsn, newDBName string) string {
// Возвращает ОШИБКУ для не-URL (key=value) DSN: иначе функция молча вернула бы
// исходный DSN, и телеметрия ВСЕХ тенантов ушла бы в management-БД — смешение
// тенантов (ревью 2026-10-10). Query отделяется ПЕРВЫМ '?', поэтому '/' внутри
// query (напр. sslrootcert=/path) не ломает поиск имени БД.
func replaceDSNDatabase(dsn, newDBName string) (string, error) {
schemeEnd := strings.Index(dsn, "://") schemeEnd := strings.Index(dsn, "://")
if schemeEnd < 0 { if schemeEnd < 0 {
return "", fmt.Errorf("iotpg: replaceDSNDatabase: неподдерживаемый DSN (нет «://»); нужен URL вида postgresql://user:pass@host:port/db") return dsn
} }
rest := dsn[schemeEnd+3:] hostPart := dsn[schemeEnd+3:]
query := "" slashIdx := strings.LastIndex(hostPart, "/")
if q := strings.Index(rest, "?"); q >= 0 { if slashIdx < 0 {
query = rest[q:] return dsn
rest = rest[:q]
} }
slash := strings.LastIndex(rest, "/") afterSlash := hostPart[slashIdx+1:]
if slash < 0 { suffix := ""
return "", fmt.Errorf("iotpg: replaceDSNDatabase: в DSN нет имени базы данных") if qIdx := strings.Index(afterSlash, "?"); qIdx >= 0 {
suffix = afterSlash[qIdx:]
} }
return dsn[:schemeEnd+3] + rest[:slash+1] + newDBName + query, nil prefix := dsn[:schemeEnd+3+slashIdx+1]
return prefix + newDBName + suffix
} }
// tenantDBName возвращает имя Postgres DATABASE для namespace. // tenantDBName возвращает имя Postgres DATABASE для namespace.