Files
houseplan-card/scripts/support-relay

House Plan support relay

Приёмщик обезличенных отчётов «Помощь и обратная связь» (#43, §9 ТЗ 043). Отдельно разворачиваемый сервис: в артефакт 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_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, ни в дампе окружения. Адрес вебхука — такой же секрет, как токен: он сам себе ключ доступа.

Установка

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

Проверить состояние

curl -s https://support.houseplan.tech/health
systemctl status hp-support-relay@prod
journalctl -u hp-support-relay@prod -n 50

Выключить приём (§19 ТЗ — откат начинается отсюда)

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 и не принимает отчёты. Это важнее, чем кажется: принять и потерять — хуже, чем честно отказать, потому что пользователь считает обращение отправленным.

Сменить секрет доставки

# прямой 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, не в этом репозитории):

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 здесь не косметика: без него сообщение пользователя разбирается как разметка и может подделать вид всего уведомления.

Прочитать обращение

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.

Тесты

cd scripts/support-relay && python3 -m unittest discover -s tests -q

Тридцать пять проверок: схема, размеры, хеш, идемпотентность, частота, ретеншн, буквальность текста, отсутствие адреса в журналах, невозможность выбрать себе корзину лимита подделкой заголовка, поведение рубильника. Каждая проверялась отрицательным прогоном — тринадцать мутаций рабочего кода (снять сверку хеша, разрешить лишнюю часть, не чистить управляющие символы, снять лимит, писать адрес в журнал, игнорировать идемпотентность, отключить рубильник, отключить ретеншн, не проверять секции пакета, брать первый элемент X-Forwarded-For, подменить source в вебхуке, приложить пакет к вебхуку) роняют ровно те проверки, ради которых написаны.