Author SHA1 Message Date
Repinoid 32b6cd3bf2 chore: update ignore rules 2026-10-27 14:15:14 +03:00
Repinoid 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/)
/iot-operator
/mqtt-bridge
/kafka-consumer
/sqs-consumer
bin/
ws-probe/ws-probe
/iot-service
@@ -9,7 +13,6 @@ ws-probe/ws-probe
*.out
*.prof
*.cover
*.key
# Python — кэш и генерируемые файлы
__pycache__/
+2 -2
View File
@@ -1,5 +1,5 @@
# Dockerfile — монолит iot-service (один бинарник, один контейнер).
# Образ: gitea.services.ngcloud.ru/nail/iot-service (Gitea registry платформы).
# Образ: naeel/iot-service (Docker Hub, публичный — требование платформы).
#
# Env-дефолты (не секреты) вшиты сюда — deck-UI задаёт только секреты.
@@ -24,7 +24,7 @@ USER 65532:65532
EXPOSE 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_REGION=us-east-1 \
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/Dockerfile.old` — собирал 3 старых бинарника
- `legacy/Makefile.old` — старые k8s-цели
- ⛔ `cmd/iot-operator/`, `controllers/`, `api/v1alpha1/` — оператор (CRD-контроллер):
**УДАЛЁН 2026-10-10** — не импортировался активным монолитом, не собирался.
- ⛔ `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).
- `cmd/iot-operator/`, `controllers/`, `api/v1alpha1/` — оператор (CRD-контроллер);
пока в корне из-за импортов (перенос — во время Фазы 1 после переписывания хендлеров)
## Аудит вмешательства старого IoT (2026-08-16, проверено)
@@ -49,8 +43,8 @@
- `HISTORY/2026-08-16-session-log.md`
- `doc/sqs-integration.md`
- `ws-probe/`
- `cmd/iot-service/`, `internal/service/`, `internal/storage/iotpg/` — активный
монолит iot-service
- `cmd/mqtt-bridge/`, `internal/` — Go-код, переиспользуется в новом монолите
(после адаптации под платформу)
- ⛔ `cmd/sqs-consumer/` — **УДАЛЁН 2026-10-07**: standalone-консьюмер, который
НЕ собирался Dockerfile/Makefile (те билдят только `./cmd/iot-service/`); его
логика поглощена `internal/service/consumer/`. Был источником пустого
+2 -4
View File
@@ -1,10 +1,8 @@
# Makefile — монолит iot-service (образ gitea.services.ngcloud.ru/nail/iot-service).
# Registry: Gitea (gitea.services.ngcloud.ru) — managed-платформа тянет образы отсюда.
# Makefile — монолит iot-service (образ naeel/iot-service).
# Старые k8s-цели — в legacy/Makefile.old.
REGISTRY ?= gitea.services.ngcloud.ru/nail
VERSION ?= v0.1.13
IMAGE ?= $(REGISTRY)/iot-service
IMAGE ?= naeel/iot-service
LDFLAGS = -X main.version=$(VERSION)
.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/signal"
"sync"
"sync/atomic"
"syscall"
"time"
@@ -30,37 +29,14 @@ import (
"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 при сборке.
var version = "dev"
// main — тонкая обёртка: os.Exit обходит defer-ы, поэтому вся логика в run(),
// которая возвращает код выхода и выполняет свои defer-ы (devices/iotStore
// Close, финальный flush) ДО выхода (ревью 🔴5: «os.Exit обходит defer-ы»).
func main() {
os.Exit(run())
}
func run() int {
cfg, err := config.Load()
if err != nil {
slog.Error("config", "err", err)
return 1
os.Exit(1)
}
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.LogLevel}))
@@ -77,7 +53,7 @@ func run() int {
storeCancel()
if err != nil {
log.Error("devices store", "err", err)
return 1
os.Exit(1)
}
defer devices.Close()
log.Info("devices store ready")
@@ -88,14 +64,14 @@ func run() int {
ensureCancel()
if err != nil {
log.Error("ensure bridge device", "err", err)
return 1
os.Exit(1)
}
log.Info("bridge device ensured", "username", cfg.MQTTUsername)
iotStore, err := iotpg.New(cfg.IOTPGDSN, log)
if err != nil {
log.Error("iotpg store", "err", err)
return 1
os.Exit(1)
}
defer iotStore.Close()
log.Info("telemetry store ready")
@@ -120,50 +96,27 @@ func run() int {
Addr: ":" + cfg.APIPort,
Handler: router,
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 — в фоне.
var wg sync.WaitGroup
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() {
defer wg.Done()
// ready.SetBridge управляется самим bridge: true только после успешной
// подписки (находка #3). Иначе «соединён, но не подписан» давал /ready=200
// и молча терял телеметрию.
ready.SetBridge(true)
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)
fatal.Store(true)
cancel() // штатный shutdown, затем выход ≠ 0
}
}()
go func() {
defer wg.Done()
// ready.SetConsumer управляется consumer'ом: true после успешного
// ReceiveMessage, false при ошибке SQS (находка «readiness»).
ready.SetConsumer(true)
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)
fatal.Store(true)
cancel()
}
}()
@@ -173,36 +126,18 @@ func run() int {
// (фикс MEDIUM из ревью 2026-08-16).
go func() {
<-ctx.Done()
// Ждём остановки подсистем, но не бесконечно (зависшую не ждём).
if !waitWithTimeout(&wg, shutdownGrace) {
log.Warn("iot-service: подсистемы не завершились за отведённое время", "grace", shutdownGrace)
}
wg.Wait()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
_ = server.Shutdown(shutdownCtx)
close(shutdownDone)
}()
log.Info("iot-service listening", "addr", server.Addr)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Error("http server", "err", err)
cancel() // останавливаем bridge/consumer
waitWithTimeout(&wg, shutdownGrace)
<-shutdownDone
return 1
os.Exit(1)
}
// Ждём завершения Shutdown (дренаж in-flight HTTP) до deferred Close.
<-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
}
wg.Wait()
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 (чужой кластер)
**Версии на 2026-08-16:** `gitea.services.ngcloud.ru/nail/iot-emqx:v0.2.4`, `gitea.services.ngcloud.ru/nail/iot-service:v0.1.2`.
Образы — в **Gitea container registry** (`gitea.services.ngcloud.ru`), не на Docker Hub
(managed-платформа тянет образы из Gitea; логин — `docker login gitea.services.ngcloud.ru`).
Каждый образ имеет РОВНО один EXPOSE
**Версии на 2026-08-16:** `naeel/iot-emqx:v0.2.4`, `naeel/iot-service:v0.1.2`.
Образы публичные на Docker Hub, каждый имеет РОВНО один EXPOSE
(EMQX — 8083, iot-service — 9090). Поле порта в deck отсутствует — порт
берётся из EXPOSE образа.
@@ -119,9 +117,8 @@ curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/jso
https://<iot-домен>/v1/namespaces/<ns>/iot/devices
```
Ответ содержит `mqtt_username` = `<ns>_<device_id>` и `mqtt_password` — пароль
отдаётся ТОЛЬКО здесь, один раз (в БД хранится bcrypt-хэш, см. P0-3). GET пароль
НЕ возвращает — сохраните его сразу.
Ответ содержит `mqtt_username` = `<ns>_<device_id>` и `mqtt_password`.
Повторный просмотр пароля: `GET /v1/namespaces/<ns>/iot/devices/<name>`.
Устройство подключается:
- 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.
# Образ: gitea.services.ngcloud.ru/nail/iot-emqx (Gitea registry платформы).
# Образ: naeel/iot-emqx (Docker Hub, публичный).
#
# ⚠️ ВАЖНО: платформа Nubes требует РОВНО ОДИН EXPOSE у образа (иначе create падает
# с «Не указан applicationUsePort, а expose в Dockerfile содержит несколько портов»).
# База emqx/emqx:5.5.1 имеет 7 EXPOSE (18083/1883/4370/5369/8083/8084/8883), а EXPOSE
# не удаляется из наследуемого образа. Поэтому копируем rootfs базового образа в чистый
# (EXPOSE НЕ переносится) и выставляем единственный порт 8083.
#
# БАЗА — официальный emqx/emqx:5.5.1. Добавляем только наш emqx.conf (auth/ACL, listeners, логи).
# БАЗА — официальный emqx/emqx:5.5.1 (проверен на платформе: заводится, логи в
# stdout). Добавляем только наш emqx.conf (auth/ACL, listeners, логи).
# ⚠️ У образа 7 EXPOSE-портов (наследие базы) — deck-UI при СОЗДАНИИ требует
# один порт; в НАШЕМ кластере используется kubectl set image, вопрос одного
# EXPOSE для чужого кластера — отдельная задача (тикет Nubes).
FROM emqx/emqx:5.5.1 AS base
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
FROM emqx/emqx:5.5.1
COPY emqx.conf /opt/emqx/etc/emqx.conf
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).
# Registry: Gitea (gitea.services.ngcloud.ru) — managed-платформа тянет образы отсюда.
# EMQX-контейнер для IoT (образ naeel/iot-emqx).
REGISTRY ?= gitea.services.ngcloud.ru/nail
VERSION ?= v0.2.4
IMAGE ?= $(REGISTRY)/iot-emqx
IMAGE ?= naeel/iot-emqx
.PHONY: docker-build docker-push
+2 -6
View File
@@ -40,12 +40,8 @@ authentication = [
verify = verify_none
}
password_hash_algorithm {
name = bcrypt
## ⚠ salt_rounds НЕ поддерживается схемой 5.5.1 (проверено на образе
## emqx/emqx:5.5.1: «unknown_fields, unknown => "salt_rounds"» и
## call_hocon_failed). Оставлять его нельзя — контейнер не стартует.
## Роль поля нулевая: EMQX только СВЕРЯЕТ хеш из БД, а cost берётся из
## самого bcrypt-хеша, не из конфига.
name = plain
salt_position = disable
}
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/gorilla/mux v1.8.1
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 (
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/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/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/oauth2 v0.0.0-20220223155221-ee480838109b // 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/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
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/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/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/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/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/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
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/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/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
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/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/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 (
"context"
"crypto/subtle"
"net/http"
"strings"
"time"
@@ -20,11 +19,8 @@ func (h *Handler) AdminStats(w http.ResponseWriter, r *http.Request) {
return
}
// Constant-time сравнение токена: обычное != давало утечку по времени (ревью 2026-10-10).
authHeader := r.Header.Get("Authorization")
token := strings.TrimPrefix(authHeader, "Bearer ")
if !strings.HasPrefix(authHeader, "Bearer ") ||
subtle.ConstantTimeCompare([]byte(token), []byte(h.Cfg.AdminStatsToken)) != 1 {
if !strings.HasPrefix(authHeader, "Bearer ") || strings.TrimPrefix(authHeader, "Bearer ") != h.Cfg.AdminStatsToken {
writeJSON(w, http.StatusUnauthorized, errResp("unauthorized"))
return
}
+62 -185
View File
@@ -2,9 +2,9 @@
//
// 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/{name} — одно (без пароля)
// GET /v1/namespaces/{ns}/iot/devices/{name} — одно (с паролем)
// DELETE /v1/namespaces/{ns}/iot/devices/{name} — удалить
// PATCH /v1/namespaces/{ns}/iot/devices/{name} — enabled
package handler
@@ -12,14 +12,12 @@ package handler
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"regexp"
"strconv"
"time"
"golang.org/x/crypto/bcrypt"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/store"
)
@@ -31,67 +29,48 @@ type iotDeviceCreateRequest struct {
Metadata map[string]string `json:"metadata,omitempty"`
}
// iotDeviceUpdateRequest — тело PATCH. Хотя бы одно поле обязательно:
// enabled и/или metadata (metadata раньше нельзя было обновить — ревью 2026-10-10).
// iotDeviceUpdateRequest — тело PATCH.
type iotDeviceUpdateRequest struct {
Enabled *bool `json:"enabled,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
Enabled *bool `json:"enabled"`
}
// iotDeviceResponse — ответ API. MQTTPassword заполняется ТОЛЬКО при создании
// устройства (отдаётся один раз) и НИКОГДА при чтении — чтобы пароль не утекал
// через GET и не хранился в логах/истории.
type iotDeviceResponse struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
DeviceID string `json:"device_id"`
Enabled bool `json:"enabled"`
Phase string `json:"phase"`
MQTTUsername string `json:"mqtt_username,omitempty"`
MQTTPassword string `json:"mqtt_password,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"`
// Metadata — без omitempty: всегда присутствует в ответе. Пустой → «{}»,
// иначе Terraform видел бы drift «{}» против null (ревью 2026-10-10).
Metadata map[string]string `json:"metadata"`
CreatedAt string `json:"created_at,omitempty"`
Name string `json:"name"`
Namespace string `json:"namespace"`
DeviceID string `json:"device_id"`
Enabled bool `json:"enabled"`
Phase string `json:"phase"`
MQTTUsername string `json:"mqtt_username,omitempty"`
MQTTPassword string `json:"mqtt_password,omitempty"`
TopicPrefix string `json:"topic_prefix,omitempty"`
LastConnected string `json:"last_connected,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
}
// deviceToResponse конвертирует устройство из БД в ответ API.
// password отдаётся только если непустая — только на создании, при чтении
// передаётся пустая строка (пароль повторно не показывается).
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{
Name: d.Name,
Namespace: d.Namespace,
DeviceID: d.DeviceID,
Enabled: d.Enabled,
Phase: d.Phase,
MQTTUsername: d.Namespace + "_" + d.DeviceID,
MQTTPassword: password,
TopicPrefix: d.Namespace + "/",
TelemetryTopic: d.Namespace + "/telemetry/" + d.DeviceID,
Metadata: metadata,
Name: d.Name,
Namespace: d.Namespace,
DeviceID: d.DeviceID,
Enabled: d.Enabled,
Phase: d.Phase,
MQTTUsername: d.Namespace + "_" + d.DeviceID,
MQTTPassword: password,
TopicPrefix: d.Namespace + "/",
Metadata: d.Metadata,
}
if d.LastConnected != nil && !d.LastConnected.IsZero() {
resp.LastConnected = d.LastConnected.UTC().Format(time.RFC3339)
}
if !d.CreatedAt.IsZero() {
// RFC3339 — как last_connected: единый формат для Terraform (drift между
// POST/GET из-за формата «2006-01-02 15:04:05 UTC» устранён).
resp.CreatedAt = d.CreatedAt.UTC().Format(time.RFC3339)
resp.CreatedAt = d.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC")
}
return resp
}
@@ -112,68 +91,24 @@ func generateMQTTPassword() (string, error) {
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.
func (h *Handler) CreateIoTDevice(w http.ResponseWriter, r *http.Request) {
// Порядок важен: сначала разбор тела (400 «invalid JSON»), потом валидация
// идентификаторов — так и было до вынесения общей проверки.
ns := namespaceFromPath(r)
ns := pathVar(r, "namespace")
if ns == "" {
ns = "default"
}
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()))
return
}
// Валидация идентификаторов до записи в БД: единый charset и лимиты для
// namespace (из URL), name и device_id. Критично для MQTT (username/топик)
// и PG (имя БД tenant_<ns>): плохие символы ломают auth/ACL или дают чужой
// доступ (см. identRe). Ошибка 400 с понятным текстом — для Terraform.
if msg := validateIdentifier(ns, "namespace", 56); msg != "" {
writeJSON(w, http.StatusBadRequest, errResp(msg))
if req.Name == "" {
writeJSON(w, http.StatusBadRequest, errResp("name is required"))
return
}
if msg := validateIdentifier(req.Name, "name", 64); msg != "" {
writeJSON(w, http.StatusBadRequest, errResp(msg))
return
}
if msg := validateIdentifier(req.DeviceID, "device_id", 64); msg != "" {
writeJSON(w, http.StatusBadRequest, errResp(msg))
if req.DeviceID == "" {
writeJSON(w, http.StatusBadRequest, errResp("device_id is required"))
return
}
@@ -189,15 +124,6 @@ func (h *Handler) CreateIoTDevice(w http.ResponseWriter, r *http.Request) {
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"
if !enabled {
phase = "Disabled"
@@ -208,16 +134,12 @@ func (h *Handler) CreateIoTDevice(w http.ResponseWriter, r *http.Request) {
Name: req.Name,
DeviceID: req.DeviceID,
Enabled: enabled,
MQTTPassword: hash,
MQTTPassword: password,
Metadata: req.Metadata,
Phase: phase,
}
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) {
writeJSON(w, http.StatusConflict, errResp("iot device already exists"))
return
@@ -227,51 +149,19 @@ func (h *Handler) CreateIoTDevice(w http.ResponseWriter, r *http.Request) {
return
}
// Перечитываем устройство из БД: после INSERT нужны БД-поля created_at
// (и 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))
writeJSON(w, http.StatusCreated, deviceToResponse(device, ""))
}
// 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) {
ns, ok := requireNamespace(w, r)
if !ok {
return
ns := pathVar(r, "namespace")
if ns == "" {
ns = "default"
}
// limit: дефолт 100, потолок 1000 — иначе ошибка парсинга/омиссия = безлимит
// (ревью 2026-10-10).
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
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
devices, err := h.Devices.List(r.Context(), ns, limit, offset)
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} (с паролем).
func (h *Handler) GetIoTDevice(w http.ResponseWriter, r *http.Request) {
ns, ok := requireNamespace(w, r)
if !ok {
return
ns := pathVar(r, "namespace")
if ns == "" {
ns = "default"
}
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}.
func (h *Handler) DeleteIoTDevice(w http.ResponseWriter, r *http.Request) {
ns, ok := requireNamespace(w, r)
if !ok {
return
ns := pathVar(r, "namespace")
if ns == "" {
ns = "default"
}
name := pathVar(r, "name")
@@ -333,44 +223,31 @@ func (h *Handler) DeleteIoTDevice(w http.ResponseWriter, r *http.Request) {
// UpdateIoTDevice — PATCH /v1/namespaces/{ns}/iot/devices/{name} (enabled).
func (h *Handler) UpdateIoTDevice(w http.ResponseWriter, r *http.Request) {
ns, ok := requireNamespace(w, r)
if !ok {
return
ns := pathVar(r, "namespace")
if ns == "" {
ns = "default"
}
name := pathVar(r, "name")
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()))
return
}
if req.Enabled == nil && req.Metadata == nil {
writeJSON(w, http.StatusBadRequest, errResp("enabled или metadata обязательны"))
if req.Enabled == nil {
writeJSON(w, http.StatusBadRequest, errResp("enabled field is required"))
return
}
// Обновляем только переданные поля (enabled и/или metadata).
if req.Enabled != nil {
if err := h.Devices.UpdateEnabled(r.Context(), ns, name, *req.Enabled); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeJSON(w, http.StatusNotFound, errResp("iot device not found"))
return
}
h.Log.Error("update device enabled", "namespace", ns, "name", name, "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to update device"))
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"))
err := h.Devices.UpdateEnabled(r.Context(), ns, name, *req.Enabled)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeJSON(w, http.StatusNotFound, errResp("iot device not found"))
return
}
h.Log.Error("update device", "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)
@@ -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)
}
// 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.
func errResp(msg string) map[string]string {
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 {
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
}
ns, ok := requireNamespace(w, r)
if !ok {
return
}
ns := pathVar(r, "namespace")
deviceID := r.URL.Query().Get("device_id")
limit := 50
if ls := r.URL.Query().Get("limit"); ls != "" {
+10 -27
View File
@@ -19,27 +19,23 @@ import (
// - false — проверка JWT: sub + exp; если задан hmacSecret — обязательна
// HS256-подпись (HMAC-SHA256, env JWT_HMAC_SECRET). Без секрета —
// структурная проверка (периметр обеспечивает платформа).
//
// Тест-режим игнорируется при заданном hmacSecret (иначе обходил бы подпись).
func Auth(authTestMode bool, hmacSecret string, 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)
authErr(w, http.StatusUnauthorized, "authorization required")
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)
authErr(w, http.StatusUnauthorized, "invalid authorization format, use Bearer <token>")
http.Error(w, `{"error":"invalid authorization format, use Bearer <token>"}`, http.StatusUnauthorized)
return
}
token := parts[1]
// Тест-режим только без секрета: при заданном JWT_HMAC_SECRET он обходил
// бы проверку подписи (ревью 2026-10-10).
if authTestMode && hmacSecret == "" && isPlainToken(token) {
if authTestMode && isPlainToken(token) {
log.Info("auth: test mode — plain token accepted", "remote", r.RemoteAddr, "path", r.URL.Path)
next.ServeHTTP(w, r)
return
@@ -49,16 +45,14 @@ func Auth(authTestMode bool, hmacSecret string, log *slog.Logger, next http.Hand
// токен можно подделать — отказываем в обслуживании, а не проверяем «структурно».
if !authTestMode && hmacSecret == "" {
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
}
sub, err := validateJWT(token, hmacSecret)
if err != nil {
// 401, а не 403: невалидный/просроченный токен — не аутентифицирован
// (клиент/Terraform должны отличать «протух» от «чужой namespace»).
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
}
@@ -66,7 +60,7 @@ func Auth(authTestMode bool, hmacSecret string, log *slog.Logger, next http.Hand
// Иначе любой валидный токен читал/писал бы любой namespace (горизонтальный доступ).
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)
authErr(w, http.StatusForbidden, "forbidden")
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
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 (три части через точку).
func isPlainToken(token string) bool {
if token == "" || strings.ContainsAny(token, " \t\n\r") {
@@ -151,13 +137,10 @@ func validateJWT(token, secret string) (string, error) {
if claims.Sub == "" {
return "", &jwtError{"JWT missing sub claim"}
}
// exp ОБЯЗАТЕЛЕН: без него токен вечный. Раньше проверка шла только при
// 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 {
return "", &jwtError{"JWT expired"}
if claims.Exp > 0 {
if now := float64(timeNow().Unix()); claims.Exp < now {
return "", &jwtError{"JWT expired"}
}
}
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()
rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rw, r)
// Пробы здоровья логируем на Debug: платформа опрашивает их часто, а
// Info-строка на каждую засоряет лог (ревью 2026-10-10).
level := slog.LevelInfo
switch r.URL.Path {
case "/health", "/healthz", "/ready":
level = slog.LevelDebug
}
log.Log(r.Context(), level, "http",
log.Info("http",
"method", r.Method,
"path", r.URL.Path,
"status", rw.status,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+17 -46
View File
@@ -19,26 +19,12 @@ import (
// telemetryTopicFilter — все namespace, все устройства.
const telemetryTopicFilter = "+/telemetry/+"
// subscribeRetryInterval — пауза между попытками подписки при сбое.
// Подписка обязательна (иначе bridge «глохнет»: соединение есть, а топик не слушается).
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 {
// Run — бесконечный цикл bridge: подключение → подписка → ожидание сигнала завершения.
func Run(ctx context.Context, cfg *config.Config, sqsClient *sqs.Client, log *slog.Logger) error {
log.Info("bridge: starting", "broker", cfg.MQTTBrokerURL, "client_id", cfg.MQTTClientID)
// URL очереди получаем один раз при старте (как старый consumer).
queueURL, err := sqsclient.ResolveQueueURLWithRetry(ctx, sqsClient, cfg.SQSQueueName)
queueURL, err := sqsclient.ResolveQueueURL(ctx, sqsClient, cfg.SQSQueueName)
if err != nil {
return err
}
@@ -52,11 +38,11 @@ func Run(ctx context.Context, cfg *config.Config, sqsClient *sqs.Client, log *sl
// Отправка в SQS — через диспатчер (канал + worker-пул): колбэк MQTT
// не блокируется сетью (фикс CRITICAL из ревью 2026-08-16).
dispatcher := newSQSDispatcher(sqsClient, queueURL, log)
dispatcher.start()
dispatcher.start(ctx)
handler := newMessageHandler(dispatcher, log)
client, err := connectMQTT(ctx, cfg, log, handler, onSubscribed)
client, err := connectMQTT(ctx, cfg, log, handler)
if err != nil {
dispatcher.closeAndWait()
return err
@@ -80,7 +66,7 @@ func (e errBridge) Error() string { return string(e) }
// connectMQTT устанавливает подключение к EMQX с автореконнектом.
// 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.AddBroker(cfg.MQTTBrokerURL)
opts.SetClientID(cfg.MQTTClientID)
@@ -89,44 +75,29 @@ func connectMQTT(ctx context.Context, cfg *config.Config, log *slog.Logger, hand
opts.SetAutoReconnect(true)
opts.SetConnectRetry(true)
opts.SetConnectRetryInterval(5 * time.Second)
// MaxReconnectInterval по умолчанию ~10 минут — слишком долго для потоковой
// телеметрии; 30с (ревью 2026-10-10).
opts.SetMaxReconnectInterval(30 * time.Second)
opts.SetKeepAlive(30 * time.Second)
opts.SetCleanSession(false)
opts.SetConnectionLostHandler(func(_ mqtt.Client, err error) {
log.Warn("bridge: MQTT connection lost, reconnecting...", "err", err)
setSubscribed(onSubscribed, false)
})
opts.SetReconnectingHandler(func(_ mqtt.Client, _ *mqtt.ClientOptions) {
log.Info("bridge: MQTT reconnecting...")
})
opts.SetOnConnectHandler(func(c mqtt.Client) {
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)
if token.WaitTimeout(10*time.Second) && token.Error() == nil {
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):
}
token := c.Subscribe(telemetryTopicFilter, 1, handler)
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)
})
client := mqtt.NewClient(opts)
+3 -25
View File
@@ -5,15 +5,13 @@
package bridge
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"log/slog"
"regexp"
"strings"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
"github.com/google/uuid"
)
// TelemetryEnvelope — сообщение, перекладываемое в SQS (разбирается consumer).
@@ -44,13 +42,6 @@ func newMessageHandler(dispatcher *sqsDispatcher, log *slog.Logger) mqtt.Message
ns := parts[0]
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 оборачиваем в строку.
rawPayload := json.RawMessage(payload)
if !json.Valid(payload) {
@@ -63,21 +54,8 @@ func newMessageHandler(dispatcher *sqsDispatcher, log *slog.Logger) mqtt.Message
DeviceID: deviceID,
Topic: topic,
Payload: rawPayload,
ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano),
MessageID: messageIDFor(ns, deviceID, topic, payload),
ReceivedAt: time.Now().UTC().Format(time.RFC3339),
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[:])
}
+23 -94
View File
@@ -12,7 +12,6 @@ package bridge
import (
"context"
"encoding/json"
"errors"
"log/slog"
"sync"
"sync/atomic"
@@ -20,7 +19,6 @@ import (
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/sqs"
"github.com/aws/smithy-go"
)
// Константы диспатчера.
@@ -29,18 +27,8 @@ const (
dispatcherQueueSize = 10000
// dispatcherWorkers — горутин, отправляющих в SQS.
dispatcherWorkers = 4
// sendAttemptTimeout — таймаут ОДНОЙ попытки SendMessage. Без него зависшее
// соединение (SQS висит, но не отвечает) блокировало бы воркера навсегда и
// забило бы весь пул (ревью 🔴4).
sendAttemptTimeout = 10 * time.Second
// sendMaxBackoff — потолок паузы между попытками (экспоненциальный backoff).
sendMaxBackoff = 8 * time.Second
// bridgeFlushTimeout — сколько ждать слива остатка буфера в SQS при shutdown.
// Если SQS лежит и ретраи не завершаются, по этому таймауту ctx отменяется,
// воркеры обрывают отправку (остаток логируется как дропнутый).
bridgeFlushTimeout = 10 * time.Second
// sendMaxAttempts — попыток SendMessage на одно сообщение.
sendMaxAttempts = 3
// maxSQSBodyBytes — потолок размера envelope для SQS.
// SQS (и shared-sqs) режут сообщения >256KB — запас на оверхед.
@@ -55,54 +43,31 @@ type sqsDispatcher struct {
log *slog.Logger
wg sync.WaitGroup
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-пул.
func newSQSDispatcher(client *sqs.Client, queueURL string, log *slog.Logger) *sqsDispatcher {
// ctx независимый от сигнала bridge: см. поле ctx. Отменяется только в
// closeAndWait (по таймауту), чтобы при shutdown воркеры успели слить буфер.
ctx, cancel := context.WithCancel(context.Background())
return &sqsDispatcher{
ch: make(chan TelemetryEnvelope, dispatcherQueueSize),
client: client,
queueURL: queueURL,
log: log,
ctx: ctx,
cancel: cancel,
}
}
// start запускает worker-пул. Воркеры используют d.ctx (независимый от сигнала).
func (d *sqsDispatcher) start() {
// start запускает worker-пул.
func (d *sqsDispatcher) start(ctx context.Context) {
for i := 0; i < dispatcherWorkers; i++ {
d.wg.Add(1)
go func(wid int) {
defer d.wg.Done()
d.worker(wid)
d.worker(ctx, wid)
}(i)
}
}
// enqueue кладёт envelope в канал НЕблокирующе. false = буфер полон (дроп) или
// канал уже закрыт (shutdown). Под mu, чтобы не писать в закрытый ch.
// enqueue кладёт envelope в канал НЕблокирующе. false = буфер полон (дроп).
func (d *sqsDispatcher) enqueue(e TelemetryEnvelope) bool {
d.mu.Lock()
defer d.mu.Unlock()
if d.closed {
return false
}
select {
case d.ch <- e:
return true
@@ -117,25 +82,15 @@ func (d *sqsDispatcher) enqueue(e TelemetryEnvelope) bool {
}
}
// closeAndWait закрывает канал и ждёт, пока воркеры сольют остаток буфера в SQS.
// Закрытие — под mu (см. поле mu): после него поздний колбэк paho не упадёт на
// «send on closed channel». Флаш ограничен bridgeFlushTimeout: если SQS лежит,
// отменяем d.ctx, чтобы sendWithRetry оборвался и wg.Wait() вернулся.
// closeAndWait закрывает канал и ждёт завершения worker'ов (flush).
func (d *sqsDispatcher) closeAndWait() {
d.mu.Lock()
d.closed = true
close(d.ch)
d.mu.Unlock()
t := time.AfterFunc(bridgeFlushTimeout, d.cancel)
d.wg.Wait()
t.Stop()
d.log.Info("bridge: SQS dispatcher stopped", "dropped_total", d.dropped.Load())
}
// worker — цикл отправки из канала с ретраями. Использует d.ctx (не сигнальный),
// чтобы при shutdown остаток буфера успел сойти в SQS.
func (d *sqsDispatcher) worker(wid int) {
// worker — цикл отправки из канала с ограниченными ретраями.
func (d *sqsDispatcher) worker(ctx context.Context, wid int) {
for e := range d.ch {
body, err := json.Marshal(e)
if err != nil {
@@ -153,7 +108,7 @@ func (d *sqsDispatcher) worker(wid int) {
"limit_bytes", maxSQSBodyBytes)
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",
"worker", wid, "namespace", e.Namespace,
"device", e.DeviceID, "err", err)
@@ -161,56 +116,30 @@ func (d *sqsDispatcher) worker(wid int) {
}
}
// sendWithRetry — SendMessage с ретраями до остановки (ctx) и таймаутом на
// попытку. НЕ дропаем после фиксированного числа попыток (было 3 ≈ 7с): буфер
// на dispatcherQueueSize рассчитан на поглощение временной недоступности SQS,
// поэтому ретраим до ctx.Done() (shutdown) или успеха — иначе потеря была бы
// безвозвратной (ревью 🔴4).
// sendWithRetry — SendMessage с backoff 1с/2с/4с.
func (d *sqsDispatcher) sendWithRetry(ctx context.Context, body string) error {
var lastErr error
backoff := time.Second
for {
attemptCtx, cancel := context.WithTimeout(ctx, sendAttemptTimeout)
_, err := d.client.SendMessage(attemptCtx, &sqs.SendMessageInput{
for attempt := 1; attempt <= sendMaxAttempts; attempt++ {
_, err := d.client.SendMessage(ctx, &sqs.SendMessageInput{
QueueUrl: aws.String(d.queueURL),
MessageBody: aws.String(body),
})
cancel()
if err == nil {
return nil
}
// Permanent ошибки (неверный параметр/размер/нет очереди/доступ) — ретрай
// бессмыслен: вернуть сразу, иначе воркер залип бы на poison-сообщении и
// заполнил буфер (дроп остальных).
if isPermanentSQSErr(err) {
return err
}
lastErr = err
if ctx.Err() != nil {
// shutdown: ctx отменён, ретраить бессмысленно.
return err
return lastErr
}
select {
case <-ctx.Done():
return err
case <-time.After(backoff):
}
if backoff < sendMaxBackoff {
if attempt < sendMaxAttempts {
select {
case <-ctx.Done():
return lastErr
case <-time.After(backoff):
}
backoff *= 2
}
}
}
// 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
return lastErr
}
-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")
}
}
+2 -9
View File
@@ -123,17 +123,13 @@ func Load() (*Config, error) {
LogLevel: parseLogLevel(os.Getenv("LOG_LEVEL"), slog.LevelInfo),
}
// MQTT broker URL: либо полный MQTT_BROKER_URL, либо из HOST/PORT/WS_PATH.
// MQTT_HOST обязателен: EMQX в другом namespace платформы, дефолта нет
// (см. DefaultMQTTHost).
// MQTT broker URL: либо полный MQTT_BROKER_URL, либо из HOST/PORT/WS_PATH
cfg.MQTTBrokerURL = os.Getenv("MQTT_BROKER_URL")
if cfg.MQTTBrokerURL == "" {
host := getEnv("MQTT_HOST", DefaultMQTTHost)
port := getEnv("MQTT_PORT", DefaultMQTTPort)
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://") &&
!strings.HasPrefix(cfg.MQTTBrokerURL, "wss://") {
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 == "" {
missing = append(missing, "IOT_PG_DSN")
}
if cfg.MQTTBrokerURL == "" {
missing = append(missing, "MQTT_HOST (или MQTT_BROKER_URL)")
}
if cfg.SQSAccessKey == "" {
missing = append(missing, "SQS_ACCESS_KEY")
}
+6 -8
View File
@@ -6,9 +6,9 @@ const (
// DefaultAPIPort — единственный порт контейнера: API + /health + /metrics.
DefaultAPIPort = "9090"
// DefaultSQSEndpoint — публичный endpoint shared-sqs (ВМ 213), внешний доступ
// обязателен: shared-sqs живёт НЕ в кластере Nubes. Переопределяется SQS_ENDPOINT.
DefaultSQSEndpoint = "https://sqs.sinefo.ru"
// DefaultSQSEndpoint — внутренний адрес shared-sqs в том же кластере Nubes.
// Обходит внешний шлюз платформы (таймауты 31-33с, MSS 1448, ~150с).
DefaultSQSEndpoint = "http://containerk8s.f1ffb134-7d16-45bd-8bef-69f6ec8ab33c.svc.cluster.local:4100"
// DefaultSQSQueueName — очередь телеметрии IoT.
DefaultSQSQueueName = "iot-telemetry"
@@ -21,11 +21,9 @@ const (
// DefaultSQSRegion — регион для self-hosted SQS (не важен, оставлен для SDK).
DefaultSQSRegion = "us-east-1"
// DefaultMQTTHost — пусто: вменяемого дефолта нет, EMQX живёт в ДРУГОМ
// namespace платформы и доступен только по FQDN
// containerk8s.<instanceUid EMQX>.svc.cluster.local. Поэтому MQTT_HOST обязателен
// (задаётся Terraform'ом из state EMQX), иначе config.Load вернёт ошибку.
DefaultMQTTHost = ""
// DefaultMQTTHost — внутреннее имя EMQX-контейнера на платформе.
// Реальный UUID namespace EMQX задаётся в deck-UI (MQTT_HOST) при создании.
DefaultMQTTHost = "emqx"
// DefaultMQTTPort — WebSocket-листенер EMQX (wss внутри платформы).
DefaultMQTTPort = "8083"
+30 -117
View File
@@ -8,12 +8,10 @@ import (
"context"
"errors"
"log/slog"
"strconv"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"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/sqsclient"
@@ -26,37 +24,18 @@ const errorBackoff = 5 * time.Second
// maxProcessBackoff — потолок backoff после ошибок обработки (PG).
const maxProcessBackoff = 60 * time.Second
// maxReceiveCount — после стольких приёмов «ядовитое» сообщение дропается, если
// DLQ недоступна: иначе оно крутилось бы на основной очереди бесконечно
// (находка #2 — без чтения ApproximateReceiveCount). При доступной DLQ битое
// уходит туда на первом же приёме.
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)
// Run — бесконечный long-poll цикл потребителя.
func Run(ctx context.Context, cfg *config.Config, sqsClient *sqs.Client, store *iotpg.IoTPostgresStore, log *slog.Logger) error {
queueURL, err := sqsclient.ResolveQueueURL(ctx, sqsClient, cfg.SQSQueueName)
if err != nil {
return err
}
// DLQ резолвим с ретраями: временная ошибка ResolveQueueURL НЕ должна навсегда
// отключить DLQ — иначе битые сообщения подтверждались бы и терялись без следа
// (ревью 2026-10-10, 🔴7). При окончательном провале dlqURL остаётся "" —
// битые сообщения тогда НЕ подтверждаются (см. обработку errInvalidEnvelope).
dlqURL := ""
for attempt := 1; attempt <= 3; attempt++ {
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)
// DLQ присутствует не всегда: резолвим лениво, отказ не фатален (битые
// сообщения тогда просто НЕ подтверждаются и оседают после ретраев).
dlqURL, dlqErr := sqsclient.ResolveQueueURL(ctx, sqsClient, cfg.SQSDLQName)
if dlqErr != nil {
log.Warn("consumer: DLQ не найдена, битые сообщения будут только логироваться", "dlq", cfg.SQSDLQName, "err", dlqErr)
dlqURL = ""
}
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,
WaitTimeSeconds: int32(cfg.SQSLongPollSeconds),
VisibilityTimeout: int32(cfg.SQSVisibilityTimeout),
// Просим ApproximateReceiveCount — по нему ограничиваем повторы
// «ядовитого» сообщения (см. maxReceiveCount, находка #2).
MessageSystemAttributeNames: []sqstypes.MessageSystemAttributeName{
sqstypes.MessageSystemAttributeNameApproximateReceiveCount,
},
})
if err != nil {
if ctx.Err() != nil {
break
}
log.Error("consumer: ReceiveMessage failed", "err", err)
setReady(onReady, false)
if !sleepCtx(ctx, errorBackoff) {
break
}
continue
}
setReady(onReady, true)
processBackoff := time.Second
// Копим подтверждения, но НЕ удаляем из SQS сразу: InsertTelemetry лишь
@@ -100,58 +72,27 @@ func Run(ctx context.Context, cfg *config.Config, sqsClient *sqs.Client, store *
}
var pending []pendingAck
for _, msg := range resp.Messages {
// nil/пустое тело не подтверждаем молча: прогоняем через processTelemetry,
// который вернёт errInvalidEnvelope → DLQ (ревью 2026-10-10: пустое тело
// подтверждалось без DLQ).
body := ""
if msg.Body != nil {
body = *msg.Body
if msg.Body == nil || *msg.Body == "" {
log.Warn("consumer: empty message body", "message_id", aws.ToString(msg.MessageId))
pending = append(pending, pendingAck{aws.ToString(msg.ReceiptHandle), aws.ToString(msg.MessageId)})
continue
}
if err := processTelemetry(ctx, body, store, log); err != nil {
if err := processTelemetry(ctx, *msg.Body, store, log); err != nil {
// Битый envelope → DLQ (а не бесконечный ретрай и не потеря):
// кладём тело в DLQ, затем подтверждаем основное сообщение.
if errors.Is(err, errInvalidEnvelope) {
// Битый envelope → DLQ. Подтверждаем только если DLQ реально
// приняла сообщение (иначе битый теряется — 🔴7). Если DLQ
// недоступна/сбой — ограничиваем число повторов, чтобы не
// зациклиться навсегда (находка #2): после maxReceiveCount
// дропаем битое с громким логом.
if dlqURL != "" {
if derr := sendToDLQ(ctx, sqsClient, dlqURL, body, log); derr == nil {
pending = append(pending, pendingAck{aws.ToString(msg.ReceiptHandle), aws.ToString(msg.MessageId)})
continue
} else {
if derr := sendToDLQ(ctx, sqsClient, dlqURL, *msg.Body, log); derr != nil {
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 {
log.Warn("consumer: invalid envelope deferred (DLQ недоступна, будет повтор)",
"message_id", aws.ToString(msg.MessageId), "receive_count", recv)
log.Warn("consumer: invalid envelope dropped (DLQ отсутствует)", "message_id", aws.ToString(msg.MessageId))
}
// Подтверждаем: DLQ-отправка (или логирование) — терминальная обработка.
pending = append(pending, pendingAck{aws.ToString(msg.ReceiptHandle), aws.ToString(msg.MessageId)})
continue
}
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.
// Backoff с jitter — не долбить упавший PG в цикле
// (фикс 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)
continue
}
// DeleteMessageBatch: до 10 записей за вызов (было по одному DeleteMessage
// на сообщение — лишние round-trip, ревью 2026-10-10).
for i := 0; i < len(pending); i += 10 {
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))
for _, p := range pending {
if err := deleteMessage(ctx, sqsClient, queueURL, p.handle, log); err != nil {
log.Error("consumer: DeleteMessage failed", "err", err, "message_id", p.id)
}
}
}
@@ -210,6 +128,15 @@ func Run(ctx context.Context, cfg *config.Config, sqsClient *sqs.Client, store *
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 для диагностики.
func sendToDLQ(ctx context.Context, client *sqs.Client, dlqURL, body string, log *slog.Logger) error {
_, err := client.SendMessage(ctx, &sqs.SendMessageInput{
@@ -219,20 +146,6 @@ func sendToDLQ(ctx context.Context, client *sqs.Client, dlqURL, body string, log
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 завершён.
func sleepCtx(ctx context.Context, d time.Duration) bool {
select {
+4 -24
View File
@@ -26,31 +26,13 @@ func processTelemetry(ctx context.Context, body string, store *iotpg.IoTPostgres
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 идемпотентен, кэшируется после первого вызова.
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
}
// Время приёма сохраняем в ts (порядок телеметрии). RFC3339Nano — субсекундная
// точность (очередь non-FIFO: несколько показаний в секунду не должны терять
// порядок). Parse RFC3339Nano принимает и старые RFC3339 (доля секунды опциональна).
// Битый/пустой received_at → zero, стор подставит now().
receivedAt, perr := time.Parse(time.RFC3339Nano, envelope.ReceivedAt)
// Время приёма сохраняем в ts (порядок телеметрии). Битый/пустой received_at
// → zero, стор подставит now().
receivedAt, perr := time.Parse(time.RFC3339, envelope.ReceivedAt)
if perr != nil {
receivedAt = time.Time{}
}
@@ -58,9 +40,7 @@ func processTelemetry(ctx context.Context, body string, store *iotpg.IoTPostgres
return err
}
// Debug: данные ещё только буферизованы (не записаны), Info на каждое
// сообщение засорял бы лог (ревью 2026-10-10).
log.Debug("consumer: telemetry buffered",
log.Info("consumer: telemetry saved",
"namespace", envelope.Namespace,
"device", envelope.DeviceID,
)
-27
View File
@@ -7,7 +7,6 @@ package sqsclient
import (
"context"
"fmt"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"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
}
// 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 (
"context"
"fmt"
"golang.org/x/crypto/bcrypt"
)
// bridgeNamespace — служебный namespace строки бриджа в iot_devices.
@@ -19,20 +17,14 @@ const bridgeNamespace = "__bridge"
// Нужно для PG-auth EMQX (хвост 3): EMQX зависит только от PostgreSQL,
// монолит при старте гарантирует наличие строки бриджа.
func (s *DeviceStore) EnsureBridgeDevice(ctx context.Context, username, password string) error {
// Пароль бриджа храним bcrypt-хэшем (EMQX проверяет bcrypt), как и пароли
// устройств.
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, `
_, err := s.db.ExecContext(ctx, `
INSERT INTO iot_devices (namespace, name, device_id, enabled, mqtt_password, metadata, phase)
VALUES ($1, $2, $3, true, $4, '{}'::jsonb, 'Active')
ON CONFLICT (namespace, name) DO UPDATE
SET device_id = EXCLUDED.device_id,
enabled = true,
mqtt_password = EXCLUDED.mqtt_password`,
bridgeNamespace, "iot-bridge", username, string(hash))
bridgeNamespace, "iot-bridge", username, password)
if err != nil {
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).
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.
type Device struct {
Namespace string
+3 -35
View File
@@ -6,9 +6,6 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/lib/pq"
)
// Create вставляет новое устройство. Конфликт (namespace, name) или
@@ -18,24 +15,13 @@ func (s *DeviceStore) Create(ctx context.Context, d *Device) error {
if err != nil {
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, `
INSERT INTO iot_devices (namespace, name, device_id, enabled, mqtt_password, metadata, phase)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
d.Namespace, d.Name, d.DeviceID, d.Enabled, d.MQTTPassword, metadata, phaseOf(d.Enabled),
INSERT INTO iot_devices (namespace, name, device_id, enabled, mqtt_password, metadata)
VALUES ($1, $2, $3, $4, $5, $6)`,
d.Namespace, d.Name, d.DeviceID, d.Enabled, d.MQTTPassword, metadata,
)
if err != nil {
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 fmt.Errorf("store: insert device: %w", err)
@@ -107,24 +93,6 @@ func (s *DeviceStore) UpdateEnabled(ctx context.Context, namespace, name string,
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 удаляет устройство.
func (s *DeviceStore) Delete(ctx context.Context, namespace, name string) error {
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)
}
}
}
+37 -124
View File
@@ -24,7 +24,6 @@ import (
"fmt"
"log/slog"
"os"
"regexp"
"strings"
"sync"
"time"
@@ -43,19 +42,10 @@ type IoTPostgresStore struct {
log *slog.Logger
// батчинг вставок (фикс MEDIUM ревью 2026-08-16: 1000 msg/s = 1000 INSERT)
mu sync.Mutex
// flushMu сериализует фактическую запись (grab буфера + INSERT) между
// фоновым flusher'ом, Flush() из consumer и синхронной записью полного
// батча в InsertTelemetry. Без него Flush видел бы пустой буфер и возвращал
// nil, пока INSERT ещё в полёте → подтверждение SQS до записи (потеря).
flushMu sync.Mutex
mu sync.Mutex
batches map[string][]telemetryInsert
stopFlush chan struct{}
flushWG sync.WaitGroup
// closeOnce — Close идемпотентен: повторный вызов не паникует на close(stopFlush)
// (ревью 2026-10-10).
closeOnce sync.Once
}
// telemetryInsert — строка для батч-вставки.
@@ -72,14 +62,6 @@ const telemetryBatchSize = 100
// telemetryFlushInterval — период фонового flush неполных батчей.
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.
type TelemetryRow struct {
ID int64 `json:"id"`
@@ -143,12 +125,6 @@ func (s *IoTPostgresStore) startFlusher() {
// в буфер и будут повторены. Иначе фоновый flusher мог бы молча потерять
// телеметрию, а consumer, увидев пустой буфер в Flush, подтвердил бы SQS-сообщение.
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()
if len(s.batches) == 0 {
s.mu.Unlock()
@@ -162,21 +138,19 @@ func (s *IoTPostgresStore) flushAll(ctx context.Context) error {
for ns, rows := range batches {
if err := s.flushTenant(ctx, ns, rows); err != nil {
s.log.Error("iotpg: flush batch", "namespace", ns, "rows", len(rows), "err", err)
if firstErr == nil {
firstErr = err
}
if isRetryableFlushErr(err) {
if firstErr == nil {
firstErr = err
}
// Сеть/PG временно недоступен — возвращаем строки в буфер, повторим.
s.mu.Lock()
s.batches[ns] = append(s.batches[ns], rows...)
s.mu.Unlock()
} else {
// Постоянная ошибка: валидные строки уже вставлены построчным
// fallback'ом (см. flushTenant), битые отброшены с логом. Ошибку
// НЕ возвращаем и строки в буфер НЕ возвращаем — данные обработаны,
// иначе consumer увидел бы ошибку и бесконечно гонял poison-батч
// через visibility timeout (ревью 🔴).
s.log.Error("iotpg: permanent flush error (валидные строки — построчно, битые отброшены)", "namespace", ns, "rows", len(rows), "err", err)
// Постоянная ошибка (неверные данные/constraint) — повтор бессмыслен:
// ВЫБРАСЫВАЕМ батч, иначе он зависнет в буфере навсегда и заблокирует
// весь namespace (вечные 200мс-ретраи). Логируем подробно.
s.log.Error("iotpg: permanent flush error, batch dropped", "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")
_, 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
}
// 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.
// Возвращает (nil, nil) если переменная не задана — IoT Postgres опционален.
func NewFromEnv(log *slog.Logger) (*IoTPostgresStore, error) {
@@ -284,10 +227,6 @@ created_at TIMESTAMPTZ DEFAULT now()
// EnsureTenantDB создаёт DATABASE, USER и таблицу iot_telemetry для namespace.
// Идемпотентен — повторный вызов безопасен (кэш ensured пропускает проверки).
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 {
return nil
}
@@ -403,22 +342,15 @@ func (s *IoTPostgresStore) InsertTelemetry(ctx context.Context, namespace, devic
s.mu.Unlock()
if full {
// Синхронная запись полного батча — под тем же flushMu, что и фоновый
// flusher и Flush: чтобы не писать параллельно (см. поле flushMu).
s.flushMu.Lock()
err := s.flushTenant(ctx, namespace, rows)
if err != nil {
if err := s.flushTenant(ctx, namespace, rows); err != nil {
if isRetryableFlushErr(err) {
// Не теряем строки: возвращаем в буфер (сеть/PG временно лежит).
s.mu.Lock()
s.batches[namespace] = append(s.batches[namespace], rows...)
s.mu.Unlock()
} 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
}
}
@@ -475,13 +407,13 @@ func (s *IoTPostgresStore) QueryTelemetry(ctx context.Context, namespace, device
if deviceID != "" {
rows, err = tenantDB.QueryContext(ctx,
`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,
)
} else {
rows, err = tenantDB.QueryContext(ctx,
`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,
)
}
@@ -608,35 +540,23 @@ func isDBNotExistErr(err error) bool {
}
// Close останавливает flusher, дописывает остаток и закрывает все подключения.
// Идемпотентен (closeOnce): повторный вызов не паникует на close(stopFlush).
func (s *IoTPostgresStore) Close() error {
var err error
s.closeOnce.Do(func() {
close(s.stopFlush)
s.flushWG.Wait()
_ = s.flushAll(context.Background())
s.tenants.Range(func(_, value any) bool {
if db, ok := value.(*sql.DB); ok {
db.Close()
}
return true
})
err = s.adminDB.Close()
close(s.stopFlush)
s.flushWG.Wait()
_ = s.flushAll(context.Background())
s.tenants.Range(func(_, value any) bool {
if db, ok := value.(*sql.DB); ok {
db.Close()
}
return true
})
return err
return s.adminDB.Close()
}
// getTenantDB возвращает *sql.DB для tenant DB из кэша или открывает новый.
// Открытие дедуплицируется per-namespace мьютексом (гонка Load→LoadOrStore
// из ревью 2026-08-16).
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 {
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 {
return cached.(*sql.DB), nil
}
dsn, err := replaceDSNDatabase(s.adminDSN, tenantDBName(namespace))
if err != nil {
return nil, err
}
dsn := replaceDSNDatabase(s.adminDSN, tenantDBName(namespace))
db, err := sql.Open("postgres", dsn)
if err != nil {
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
}
// replaceDSNDatabase заменяет имя базы данных в URL-DSN.
// Вход: "postgresql://user:pass@host:5432/iot_platform?sslmode=require"
// Выход: "postgresql://user:pass@host:5432/tenant_abc?sslmode=require"
//
// Возвращает ОШИБКУ для не-URL (key=value) DSN: иначе функция молча вернула бы
// исходный DSN, и телеметрия ВСЕХ тенантов ушла бы в management-БД — смешение
// тенантов (ревью 2026-10-10). Query отделяется ПЕРВЫМ '?', поэтому '/' внутри
// query (напр. sslrootcert=/path) не ломает поиск имени БД.
func replaceDSNDatabase(dsn, newDBName string) (string, error) {
// replaceDSNDatabase заменяет имя базы данных в DSN.
// Вход: "postgresql://user:pass@host:5432/iot_platform?sslmode=disable"
// Выход: "postgresql://user:pass@host:5432/tenant_abc?sslmode=disable"
func replaceDSNDatabase(dsn, newDBName string) string {
schemeEnd := strings.Index(dsn, "://")
if schemeEnd < 0 {
return "", fmt.Errorf("iotpg: replaceDSNDatabase: неподдерживаемый DSN (нет «://»); нужен URL вида postgresql://user:pass@host:port/db")
return dsn
}
rest := dsn[schemeEnd+3:]
query := ""
if q := strings.Index(rest, "?"); q >= 0 {
query = rest[q:]
rest = rest[:q]
hostPart := dsn[schemeEnd+3:]
slashIdx := strings.LastIndex(hostPart, "/")
if slashIdx < 0 {
return dsn
}
slash := strings.LastIndex(rest, "/")
if slash < 0 {
return "", fmt.Errorf("iotpg: replaceDSNDatabase: в DSN нет имени базы данных")
afterSlash := hostPart[slashIdx+1:]
suffix := ""
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.