mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
216 lines
13 KiB
Markdown
216 lines
13 KiB
Markdown
# House Plan support relay
|
||
|
||
Приёмщик обезличенных отчётов «Помощь и обратная связь» (#43, §9 ТЗ
|
||
[043](../../docs/specs/043-private-support-report.md)). Отдельно разворачиваемый
|
||
сервис: в артефакт HACS не входит, в карточку не собирается.
|
||
|
||
Два маршрута и ни одного лишнего:
|
||
|
||
| Маршрут | Назначение |
|
||
|---|---|
|
||
| `POST /v1/reports` | приём отчёта (multipart: часть `request` + необязательная `attachment`) |
|
||
| `GET /health` | режим, состояние рубильника, срок хранения |
|
||
|
||
## Почему без зависимостей
|
||
|
||
Сервис написан на стандартной библиотеке Python 3.12. Причина не в аскезе: это
|
||
публичный эндпоинт без общего секрета с клиентом, и любая зависимость на нём —
|
||
это чужой код, за обновлениями которого придётся следить вечно ради пяти
|
||
запросов в час. Отсутствие зависимостей делает установку копированием каталога,
|
||
а ревью — чтением четырёхсот строк.
|
||
|
||
## Как устроена защита
|
||
|
||
Общего секрета у открытого клиента быть не может (§9.2 ТЗ), поэтому защита
|
||
стоит на трёх опорах, и каждая проверяется тестами:
|
||
|
||
1. **Схема.** Неизвестная часть multipart, неизвестное поле в `request`,
|
||
неизвестная секция в пакете, чужой `format`, вложенный multipart, повтор
|
||
части — отказ. Не «игнорируем лишнее», а именно отказ.
|
||
2. **Размер.** `Content-Length` больше 8,5 МиБ отвергается **до** чтения тела;
|
||
вложение сверяется с заявленными длиной и sha256 и разбирается как JSON.
|
||
3. **Частота.** 5 попыток в час и 20 в сутки на источник плюс общий
|
||
предохранитель 60 в час на узел.
|
||
|
||
Адрес источника нигде не хранится: он превращается в HMAC от секрета узла и
|
||
сегодняшней даты, ключ живёт сутки. Штатный логгер `BaseHTTPRequestHandler`
|
||
заменён — он печатал адрес клиента.
|
||
|
||
Из `X-Forwarded-For` берётся **последний** элемент, а не первый: первый прислал
|
||
клиент, и подделать его может кто угодно, а последний проставлен ближайшим
|
||
звеном — нашим же Caddy. Caddy при этом настроен перезаписывать заголовок
|
||
целиком (`header_up X-Forwarded-For {remote_host}`). Две меры вместо одной
|
||
потому, что цена ошибки здесь — обход частотного лимита сменой одной строки в
|
||
запросе, и полагаться на умолчания чужого конфига для этого нельзя.
|
||
|
||
Заголовок читается только при включённом `HP_RELAY_TRUSTED_PROXY` (умолчание —
|
||
включён). Со снятым переключателем relay игнорирует заголовок и берёт адрес
|
||
соединения: это верно для узла, выставленного в интернет напрямую, и **запрещено
|
||
для узла за прокси** — там все соединения приходят от прокси, и весь публичный
|
||
эндпоинт делил бы одну корзину лимита на всех. Оба продовых инстанса стоят за
|
||
Caddy, поэтому у обоих переключатель включён.
|
||
|
||
Сообщение и контакт нормализуются и очищаются от управляющих символов, включая
|
||
маркеры двунаправленного письма: доставка выводит их буквальным текстом без
|
||
разметки, поэтому подделать вид сообщения нельзя.
|
||
|
||
## Доставка
|
||
|
||
Каналов два, `HP_RELAY_CHANNEL`.
|
||
|
||
**`ha_webhook` — рабочий канал стенда.** Relay отдаёт сводку вебхуку Home
|
||
Assistant владельца, а последнюю милю до Telegram делает уже он. Так вышло не от
|
||
хорошей жизни: узел стоит у российского хостера, откуда `api.telegram.org`
|
||
недоступен по всем адресам — прямая доставка падала с «Network is unreachable».
|
||
Побочный выигрыш: пакет остаётся в спуле стенда и в мессенджер не уходит, то
|
||
есть геометрия чужого дома не гуляет по чатам. Сводка называет путь к пакету.
|
||
|
||
**`telegram` — прямой канал.** Сводка сообщением, пакет документом. Годится для
|
||
узла, откуда Telegram доступен. `parse_mode` не используется намеренно: текст
|
||
пользователя отображается буквально.
|
||
|
||
Ответ провайдера наружу не отражается ни в одном из каналов: клиент получает
|
||
только `report_id` либо стабильный код отказа.
|
||
|
||
Отчёт кладётся на диск **до** попытки доставки. Если доставка не удалась,
|
||
клиент получает retryable `support_unavailable`, а обращение остаётся на узле —
|
||
терять его нельзя.
|
||
|
||
`HP_RELAY_MODE=discard` (staging) принимает и складывает отчёт, но никуда его не
|
||
отправляет. Это и есть эндпоинт для CI без production-доставки.
|
||
|
||
## Коды ответа
|
||
|
||
| HTTP | Тело | Когда |
|
||
|---|---|---|
|
||
| 200 | `{"report_id": "hpr-…"}` | принято; повтор с тем же `idempotency_key` вернёт тот же id и `"duplicate": true` |
|
||
| 400 | `support_rejected` / `support_invalid_message` | схема, размерность полей, хеш, пустое сообщение |
|
||
| 413 | `support_package_too_large` | запрос или вложение больше лимита |
|
||
| 429 | `support_rate_limited` | исчерпан лимит источника или узла |
|
||
| 503 | `support_unavailable` | рубильник выключен либо доставка не удалась |
|
||
|
||
## Переменные окружения
|
||
|
||
См. `deploy/env.example`. Секреты доставки задаются **путём к файлу**
|
||
(`HP_RELAY_TELEGRAM_TOKEN_FILE`, `HP_RELAY_WEBHOOK_URL_FILE`), а не значением:
|
||
так они не видны ни в `systemctl show`, ни в `ps`, ни в дампе окружения. Адрес
|
||
вебхука — такой же секрет, как токен: он сам себе ключ доступа.
|
||
|
||
## Установка
|
||
|
||
```bash
|
||
sudo useradd --system --home-dir /var/lib/hp-support-relay --shell /usr/sbin/nologin hprelay
|
||
sudo mkdir -p /opt/hp-support-relay /etc/hp-support-relay /var/lib/hp-support-relay/{prod,staging}
|
||
sudo rsync -a --delete scripts/support-relay/ /opt/hp-support-relay/
|
||
sudo chown -R hprelay:hprelay /var/lib/hp-support-relay
|
||
sudo chmod 700 /var/lib/hp-support-relay/{prod,staging}
|
||
|
||
sudo cp deploy/hp-support-relay@.service deploy/hp-support-relay-purge@.service \
|
||
deploy/hp-support-relay-purge@.timer /etc/systemd/system/
|
||
sudo install -m 0640 -o root -g hprelay deploy/env.example /etc/hp-support-relay/prod.env
|
||
# staging: HP_RELAY_MODE=discard, HP_RELAY_PORT=8131, свой спул
|
||
sudo systemctl daemon-reload
|
||
sudo systemctl enable --now hp-support-relay@prod hp-support-relay@staging
|
||
sudo systemctl enable --now hp-support-relay-purge@prod.timer hp-support-relay-purge@staging.timer
|
||
```
|
||
|
||
Затем добавить `deploy/Caddyfile.fragment` в `/etc/caddy/Caddyfile` и
|
||
`sudo systemctl reload caddy`. **Reload, а не restart**: валидный конфиг с
|
||
недоступным доменом уронит сервис при рестарте, тогда как reload оставит
|
||
работать прежний.
|
||
|
||
## Ответственный
|
||
|
||
За рубильник, оповещения и ротацию секрета отвечает Sergey Matyunin
|
||
(владелец проекта).
|
||
|
||
## Runbook
|
||
|
||
**Проверить состояние**
|
||
|
||
```bash
|
||
curl -s https://support.houseplan.tech/health
|
||
systemctl status hp-support-relay@prod
|
||
journalctl -u hp-support-relay@prod -n 50
|
||
```
|
||
|
||
**Выключить приём** (§19 ТЗ — откат начинается отсюда)
|
||
|
||
```bash
|
||
sudo sed -i 's/^HP_RELAY_ENABLED=1/HP_RELAY_ENABLED=0/' /etc/hp-support-relay/prod.env
|
||
sudo systemctl restart hp-support-relay@prod
|
||
```
|
||
|
||
Выключенный relay отвечает единообразным 503 и **не принимает** отчёты. Это
|
||
важнее, чем кажется: принять и потерять — хуже, чем честно отказать, потому что
|
||
пользователь считает обращение отправленным.
|
||
|
||
**Сменить секрет доставки**
|
||
|
||
```bash
|
||
# прямой Telegram
|
||
sudo install -m 0400 -o hprelay -g hprelay /dev/stdin /etc/hp-support-relay/telegram.token
|
||
# канал через Home Assistant (адрес вебхука целиком)
|
||
sudo install -m 0400 -o hprelay -g hprelay /dev/stdin /etc/hp-support-relay/webhook.url
|
||
sudo systemctl restart hp-support-relay@prod
|
||
```
|
||
|
||
Ротация вебхука — это ещё и правка `webhook_id` в автоматизации Home Assistant
|
||
«House Plan: приёмщик обратной связи → личка»: адрес и есть ключ.
|
||
|
||
**Минимальная конфигурация той автоматизации** — на случай, если её придётся
|
||
пересоздать (она живёт в чужом Home Assistant, не в этом репозитории):
|
||
|
||
```yaml
|
||
alias: "House Plan: приёмщик обратной связи → личка"
|
||
mode: queued
|
||
max: 10
|
||
triggers:
|
||
- trigger: webhook
|
||
webhook_id: "<тот же идентификатор, что в webhook.url>"
|
||
allowed_methods: [POST]
|
||
local_only: false # relay вызывает её снаружи
|
||
conditions:
|
||
- condition: template
|
||
value_template: "{{ trigger.json.source == 'houseplan-support-relay' }}"
|
||
actions:
|
||
- action: telegram_bot.send_message
|
||
data:
|
||
entity_id: notify.<получатель>
|
||
parse_mode: plain_text # текст пишет посторонний человек
|
||
disable_web_page_preview: true
|
||
message: "{{ trigger.json.text }}"
|
||
```
|
||
|
||
`parse_mode: plain_text` здесь не косметика: без него сообщение пользователя
|
||
разбирается как разметка и может подделать вид всего уведомления.
|
||
|
||
**Прочитать обращение**
|
||
|
||
```bash
|
||
sudo -u hprelay ls /var/lib/hp-support-relay/prod/reports/*/
|
||
sudo -u hprelay cat /var/lib/hp-support-relay/prod/reports/2026-09/hpr-…/report.json
|
||
```
|
||
|
||
**Срок хранения.** Таймер `hp-support-relay-purge@prod.timer` ежедневно удаляет
|
||
отчёты старше `HP_RELAY_RETENTION_DAYS` (30) и метаданные частоты и
|
||
идемпотентности старше суток. Проверить вручную:
|
||
`sudo -u hprelay HP_RELAY_SPOOL=… python3 /opt/hp-support-relay/relay.py purge`.
|
||
|
||
## Тесты
|
||
|
||
```bash
|
||
cd scripts/support-relay && python3 -m unittest discover -s tests -q
|
||
```
|
||
|
||
Тридцать шесть проверок: схема, размеры, хеш, идемпотентность, частота,
|
||
ретеншн, буквальность текста, отсутствие адреса в журналах, невозможность
|
||
выбрать себе корзину лимита подделкой заголовка, поведение рубильника.
|
||
Каждая проверялась отрицательным прогоном — четырнадцать мутаций рабочего кода
|
||
(снять сверку хеша, разрешить лишнюю часть, не чистить управляющие символы,
|
||
снять лимит, писать адрес в журнал, игнорировать идемпотентность, отключить
|
||
рубильник, отключить ретеншн, не проверять секции пакета, брать первый элемент
|
||
`X-Forwarded-For`, подменить `source` в вебхуке, приложить пакет к вебхуку,
|
||
читать заголовок независимо от переключателя доверия прокси) роняют ровно те
|
||
проверки, ради которых написаны.
|