Files

216 lines
13 KiB
Markdown
Raw Permalink 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.
# 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` в вебхуке, приложить пакет к вебхуку,
читать заголовок независимо от переключателя доверия прокси) роняют ровно те
проверки, ради которых написаны.