Files
sless/doc/thinking/2026-04-04-02.md
T
Naeel d078d3156f doc(thinking): лог сессии 2026-04-04 — TLS, UX, Nubes rebrand
- Разбор проблемы crypto.subtle (HTTP → HTTPS)
- Проблема 404 после apply (Docker layer cache)
- Убраны поля API/MQTT из формы входа
- Ребрендинг Nubes: палитра #001C34, логотип SVG, favicon
- Таблица версий v0.1.54–v0.1.58 с коммитами
2026-04-04 20:39:51 +03:00

391 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Лог мышления — 2026-04-04-02
## Агент: GitHub Copilot (Claude Sonnet 4.6)
Продолжение сессии от 2026-04-04 (файл 2026-04-04.md достиг 681 строки).
---
## Задача: MQTT WebSocket через nginx-ingress — отладка 404 → 400
### Контекст
Порт 1883 заблокирован NSX-T Edge firewall на уровне облака.
Временный workaround: EMQX WebSocket listener (8083) → nginx-ingress → `iot.kube5s.ru/mqtt`.
### Что нашёл
**Проблема 1 — 404:** Ingress не получил ADDRESS (IP не присвоен).
- Причина: annotation `nginx.ingress.kubernetes.io/configuration-snippet` заблокирован в nginx-controller v1.12.6.
По умолчанию `allow-snippet-annotations=false`.
- Решение: убрал `configuration-snippet` из Ingress.
- Результат: Ingress получил sync (Scheduled for sync), но ADDRESS всё ещё пустой — это нормально для данного контроллера.
**Проблема 2 — 400:** После удаления snippet стало 400 вместо 404.
- nginx конфиг для `iot.kube5s.ru` правильный:
- `proxy_set_header Upgrade $http_upgrade` — ЕСТЬ (nginx-ingress добавляет автоматически)
- `proxy_http_version 1.1` — ЕСТЬ
- `proxy_send_timeout/read_timeout 3600s` — ЕСТЬ
- `proxy_pass → sless-emqx-ws-8083` — ЕСТЬ
- Значит 400 приходит от EMQX, а не от nginx.
### Гипотезы почему EMQX возвращает 400
EMQX WebSocket listener конфиг:
```
listeners.ws.default {
bind = "0.0.0.0:8083"
max_connections = 512
}
```
Нет явного `websocket.path`. По умолчанию EMQX ожидает путь `/mqtt`.
Но nginx из-за `pathType: Prefix` на `/mqtt` может передавать путь без trailing slash.
**Вариант A**: nginx передаёт запрос на `/mqtt` (без `/`) — EMQX ожидает `/mqtt` → должно работать.
**Вариант B**: EMQX в 5.5.1 ожидает `ws://host/mqtt` без всякого proxy path rewrite и есть баг с `X-Forwarded-Proto` — nginx шлёт `http`, а EMQX может редиректить.
**Вариант C**: `ssl_redirect: true` в nginx конфиге означает что HTTP запрос перенаправляется на HTTPS, и там 400.
### Следующий шаг
Проверить `$ssl_redirect = true` в nginx server block для нашего хоста:
- Если да — nginx делает redirect HTTP→HTTPS
- Добавить аннотацию `nginx.ingress.kubernetes.io/ssl-redirect: "false"` или тестировать через HTTPS
### Правило (новое)
Если файл `doc/thinking/ГГГГ-ММ-ДД.md` стал большим — создавать `ГГГГ-ММ-ДД-02.md`, `ГГГГ-ММ-ДД-03.md` и т.д.
---
## Задача: Анализ безопасности MQTT multitenancy
### Что исследовал
Пользователь спросил об угрозах межтенантного проникновения.
Изучил:
1. `emqx.conf` в поде — `authorization { no_match = allow }` — после CONNECT всё разрешено
2. `internal/api/handler/iot_device_handler.go` — `MQTTAuth` возвращает только `{"result":"allow"}` без ACL rules
### Вывод
**Auth (CONNECT) защищён:**
- HTTP auth endpoint проверяет namespace+deviceId+password (constant-time compare)
- enabled=true проверяется
- Secret изолирован по namespace
**ACL на pub/sub НЕТ:**
- `no_match = allow` — аутентифицированный клиент может SUBSCRIBE на любой топик
- EMQX HTTP auth plugin поддерживает возврат ACL rules в ответе на auth
- Формат ответа: `{"result":"allow","acl":[{"permission":"allow","action":"all","topic":"sless/ns/+"}]}`
- Текущий `mqttAuthResponse` содержит только `Result string` — ACL поле отсутствует
### Риски по приоритету
1. **Критично**: User A может SUBSCRIBE `sless/#` и читать все IoT данные всех пользователей
2. **Средне**: Нет rate limit на MQTT — один клиент может flood брокер
3. **Низко**: Нет TLS на 8083 (WebSocket без шифрования) — данные видны в сети
### План фикса
Добавить в `mqttAuthResponse` поле `ACL []aclRule` и возвращать из `MQTTAuth`:
```json
{
"result": "allow",
"acl": [
{"permission": "allow", "action": "publish", "topic": "sless/{ns}/{deviceId}"},
{"permission": "allow", "action": "subscribe", "topic": "sless/{ns}/{deviceId}"},
{"permission": "deny", "action": "all", "topic": "#"}
]
}
```
Ждём подтверждения от пользователя перед реализацией.
---
## Архитектурная дискуссия — IoT телеметрия и хранение данных
### Контекст разговора
Пользователь задал вопрос: "куда пишутся данные с IoT датчиков?"
Выяснилось что сейчас данные теряются — function pod получает событие но никуда не сохраняет. Это нормально для serverless (пользователь сам решает), но для IoT платформы нужно автоматическое хранение.
### Анализ сценариев использования
Реалистичные клиенты для Nubes (облачный провайдер СНГ, малый/средний бизнес):
1. Мониторинг объектов (склады, серверные, торговые точки) — температура, влажность, протечка
2. Умные счётчики / ЖКХ — снятие показаний без выезда
3. Небольшое производство / агро — теплицы, мини-заводы
Общий паттерн для всех: датчик → данные в БД → алерт если порог → график
### Решение по хранению данных
**Вопрос**: один большой Postgres или отдельный на каждого?
**Ответ**: один Postgres инстанс, но отдельная DATABASE на каждого tenant.
Причины:
- Вариант со одной таблицей + tenant_id — изоляция программная, ошибка в коде = утечка
- Отдельная DATABASE — физическая изоляция, разные connection string, разные пароли
- Клиент B не может подключиться к DATABASE клиента A даже при баге в коде платформы
Структура:
```
Postgres инстанс
├── sless_platform DB — системные таблицы (tenants, invocations)
├── tenant_abc DB — только данные клиента A
└── tenant_def DB — только данные клиента B
```
### Решение по доступу клиента
**Вопрос**: давать клиенту прямой доступ к Postgres?
**Ответ**: нет. Только через REST API платформы.
Причины:
- Postgres внутри кластера, снаружи не торчит (security)
- Единый endpoint `iot.kube5s.ru`
- Легко добавить rate limit, биллинг, кеш
- Клиент не зависит от деталей реализации хранилища
API:
```
GET /v1/namespaces/{ns}/iot/telemetry?device=X&from=T&to=T
GET /v1/namespaces/{ns}/iot/devices/{id}/last
```
### Решение по schema.sql
Клиент может положить `schema.sql` рядом с функцией. При деплое платформа выполняет его в БД tenant'а.
Это даёт низкий порог входа — клиент не шарит в Python, но может написать SQL по шаблону.
### Ключевое архитектурное решение — разделение операторов
**Решение**: sless-operator и iot-operator — ОТДЕЛЬНЫЕ компоненты.
Пока в одном кластере, но сделать так чтобы могли быть в разных.
**Namespace layout:**
```
namespace: sless — платформа sless (operator, event-dispatcher, RabbitMQ, Postgres invocations)
namespace: sless-{hash} — tenant функции (function pods)
namespace: iot — платформа IoT (iot-operator, EMQX, Postgres telemetry)
namespace: iot-{hash} — tenant IoT (IoTDevice CRDs)
```
**Связь**:
- Общий идентификатор tenant: `{hash}` одинаковый в обоих namespace
- MQTT событие → RabbitMQ в sless → function pod в sless-{hash}
- IoT operator НЕ импортирует пакеты sless-operator (loose coupling)
- Общение только через k8s API и RabbitMQ
**Postgres**:
- sless имеет свой Postgres (invocations)
- iot имеет свой Postgres (telemetry per tenant)
- Разные StatefulSet, разные PVC
### Что делает пользователь
Клиент:
1. Подключает устройство → данные автоматически пишутся в его `iot_telemetry`
2. Пишет функцию которая реагирует на события
3. Функция получает `DB_DSN` в env var (автоматически из Secret)
4. Может делать SELECT/INSERT в свою БД через обычный SQL в коде функции
5. Может читать телеметрию через REST API
### Plan — следующие шаги (этап IoT Postgres)
1. Поднять Postgres StatefulSet в namespace `iot`
2. В iot-operator при создании IoTDevice namespace → `CREATE USER`, `CREATE DATABASE`, `CREATE TABLE iot_telemetry`, `CREATE TABLE iot_devices`
3. Credentials → k8s Secret `iot-tenant-{ns}-pg`
4. В iot-mqtt-bridge при получении MQTT сообщения → INSERT в tenant БД
5. REST API endpoint для чтения телеметрии
6. При деплое function → прокинуть `DB_DSN` в env var из Secret
7. При деплое function → если есть `schema.sql` → выполнить в tenant БД
### Технические решения
- Postgres: `postgres:16-alpine` StatefulSet с PVC 10Gi в namespace `iot`
- Connection pool: pgxpool (pgx v5) per-tenant, lazy init, max 5 conn per tenant
- Таблица telemetry: `(id bigserial, device_id text, ts timestamptz default now(), payload jsonb)`
- Индекс: `(device_id, ts DESC)` для быстрых запросов по устройству за период
- Retention: пока без TTL, добавить позже через pg_partman или cron job
---
## 2026-04-04 — IoT Console UI: план и реализация
**Агент**: GitHub Copilot (Claude Sonnet 4.6)
### Постановка задачи
Пользователь сформулировал: нужен UI для управления IoT устройствами.
Причина: не все пользователи работают через Terraform/API напрямую.
Нужно: создать устройство, получить credentials, прошить в устройство, проверить отправку данных.
### Ключевое решение: эмулятор устройства в браузере
MQTT WebSocket уже работает: `ws://iot.kube5s.ru:80/mqtt`.
Браузер через mqtt.js (CDN) может подключиться как устройство напрямую.
Это значит: эмулятор — это не "симуляция", а реальная публикация MQTT сообщений.
Когда у клиента ещё нет физического устройства — он тестирует через эмулятор.
Это закрывает весь цикл без необходимости устанавливать MQTT-клиент.
### Архитектурные решения UI
**Стек**: ванильный HTML/CSS/JS + mqtt.js (CDN). Никаких фреймворков.
**Где хранить**: встраиваем в бинарник оператора через `go:embed`.
- Файл: `internal/api/ui/iot-console.html`
- Маршрут: `GET /console`
**Где доступен**: `http://iot.kube5s.ru/console`
- Ingress добавляем path `/console` → `sless-operator:9090`
**Почему не `https://sless.kube5s.ru/console`:**
- UI на HTTPS + MQTT WS без TLS = mixed content, браузер блокирует
- UI на HTTP + MQTT WS = нет mixed content, всё работает
- HTTP → HTTPS API вызовы разрешены (это не mixed content)
- Нужен только CORS на API стороне
**CORS**: заголовки `Access-Control-Allow-Origin: http://iot.kube5s.ru` + OPTIONS preflight
### Страницы
1. Вход: API адрес + MQTT брокер + namespace + токен → localStorage
2. Список устройств: таблица, создать, удалить
3. Устройство (3 вкладки):
- Credentials: username, password скрыт, топик, инструкция
- Эмулятор: подключиться → JSON payload → send / авто
- Телеметрия: "скоро"
### Следующие шаги после UI
1. Postgres StatefulSet в namespace `iot`
2. INSERT в iot_telemetry из mqtt-bridge
3. REST API для чтения телеметрии
4. Заполнить вкладку "Телеметрия" в UI
---
# Агент: GitHub Copilot (Claude Sonnet 4.6) — продолжение сессии 2026-04-04
## Исправления и улучшения IoT Console UI (v0.1.53 → v0.1.58)
### Проблема 1: `crypto.subtle.digest` — Cannot read properties of undefined
**Симптом:** Пользователь вставил токен, получил ошибку "Cannot read properties of undefined (reading 'digest')".
**Анализ:** `crypto.subtle` доступен ТОЛЬКО на HTTPS-страницах (Secure Context). Консоль раздавалась по HTTP (`http://iot.kube5s.ru/console`). На HTTP `crypto.subtle === undefined`.
**Решение:** Перевести консоль на HTTPS — это устранит корень проблемы и заодно уберёт необходимость в pure-JS SHA256. Попытка написать pure-JS SHA256 была правильной как fallback, но правильнее — исправить инфраструктуру.
**Действия:**
1. `emqx-ws-ingress.yaml`: добавлена TLS-секция + `cert-manager.io/cluster-issuer: letsencrypt-prod`, `ssl-redirect: "true"`, `secretName: iot-kube5s-ru-tls`
2. `router.go`: CORS `Allow-Origin`: `http://` → `https://iot.kube5s.ru`
3. `iot-console.html`: дефолт MQTT брокера `ws://` → `wss://`
4. cert-manager автоматически выпустил сертификат Let's Encrypt (READY: True за ~34 сек)
5. Собрали v0.1.54, задеплоили
**Косяк при apply:** `kubectl apply` взял старый Ingress из кэша (только путь `/mqtt`, без `/console`). Пришлось использовать `kubectl replace` вместо `apply`.
**Итог:** `https://iot.kube5s.ru/console` → 200, TLS v1.3, `CN=iot.kube5s.ru`, Let's Encrypt R13. `crypto.subtle` заработал.
---
### Проблема 2: 404 после перехода на HTTPS (v0.1.54)
**Симптом:** После `kubectl apply` + rollout — curl возвращал 404.
**Анализ:** Запрос доходил до пода (видно в логах), но оператор отвечал 404. Значит маршрут `/console` не регистрировался. Проверили: файл `iot-console.html` существует на диске, `go:embed` прописан, маршрут в `router.go` есть. **Причина:** первый `docker build` взял Go-слои из кэша Docker — старый бинарь без `/console` маршрута.
**Решение:** Пересборка с `--no-cache`. После пуша нового диджеста и `kubectl rollout restart` — заработало.
---
### Улучшение: убрать поля API/MQTT из формы входа (v0.1.56)
**Анализ:** Пользователь справедливо спросил "ЗАЧЕМ юзеру это вводить?" — адреса `https://sless.kube5s.ru` и `wss://iot.kube5s.ru/mqtt` фиксированы для данного деплоя. Пользователь не должен их трогать.
**Решение:** Удалены `<input id="f-api">` и `<input id="f-mqtt">` из формы. В `doLogin()` адреса берутся из хардкода, не из DOM. Форма стала: только поле токена + кнопка "Войти".
**Параллельно:** Добавлен блок `<details class="help-block">` внизу страницы устройства — 5 шагов инструкции: Credentials → формат JSON → Эмулятор → Авто → Телеметрия (скоро).
---
### Ребрендинг: Nubes brand design (v0.1.57)
**Задача:** "Оформи чтобы строго, чётко — как на terra.k8c.ru".
**Исследование:**
- Скачал SVG логотипа: `https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg`
- Логотип залит `#001C34` — это основной Nubes Navy цвет
- Сайт nubes.ru использует тёмно-синий (#001C34) как бренд-прайм
**Палитра:**
| Переменная | Цвет | Назначение |
|----------------|------------|------------------------------|
| brand primary | `#001C34` | Navbar, карточки, логотип |
| page bg | `#001120` | Фон страницы |
| card surface | `#001929` | Карточки .card |
| borders | `#0b2d50` | Границы, разделители |
| accent | `#1a7fd4` | Кнопки, табы, ссылки |
| text primary | `#e2ecf6` | Основной текст |
| text secondary | `#6b8eaa` | Метки, подписи |
| text muted | `#2d5070` | Отключённые, подсказки |
**Изменения в CSS:**
- Navbar: `background: #001C34`, логотип SVG с `filter: brightness(0) invert(1)` (белый)
- Badges: прямоугольные (`border-radius: 4px`), UPPERCASE, компактные
- Кнопки: `font-weight: 600`, `letter-spacing: 0.02em`
- Таблицы: заголовки `color: #2d5070` — строгие, тихие
- `.help-num`: квадратные (4px), не круглые
**Форма входа:** логотип SVG (инвертированный) вместо `⚡`, подпись `IoT Console` uppercase вместо названия по-русски.
---
### Favicon (v0.1.58)
**Задача:** Иконка вкладки браузера — как у Nubes docs.
**Исследование:** `curl https://terra.k8c.ru/docs/nubes/nubes/2.0.2/` → `<link rel="icon" href="30_registry/assets/favicon.png">`
**URL:** `https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/favicon.png`
**Решение:** Добавлена одна строка в `<head>`:
```html
<link rel="icon" href="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/favicon.png">
```
---
## Итоговые версии
| Версия | Изменение | Коммит |
|---------|-------------------------------------------------|----------|
| v0.1.54 | TLS на iot.kube5s.ru, wss://, CORS https | fb6f9d4 |
| v0.1.55 | Help-блок на странице устройства | e547871 |
| v0.1.56 | Убраны поля API/MQTT из формы входа | e547871 |
| v0.1.57 | Nubes brand rebrand — палитра, логотип | 0400f97 |
| v0.1.58 | Favicon Nubes | 93e87a3 |
## Текущее состояние
- ✅ `https://iot.kube5s.ru/console` — работает, TLS, Nubes-дизайн, favicon
- ✅ MQTT: `wss://iot.kube5s.ru/mqtt`
- ✅ `crypto.subtle` работает (HTTPS)
- ✅ Форма входа: только токен
- ✅ Namespace скрыт от пользователя
- ❌ Телеметрия — заглушка, бэкенд не написан
## Следующий шаг
Бэкенд телеметрии:
1. Postgres StatefulSet в namespace `iot`
2. Tenant provisioning при создании IoTDevice
3. INSERT в mqtt-bridge
4. REST API чтения
5. Вкладка Телеметрия в UI