diff --git a/scripts/support-relay/README.md b/scripts/support-relay/README.md
new file mode 100644
index 00000000..2d23d917
--- /dev/null
+++ b/scripts/support-relay/README.md
@@ -0,0 +1,148 @@
+# 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`
+заменён — он печатал адрес клиента.
+
+Сообщение и контакт нормализуются и очищаются от управляющих символов, включая
+маркеры двунаправленного письма: доставка выводит их буквальным текстом без
+разметки, поэтому подделать вид сообщения нельзя.
+
+## Доставка
+
+Канал — Telegram (решение владельца 2026-09-01): сводка сообщением, пакет —
+документом. `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`), а не значением: так он не виден ни в
+`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 оставит
+работать прежний.
+
+## 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
+sudo install -m 0400 -o hprelay -g hprelay /dev/stdin /etc/hp-support-relay/telegram.token
+sudo systemctl restart hp-support-relay@prod
+```
+
+**Прочитать обращение**
+
+```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
+```
+
+Тридцать проверок: схема, размеры, хеш, идемпотентность, частота, ретеншн,
+буквальность текста, отсутствие адреса в журналах, поведение рубильника.
+Каждая проверялась отрицательным прогоном — десять мутаций рабочего кода
+(снять сверку хеша, разрешить лишнюю часть, не чистить управляющие символы,
+снять лимит, писать адрес в журнал, игнорировать идемпотентность, отключить
+рубильник, отключить ретеншн, не проверять секции пакета) роняют ровно те
+проверки, ради которых написаны.
diff --git a/scripts/support-relay/deploy/Caddyfile.fragment b/scripts/support-relay/deploy/Caddyfile.fragment
new file mode 100644
index 00000000..732ad365
--- /dev/null
+++ b/scripts/support-relay/deploy/Caddyfile.fragment
@@ -0,0 +1,19 @@
+# Фрагмент для /etc/caddy/Caddyfile. Требует A-записей support и
+# support-staging на адрес стенда — иначе Caddy не получит сертификат.
+#
+# Access-лог намеренно не настраивается: он пишет remote_ip, а сырой адрес
+# источника не должен оседать на диске (§9.2 ТЗ 043).
+
+support.houseplan.tech {
+ request_body {
+ max_size 8.7MB
+ }
+ reverse_proxy 127.0.0.1:8130
+}
+
+support-staging.houseplan.tech {
+ request_body {
+ max_size 8.7MB
+ }
+ reverse_proxy 127.0.0.1:8131
+}
diff --git a/scripts/support-relay/deploy/env.example b/scripts/support-relay/deploy/env.example
new file mode 100644
index 00000000..b194338d
--- /dev/null
+++ b/scripts/support-relay/deploy/env.example
@@ -0,0 +1,14 @@
+# /etc/hp-support-relay/prod.env — права 0640, владелец root:hprelay.
+HP_RELAY_PORT=8130
+HP_RELAY_SPOOL=/var/lib/hp-support-relay/prod
+# deliver — отправлять мейнтейнеру; discard — принимать и складывать молча.
+HP_RELAY_MODE=deliver
+# Рубильник: 0 переводит эндпоинт в единообразный 503, ничего не принимая.
+HP_RELAY_ENABLED=1
+HP_RELAY_RETENTION_DAYS=30
+# Секрет НЕ хранится в этом файле: только путь к файлу с токеном (права 0400,
+# владелец hprelay). Так он не попадает ни в `systemctl show`, ни в `ps`.
+HP_RELAY_TELEGRAM_TOKEN_FILE=/etc/hp-support-relay/telegram.token
+HP_RELAY_TELEGRAM_CHAT_ID=
+# Источник берётся из X-Forwarded-For, потому что перед сервисом стоит Caddy.
+HP_RELAY_TRUSTED_PROXY=1
diff --git a/scripts/support-relay/deploy/hp-support-relay-purge@.service b/scripts/support-relay/deploy/hp-support-relay-purge@.service
new file mode 100644
index 00000000..f392f108
--- /dev/null
+++ b/scripts/support-relay/deploy/hp-support-relay-purge@.service
@@ -0,0 +1,14 @@
+# Ретеншн: удаляет отчёты старше HP_RELAY_RETENTION_DAYS и метаданные старше суток.
+[Unit]
+Description=House Plan support relay retention (%i)
+
+[Service]
+Type=oneshot
+User=hprelay
+Group=hprelay
+EnvironmentFile=/etc/hp-support-relay/%i.env
+ExecStart=/usr/bin/python3 /opt/hp-support-relay/relay.py purge
+NoNewPrivileges=yes
+ProtectSystem=strict
+ProtectHome=yes
+ReadWritePaths=/var/lib/hp-support-relay/%i
diff --git a/scripts/support-relay/deploy/hp-support-relay-purge@.timer b/scripts/support-relay/deploy/hp-support-relay-purge@.timer
new file mode 100644
index 00000000..bff64ffa
--- /dev/null
+++ b/scripts/support-relay/deploy/hp-support-relay-purge@.timer
@@ -0,0 +1,10 @@
+[Unit]
+Description=Daily retention for House Plan support relay (%i)
+
+[Timer]
+OnCalendar=daily
+RandomizedDelaySec=30m
+Persistent=true
+
+[Install]
+WantedBy=timers.target
diff --git a/scripts/support-relay/deploy/hp-support-relay@.service b/scripts/support-relay/deploy/hp-support-relay@.service
new file mode 100644
index 00000000..4f776fd3
--- /dev/null
+++ b/scripts/support-relay/deploy/hp-support-relay@.service
@@ -0,0 +1,35 @@
+# Инстанс приёмщика: %i — имя окружения (prod | staging).
+# Конфиг: /etc/hp-support-relay/%i.env, спул: /var/lib/hp-support-relay/%i
+[Unit]
+Description=House Plan support relay (%i)
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=hprelay
+Group=hprelay
+EnvironmentFile=/etc/hp-support-relay/%i.env
+ExecStart=/usr/bin/python3 /opt/hp-support-relay/relay.py
+Restart=on-failure
+RestartSec=5
+
+# Сервис слушает только петлю, наружу ходит лишь к API доставки.
+NoNewPrivileges=yes
+PrivateTmp=yes
+PrivateDevices=yes
+ProtectSystem=strict
+ProtectHome=yes
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectControlGroups=yes
+RestrictAddressFamilies=AF_INET AF_INET6
+RestrictNamespaces=yes
+RestrictSUIDSGID=yes
+LockPersonality=yes
+MemoryMax=256M
+TasksMax=64
+ReadWritePaths=/var/lib/hp-support-relay/%i
+
+[Install]
+WantedBy=multi-user.target
diff --git a/scripts/support-relay/hp_relay/__init__.py b/scripts/support-relay/hp_relay/__init__.py
new file mode 100644
index 00000000..54b6743c
--- /dev/null
+++ b/scripts/support-relay/hp_relay/__init__.py
@@ -0,0 +1,8 @@
+"""House Plan support relay — приёмщик обезличенных отчётов (#43, §9 ТЗ 043).
+
+Пакет намеренно обходится стандартной библиотекой Python: сервис принимает
+единицы запросов в час, а отсутствие зависимостей снимает с проекта цепочку
+обновлений безопасности у чужого кода на публично доступном эндпоинте.
+"""
+
+__all__ = ["config", "multipart", "validate", "ratelimit", "store", "delivery", "app"]
diff --git a/scripts/support-relay/hp_relay/app.py b/scripts/support-relay/hp_relay/app.py
new file mode 100644
index 00000000..590eed8a
--- /dev/null
+++ b/scripts/support-relay/hp_relay/app.py
@@ -0,0 +1,188 @@
+"""HTTP-слой relay: ровно два маршрута и ни одного лишнего.
+
+`POST /v1/reports` — приём отчёта, `GET /health` — состояние. Всё остальное
+отвечает 404 без подсказок. Журнал пишет метод, путь, статус и код отказа; ни
+адреса источника, ни сообщения, ни вложения в журнале нет и быть не должно —
+это требование §9.2/§9.3 ТЗ, а не предпочтение.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import time
+from http import HTTPStatus
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+from . import config as config_module
+from . import delivery as delivery_module
+from . import multipart, ratelimit, store, validate
+
+LOG = logging.getLogger("hp-support-relay")
+
+ALLOWED_PARTS = frozenset({"request", "attachment"})
+
+STATUS_BY_CODE = {
+ "support_invalid_message": HTTPStatus.BAD_REQUEST,
+ "support_rejected": HTTPStatus.BAD_REQUEST,
+ "support_package_too_large": HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
+ "support_rate_limited": HTTPStatus.TOO_MANY_REQUESTS,
+ "support_unavailable": HTTPStatus.SERVICE_UNAVAILABLE,
+}
+
+
+class Service:
+ """Логика, отделённая от транспорта: её же вызывают тесты."""
+
+ def __init__(self, cfg) -> None:
+ self.cfg = cfg
+ self.store = store.Store(cfg.spool)
+ self.limiter = ratelimit.Limiter(cfg.spool)
+ self.secret = ratelimit.node_secret(cfg.spool)
+ self.delivery = delivery_module.build(cfg)
+
+ def health(self) -> dict:
+ return {
+ "status": "ok" if self.cfg.enabled else "disabled",
+ "mode": self.cfg.mode,
+ "delivers": self.cfg.delivers,
+ "retention_days": self.cfg.retention_days,
+ }
+
+ def handle_report(self, content_type: str, body: bytes, source: str) -> tuple[int, dict]:
+ if not self.cfg.enabled:
+ # Рубильник обязан отказывать единообразно и retryable, а не
+ # принимать отчёт и тихо его ронять (§19 ТЗ).
+ return self._error("support_unavailable")
+
+ try:
+ boundary = multipart.parse_content_type(content_type)
+ parts = multipart.parse(body, boundary, ALLOWED_PARTS)
+ except multipart.MultipartError as error:
+ LOG.info("reject multipart: %s", error)
+ return self._error("support_rejected")
+
+ if "request" not in parts:
+ return self._error("support_rejected")
+ if parts["request"].content_type not in {"application/json", ""}:
+ return self._error("support_rejected")
+
+ try:
+ request = validate.parse_request(parts["request"].body)
+ attachment = parts.get("attachment")
+ if attachment is not None:
+ validate.check_attachment(
+ attachment.body, attachment.filename, attachment.content_type, request,
+ )
+ elif request.attachment_size:
+ return self._error("support_rejected")
+ except validate.ValidationError as error:
+ LOG.info("reject payload: %s (%s)", error, error.code)
+ return self._error(error.code)
+
+ existing = self.store.lookup(request.idempotency_key)
+ if existing:
+ # Повтор возвращает исходный идентификатор и НЕ тратит лимит:
+ # это та же попытка, а не новая.
+ LOG.info("idempotent replay -> %s", existing)
+ return HTTPStatus.OK, {"report_id": existing, "duplicate": True}
+
+ key = ratelimit.source_key(self.secret, source)
+ try:
+ self.limiter.check_and_count(key)
+ except ratelimit.RateLimited as error:
+ LOG.info("rate limited: %s", "global" if str(error) == "_global" else "source")
+ return self._error("support_rate_limited")
+
+ report_id = store.new_report_id()
+ meta = {
+ "report_id": report_id,
+ "received_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
+ "message": request.message,
+ "contact": request.contact,
+ "versions": request.versions,
+ "attachment_size": len(attachment.body) if attachment else 0,
+ "attachment_sha256": request.attachment_sha256,
+ }
+ stored = self.store.save(report_id, meta, attachment.body if attachment else None)
+ result = self.delivery.send(report_id, meta, attachment.body if attachment else None)
+ self.store.mark_delivery(stored, "sent" if result.ok else "failed", result.detail)
+ if not result.ok:
+ LOG.warning("delivery failed for %s: %s", report_id, result.detail)
+ # Отчёт на диске, но пользователю обещать доставку нельзя.
+ return self._error("support_unavailable")
+ self.store.remember(request.idempotency_key, report_id)
+ LOG.info("accepted %s (%s)", report_id, result.detail)
+ return HTTPStatus.OK, {"report_id": report_id}
+
+ @staticmethod
+ def _error(code: str) -> tuple[int, dict]:
+ return STATUS_BY_CODE.get(code, HTTPStatus.BAD_REQUEST), {"error": code}
+
+
+def make_handler(service: Service):
+ class Handler(BaseHTTPRequestHandler):
+ server_version = "hp-support-relay"
+ sys_version = ""
+ protocol_version = "HTTP/1.1"
+
+ def log_message(self, fmt: str, *args) -> None: # noqa: A003 - базовый класс
+ # Штатный логгер BaseHTTPRequestHandler печатает адрес клиента.
+ # Здесь он заменён на строку без адреса: сырой IP не должен попадать
+ # в журналы приложения (§9.2 ТЗ).
+ LOG.info("%s %s", self.command, self.path)
+
+ def _respond(self, status: int, payload: dict) -> None:
+ body = json.dumps(payload).encode("utf-8")
+ self.send_response(int(status))
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.send_header("Cache-Control", "no-store")
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _source(self) -> str:
+ if service.cfg.trusted_proxy:
+ forwarded = self.headers.get("X-Forwarded-For", "")
+ if forwarded:
+ return forwarded.split(",")[0].strip()
+ return self.client_address[0]
+
+ def do_GET(self) -> None: # noqa: N802 - имя задано базовым классом
+ if self.path == "/health":
+ self._respond(HTTPStatus.OK, service.health())
+ return
+ self._respond(HTTPStatus.NOT_FOUND, {"error": "not_found"})
+
+ def do_POST(self) -> None: # noqa: N802
+ if self.path != "/v1/reports":
+ self._respond(HTTPStatus.NOT_FOUND, {"error": "not_found"})
+ return
+ raw_length = self.headers.get("Content-Length")
+ if raw_length is None or not raw_length.isdigit():
+ # Без объявленной длины нельзя отказать ДО буферизации,
+ # а буферизовать неизвестно сколько — и есть та самая дыра.
+ self._respond(HTTPStatus.LENGTH_REQUIRED, {"error": "support_rejected"})
+ return
+ length = int(raw_length)
+ if length > config_module.MAX_REQUEST_BYTES:
+ self._respond(
+ HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"error": "support_package_too_large"},
+ )
+ return
+ body = self.rfile.read(length)
+ status, payload = service.handle_report(
+ self.headers.get("Content-Type", ""), body, self._source(),
+ )
+ self._respond(status, payload)
+
+ return Handler
+
+
+def serve(cfg) -> None:
+ service = Service(cfg)
+ server = ThreadingHTTPServer(("127.0.0.1", cfg.port), make_handler(service))
+ LOG.info(
+ "listening on 127.0.0.1:%s mode=%s delivers=%s", cfg.port, cfg.mode, cfg.delivers,
+ )
+ server.serve_forever()
diff --git a/scripts/support-relay/hp_relay/config.py b/scripts/support-relay/hp_relay/config.py
new file mode 100644
index 00000000..2c57e513
--- /dev/null
+++ b/scripts/support-relay/hp_relay/config.py
@@ -0,0 +1,72 @@
+"""Конфигурация relay. Единственный источник — переменные окружения.
+
+Секреты (токен доставки) читаются из ФАЙЛА, путь к которому задан переменной:
+значение секрета не попадает ни в командную строку, ни в `systemctl show`,
+ни в вывод `ps`.
+"""
+
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+from pathlib import Path
+
+# §7.5 ТЗ: вложение ≤ 8 MiB, весь запрос ≤ 8.5 MiB.
+MAX_REQUEST_BYTES = 8 * 1024 * 1024 + 512 * 1024
+MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024
+MAX_MESSAGE_CODEPOINTS = 10_000
+MAX_CONTACT_CODEPOINTS = 320
+
+# §9.2 ТЗ: 5 попыток в час и 20 в сутки на источник.
+RATE_HOURLY = 5
+RATE_DAILY = 20
+# Глобальный предохранитель: столько принятых отчётов в час со всех источников.
+RATE_GLOBAL_HOURLY = 60
+
+IDEMPOTENCY_TTL_SECONDS = 24 * 60 * 60
+RATE_TTL_SECONDS = 24 * 60 * 60
+
+
+@dataclass(frozen=True)
+class Config:
+ port: int
+ spool: Path
+ mode: str # 'deliver' | 'discard'
+ enabled: bool
+ retention_days: int
+ telegram_token: str # пусто = доставка выключена
+ telegram_chat_id: str
+ trusted_proxy: bool # брать источник из X-Forwarded-For
+
+ @property
+ def delivers(self) -> bool:
+ return self.mode == "deliver" and bool(self.telegram_token and self.telegram_chat_id)
+
+
+def _read_secret(path_value: str) -> str:
+ if not path_value:
+ return ""
+ path = Path(path_value)
+ if not path.is_file():
+ return ""
+ return path.read_text(encoding="utf-8").strip()
+
+
+def load(env: dict[str, str] | None = None) -> Config:
+ env = dict(os.environ if env is None else env)
+ mode = env.get("HP_RELAY_MODE", "discard").strip().lower()
+ if mode not in {"deliver", "discard"}:
+ raise ValueError("HP_RELAY_MODE must be 'deliver' or 'discard'")
+ spool = Path(env.get("HP_RELAY_SPOOL", "/var/lib/hp-support-relay"))
+ return Config(
+ port=int(env.get("HP_RELAY_PORT", "8130")),
+ spool=spool,
+ mode=mode,
+ # Рубильник §19 ТЗ: выключенный relay обязан отвечать единообразным 503,
+ # а не принимать отчёты и терять их.
+ enabled=env.get("HP_RELAY_ENABLED", "1").strip() not in {"0", "false", "no"},
+ retention_days=int(env.get("HP_RELAY_RETENTION_DAYS", "30")),
+ telegram_token=_read_secret(env.get("HP_RELAY_TELEGRAM_TOKEN_FILE", "")),
+ telegram_chat_id=env.get("HP_RELAY_TELEGRAM_CHAT_ID", "").strip(),
+ trusted_proxy=env.get("HP_RELAY_TRUSTED_PROXY", "1").strip() not in {"0", "false", "no"},
+ )
diff --git a/scripts/support-relay/hp_relay/delivery.py b/scripts/support-relay/hp_relay/delivery.py
new file mode 100644
index 00000000..83168d42
--- /dev/null
+++ b/scripts/support-relay/hp_relay/delivery.py
@@ -0,0 +1,118 @@
+"""Доставка отчёта мейнтейнеру.
+
+Канал доставки — Telegram (решение владельца 2026-09-01): сообщение с
+идентификатором и безопасными версиями плюс сам пакет отдельным документом.
+Разметка НЕ используется намеренно: без `parse_mode` Telegram показывает текст
+буквально, поэтому сообщение пользователя не может ничего разметить, подделать
+или скрыть.
+
+Ответ провайдера наружу не отражается ни при каких условиях (§9.2 ТЗ): наверх
+уходит только «удалось / не удалось», а подробность живёт в журнале узла.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import urllib.error
+import urllib.request
+from dataclasses import dataclass
+
+TIMEOUT_SECONDS = 20
+API = "https://api.telegram.org"
+
+
+@dataclass(frozen=True)
+class Result:
+ ok: bool
+ detail: str
+
+
+def _post(url: str, body: bytes, content_type: str) -> tuple[int, bytes]:
+ request = urllib.request.Request(url, data=body, method="POST")
+ request.add_header("Content-Type", content_type)
+ try:
+ with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response:
+ return response.status, response.read(4096)
+ except urllib.error.HTTPError as error:
+ return error.code, error.read(4096)
+ except (urllib.error.URLError, TimeoutError, OSError) as error:
+ return 0, str(error).encode("utf-8", "replace")[:4096]
+
+
+def _multipart(fields: dict[str, str], filename: str, blob: bytes) -> tuple[bytes, str]:
+ boundary = "hp" + os.urandom(16).hex()
+ chunks: list[bytes] = []
+ for name, value in fields.items():
+ chunks.append(
+ f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'
+ .encode("utf-8")
+ )
+ chunks.append(
+ f'--{boundary}\r\nContent-Disposition: form-data; name="document"; filename="{filename}"\r\n'
+ f"Content-Type: application/json\r\n\r\n".encode("utf-8")
+ )
+ chunks.append(blob)
+ chunks.append(f"\r\n--{boundary}--\r\n".encode("utf-8"))
+ return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
+
+
+def summary_text(report_id: str, meta: dict) -> str:
+ versions = meta.get("versions") or {}
+ lines = [
+ f"House Plan support report {report_id}",
+ "",
+ "versions: " + (", ".join(f"{k}={v}" for k, v in sorted(versions.items())) or "—"),
+ "attachment: " + (f"{meta.get('attachment_size', 0)} B" if meta.get("attachment_size") else "—"),
+ "contact: " + (meta.get("contact") or "—"),
+ "",
+ "message:",
+ meta.get("message", ""),
+ ]
+ text = "\n".join(lines)
+ # Ограничение Telegram на сообщение — 4096 символов; сообщение пользователя
+ # может быть длиннее, поэтому хвост отрезается с явной пометкой, а полный
+ # текст остаётся в отчёте на диске.
+ if len(text) > 3900:
+ text = text[:3900] + "\n[…] полный текст — в report.json на узле"
+ return text
+
+
+class TelegramDelivery:
+ def __init__(self, token: str, chat_id: str) -> None:
+ self._token = token
+ self._chat_id = chat_id
+
+ def send(self, report_id: str, meta: dict, attachment: bytes | None) -> Result:
+ body = json.dumps({
+ "chat_id": self._chat_id,
+ "text": summary_text(report_id, meta),
+ "disable_web_page_preview": True,
+ }).encode("utf-8")
+ status, _ = _post(f"{API}/bot{self._token}/sendMessage", body, "application/json")
+ if status != 200:
+ return Result(False, f"sendMessage status {status}")
+ if attachment is None:
+ return Result(True, "message only")
+ payload, content_type = _multipart(
+ {"chat_id": self._chat_id, "caption": report_id},
+ f"houseplan-support-{report_id}.json",
+ attachment,
+ )
+ status, _ = _post(f"{API}/bot{self._token}/sendDocument", payload, content_type)
+ if status != 200:
+ return Result(False, f"sendDocument status {status}")
+ return Result(True, "message and document")
+
+
+class DiscardDelivery:
+ """Staging: отчёт принимается и складывается, но никуда не уходит."""
+
+ def send(self, report_id: str, meta: dict, attachment: bytes | None) -> Result:
+ return Result(True, "discarded (staging)")
+
+
+def build(cfg) -> object:
+ if cfg.delivers:
+ return TelegramDelivery(cfg.telegram_token, cfg.telegram_chat_id)
+ return DiscardDelivery()
diff --git a/scripts/support-relay/hp_relay/multipart.py b/scripts/support-relay/hp_relay/multipart.py
new file mode 100644
index 00000000..a8b5bd65
--- /dev/null
+++ b/scripts/support-relay/hp_relay/multipart.py
@@ -0,0 +1,130 @@
+"""Строгий разбор multipart/form-data.
+
+Строгий — значит «принимаем ровно то, что описано в §8.3 ТЗ, всё остальное
+отвергаем». Публичный эндпоинт без общего секрета защищается схемой, размером и
+частотой; парсер здесь — первая из трёх защит, поэтому он не прощает ничего:
+ни лишних частей, ни повторов, ни отсутствующей границы, ни вложенного
+multipart.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+class MultipartError(ValueError):
+ """Тело не соответствует объявленной схеме."""
+
+
+@dataclass(frozen=True)
+class Part:
+ name: str
+ filename: str | None
+ content_type: str
+ body: bytes
+
+
+def parse_content_type(header: str) -> str:
+ """Возвращает boundary или бросает MultipartError."""
+ if not header:
+ raise MultipartError("missing content-type")
+ pieces = [piece.strip() for piece in header.split(";")]
+ if pieces[0].lower() != "multipart/form-data":
+ raise MultipartError("content-type must be multipart/form-data")
+ for piece in pieces[1:]:
+ key, _, value = piece.partition("=")
+ if key.strip().lower() != "boundary":
+ continue
+ value = value.strip()
+ if value.startswith('"') and value.endswith('"') and len(value) >= 2:
+ value = value[1:-1]
+ if not value or len(value) > 70:
+ raise MultipartError("bad boundary")
+ return value
+ raise MultipartError("missing boundary")
+
+
+def _split_headers(chunk: bytes) -> tuple[dict[str, str], bytes]:
+ head, sep, body = chunk.partition(b"\r\n\r\n")
+ if not sep:
+ raise MultipartError("part without headers")
+ headers: dict[str, str] = {}
+ for raw in head.split(b"\r\n"):
+ if not raw:
+ continue
+ try:
+ line = raw.decode("ascii")
+ except UnicodeDecodeError as exc:
+ raise MultipartError("non-ascii header") from exc
+ key, _, value = line.partition(":")
+ if not _:
+ raise MultipartError("malformed header")
+ key = key.strip().lower()
+ if key in headers:
+ raise MultipartError("duplicate header")
+ headers[key] = value.strip()
+ return headers, body
+
+
+def _disposition(value: str) -> tuple[str, str | None]:
+ pieces = [piece.strip() for piece in value.split(";")]
+ if not pieces or pieces[0].lower() != "form-data":
+ raise MultipartError("bad content-disposition")
+ name: str | None = None
+ filename: str | None = None
+ for piece in pieces[1:]:
+ key, _, raw = piece.partition("=")
+ raw = raw.strip()
+ if raw.startswith('"') and raw.endswith('"') and len(raw) >= 2:
+ raw = raw[1:-1]
+ key = key.strip().lower()
+ if key == "name":
+ name = raw
+ elif key == "filename":
+ filename = raw
+ if not name:
+ raise MultipartError("part without name")
+ return name, filename
+
+
+def parse(body: bytes, boundary: str, allowed: frozenset[str]) -> dict[str, Part]:
+ """Разбирает тело и возвращает части по именам.
+
+ Имя, которого нет в `allowed`, — ошибка, а не игнорируемое поле: клиент,
+ приславший лишнюю часть, разговаривает не по этому контракту, и молча
+ принять его запрос значит принять неизвестно что.
+ """
+ marker = b"--" + boundary.encode("ascii")
+ if not body.startswith(marker):
+ raise MultipartError("body does not start with boundary")
+ rest = body[len(marker):]
+ if rest.startswith(b"--"):
+ raise MultipartError("empty body")
+ if not rest.startswith(b"\r\n"):
+ raise MultipartError("malformed preamble")
+ # Открывающая граница снимается ДО разбиения: иначе первая часть вбирает
+ # в себя весь остаток тела вместе с чужими заголовками.
+ segments = rest[2:].split(b"\r\n" + marker)
+ parts: dict[str, Part] = {}
+ closed = False
+ for index, segment in enumerate(segments):
+ if index:
+ if segment.startswith(b"--"):
+ closed = True
+ break
+ if not segment.startswith(b"\r\n"):
+ raise MultipartError("malformed boundary")
+ segment = segment[2:]
+ headers, raw = _split_headers(segment)
+ name, filename = _disposition(headers.get("content-disposition", ""))
+ if name not in allowed:
+ raise MultipartError(f"unexpected part: {name}")
+ if name in parts:
+ raise MultipartError(f"duplicate part: {name}")
+ content_type = headers.get("content-type", "").split(";")[0].strip().lower()
+ if content_type.startswith("multipart/"):
+ raise MultipartError("nested multipart is not accepted")
+ parts[name] = Part(name=name, filename=filename, content_type=content_type, body=raw)
+ if not closed:
+ raise MultipartError("missing closing boundary")
+ return parts
diff --git a/scripts/support-relay/hp_relay/ratelimit.py b/scripts/support-relay/hp_relay/ratelimit.py
new file mode 100644
index 00000000..b0229b2d
--- /dev/null
+++ b/scripts/support-relay/hp_relay/ratelimit.py
@@ -0,0 +1,123 @@
+"""Частотные ограничения без хранения сырых адресов (§9.2 ТЗ).
+
+Адрес источника нигде не сохраняется: он превращается в HMAC от секрета узла и
+СЕГОДНЯШНЕЙ даты. Ключ живёт максимум сутки и не позволяет связать обращения
+разных дней между собой; секрет узла генерируется при первом старте и лежит
+рядом со спулом с правами 0600.
+"""
+
+from __future__ import annotations
+
+import hmac
+import json
+import os
+import threading
+import time
+from dataclasses import dataclass
+from hashlib import sha256
+from pathlib import Path
+
+from . import config
+
+HOUR = 3600
+DAY = 24 * 3600
+
+
+class RateLimited(Exception):
+ """Источник или узел исчерпал лимит."""
+
+
+def _now() -> float:
+ return time.time()
+
+
+def node_secret(spool: Path) -> bytes:
+ path = spool / "node.secret"
+ if path.exists():
+ return path.read_bytes()
+ spool.mkdir(parents=True, exist_ok=True)
+ secret = os.urandom(32)
+ tmp = path.with_suffix(".tmp")
+ tmp.write_bytes(secret)
+ tmp.chmod(0o600)
+ tmp.replace(path)
+ return secret
+
+
+def source_key(secret: bytes, address: str, now: float | None = None) -> str:
+ """Дневной непрозрачный ключ источника: сырой адрес не возвращается никогда."""
+ day = time.strftime("%Y-%m-%d", time.gmtime(_now() if now is None else now))
+ return hmac.new(secret, f"{day}|{address}".encode("utf-8"), sha256).hexdigest()[:32]
+
+
+@dataclass
+class _Bucket:
+ stamps: list[float]
+
+ def prune(self, now: float) -> None:
+ self.stamps = [stamp for stamp in self.stamps if now - stamp < DAY]
+
+
+class Limiter:
+ def __init__(self, spool: Path) -> None:
+ self._dir = spool / "rate"
+ self._dir.mkdir(parents=True, exist_ok=True)
+ self._lock = threading.Lock()
+
+ def _path(self, key: str) -> Path:
+ return self._dir / f"{key}.json"
+
+ def _load(self, key: str) -> _Bucket:
+ path = self._path(key)
+ if not path.exists():
+ return _Bucket([])
+ try:
+ return _Bucket(list(json.loads(path.read_text(encoding="utf-8"))))
+ except (OSError, ValueError):
+ return _Bucket([])
+
+ def _save(self, key: str, bucket: _Bucket) -> None:
+ path = self._path(key)
+ tmp = path.with_suffix(".tmp")
+ tmp.write_text(json.dumps(bucket.stamps), encoding="utf-8")
+ tmp.chmod(0o600)
+ tmp.replace(path)
+
+ def check_and_count(self, key: str, now: float | None = None) -> None:
+ """Считает попытку и бросает RateLimited, если лимит исчерпан.
+
+ Попытка считается ДО доставки: иначе отправитель, добивающийся отказа,
+ получал бы бесплатные повторы.
+ """
+ moment = _now() if now is None else now
+ with self._lock:
+ for name, limit, window in (
+ (key, config.RATE_HOURLY, HOUR),
+ (key, config.RATE_DAILY, DAY),
+ ("_global", config.RATE_GLOBAL_HOURLY, HOUR),
+ ):
+ bucket = self._load(name)
+ bucket.prune(moment)
+ recent = [stamp for stamp in bucket.stamps if moment - stamp < window]
+ if len(recent) >= limit:
+ raise RateLimited(name)
+ for name in (key, "_global"):
+ bucket = self._load(name)
+ bucket.prune(moment)
+ bucket.stamps.append(moment)
+ self._save(name, bucket)
+
+ def purge(self, now: float | None = None) -> int:
+ """Удаляет ключи старше суток. Возвращает число удалённых файлов."""
+ moment = _now() if now is None else now
+ removed = 0
+ with self._lock:
+ for path in self._dir.glob("*.json"):
+ try:
+ stamps = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ stamps = []
+ if not stamps or moment - max(stamps) >= config.RATE_TTL_SECONDS:
+ path.unlink(missing_ok=True)
+ removed += 1
+ return removed
diff --git a/scripts/support-relay/hp_relay/store.py b/scripts/support-relay/hp_relay/store.py
new file mode 100644
index 00000000..c59859a8
--- /dev/null
+++ b/scripts/support-relay/hp_relay/store.py
@@ -0,0 +1,114 @@
+"""Спул отчётов и записи идемпотентности.
+
+Отчёт кладётся на диск ДО попытки доставки: доставка может не удаться, а
+обращение пользователя терять нельзя — оно и есть предмет задачи. Ретеншн
+описан в README и исполняется отдельным таймером, а не этим процессом.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import shutil
+import threading
+import time
+from dataclasses import dataclass
+from hashlib import sha256
+from pathlib import Path
+
+from . import config
+
+
+def new_report_id() -> str:
+ """Короткий непрозрачный идентификатор: показывается пользователю целиком."""
+ return "hpr-" + os.urandom(5).hex()
+
+
+@dataclass(frozen=True)
+class StoredReport:
+ report_id: str
+ directory: Path
+
+
+class Store:
+ def __init__(self, spool: Path) -> None:
+ self.spool = spool
+ self.reports = spool / "reports"
+ self.idem = spool / "idem"
+ for path in (self.reports, self.idem):
+ path.mkdir(parents=True, exist_ok=True)
+ spool.chmod(0o700)
+ self._lock = threading.Lock()
+
+ # --- идемпотентность -------------------------------------------------
+ def _idem_path(self, key: str) -> Path:
+ return self.idem / (sha256(key.encode("utf-8")).hexdigest()[:32] + ".json")
+
+ def lookup(self, key: str, now: float | None = None) -> str | None:
+ moment = time.time() if now is None else now
+ path = self._idem_path(key)
+ if not path.exists():
+ return None
+ try:
+ record = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ return None
+ if moment - float(record.get("created", 0)) >= config.IDEMPOTENCY_TTL_SECONDS:
+ path.unlink(missing_ok=True)
+ return None
+ value = record.get("report_id")
+ return value if isinstance(value, str) else None
+
+ def remember(self, key: str, report_id: str, now: float | None = None) -> None:
+ moment = time.time() if now is None else now
+ path = self._idem_path(key)
+ tmp = path.with_suffix(".tmp")
+ # Запись содержит только идентификатор и время: ни сообщения, ни адреса.
+ tmp.write_text(json.dumps({"report_id": report_id, "created": moment}), encoding="utf-8")
+ tmp.chmod(0o600)
+ tmp.replace(path)
+
+ # --- отчёты ----------------------------------------------------------
+ def save(self, report_id: str, meta: dict, attachment: bytes | None) -> StoredReport:
+ directory = self.reports / time.strftime("%Y-%m", time.gmtime()) / report_id
+ directory.mkdir(parents=True, exist_ok=True)
+ directory.chmod(0o700)
+ meta_path = directory / "report.json"
+ meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
+ meta_path.chmod(0o600)
+ if attachment is not None:
+ blob = directory / f"houseplan-support-{report_id}.json"
+ blob.write_bytes(attachment)
+ blob.chmod(0o600)
+ return StoredReport(report_id=report_id, directory=directory)
+
+ def mark_delivery(self, stored: StoredReport, status: str, detail: str = "") -> None:
+ path = stored.directory / "delivery.json"
+ path.write_text(
+ json.dumps({"status": status, "detail": detail, "at": time.time()}, ensure_ascii=False),
+ encoding="utf-8",
+ )
+ path.chmod(0o600)
+
+ # --- ретеншн ---------------------------------------------------------
+ def purge(self, retention_days: int, now: float | None = None) -> int:
+ """Удаляет отчёты старше срока хранения. Возвращает число удалённых."""
+ moment = time.time() if now is None else now
+ deadline = moment - retention_days * 86400
+ removed = 0
+ with self._lock:
+ for directory in sorted(self.reports.glob("*/*")):
+ if not directory.is_dir():
+ continue
+ if directory.stat().st_mtime < deadline:
+ shutil.rmtree(directory, ignore_errors=True)
+ removed += 1
+ for path in self.idem.glob("*.json"):
+ try:
+ record = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ record = {}
+ if moment - float(record.get("created", 0)) >= config.IDEMPOTENCY_TTL_SECONDS:
+ path.unlink(missing_ok=True)
+ removed += 1
+ return removed
diff --git a/scripts/support-relay/hp_relay/validate.py b/scripts/support-relay/hp_relay/validate.py
new file mode 100644
index 00000000..7890df01
--- /dev/null
+++ b/scripts/support-relay/hp_relay/validate.py
@@ -0,0 +1,175 @@
+"""Проверка содержимого запроса: схема `request`, вложение, тексты.
+
+Три правила, которые здесь удерживаются:
+
+1. Схема закрытая. Неизвестное поле — отказ, а не игнорирование.
+2. Тексты остаются текстами. Ни message, ни contact никогда не попадают в
+ разметку: доставка выводит их как plain text, а управляющие символы
+ вычищаются здесь, чтобы получатель не увидел «пустое» письмо с сюрпризом.
+3. Вложение — ровно тот файл, о котором объявил отправитель: длина и sha256
+ сверяются с заявленными, содержимое разбирается и проверяется по allowlist
+ верхнего уровня (§7.1 ТЗ).
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import re
+import unicodedata
+from dataclasses import dataclass
+
+from . import config
+
+SCHEMA_VERSION = 1
+
+REQUEST_REQUIRED = frozenset({"schema_version", "message", "idempotency_key"})
+REQUEST_OPTIONAL = frozenset({"contact", "versions", "attachment"})
+REQUEST_ALLOWED = REQUEST_REQUIRED | REQUEST_OPTIONAL
+
+VERSIONS_ALLOWED = frozenset({"card", "integration", "home_assistant", "model", "export_schema"})
+ATTACHMENT_META_ALLOWED = frozenset({"size", "sha256"})
+
+PACKAGE_ALLOWED_TOP_LEVEL = frozenset({
+ "format", "version", "versions", "runtime", "revisions",
+ "summary", "validation", "repairs", "plan_backup",
+})
+PACKAGE_FORMAT = "houseplan-support-package"
+PACKAGE_VERSION = 1
+
+IDEMPOTENCY_RE = re.compile(r"\A[A-Za-z0-9_.:-]{8,128}\Z")
+SHA256_RE = re.compile(r"\A[0-9a-f]{64}\Z")
+SAFE_VERSION_RE = re.compile(r"\A[0-9A-Za-z._+-]{1,32}\Z")
+FILENAME_RE = re.compile(r"\Ahouseplan-support-[0-9a-z-]{1,40}\.json\Z")
+
+
+class ValidationError(ValueError):
+ """Публичная причина отказа. Текст безопасно показывать наружу."""
+
+ def __init__(self, code: str, message: str) -> None:
+ super().__init__(message)
+ self.code = code
+
+
+def plain_text(value: str, limit: int, field: str) -> str:
+ """Возвращает текст без управляющих символов и без сюрпризов раскладки.
+
+ Удаляются категории Cc (кроме перевода строки и табуляции) и Cf — в неё
+ входят маркеры двунаправленного письма, которыми в письме можно перевернуть
+ видимый порядок строк, не меняя байтов.
+ """
+ if not isinstance(value, str):
+ raise ValidationError("support_rejected", f"{field} must be a string")
+ normalized = unicodedata.normalize("NFC", value)
+ cleaned = "".join(
+ ch for ch in normalized
+ if ch in "\n\t" or unicodedata.category(ch) not in {"Cc", "Cf"}
+ )
+ cleaned = cleaned.replace("\r\n", "\n").strip()
+ if len(cleaned) > limit:
+ raise ValidationError("support_rejected", f"{field} is too long")
+ return cleaned
+
+
+@dataclass(frozen=True)
+class Request:
+ message: str
+ contact: str
+ versions: dict[str, str]
+ idempotency_key: str
+ attachment_size: int
+ attachment_sha256: str
+
+
+def parse_request(raw: bytes) -> Request:
+ if len(raw) > 128 * 1024:
+ raise ValidationError("support_rejected", "request part is too large")
+ try:
+ payload = json.loads(raw.decode("utf-8"))
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+ raise ValidationError("support_rejected", "request part is not valid JSON") from exc
+ if not isinstance(payload, dict):
+ raise ValidationError("support_rejected", "request part must be an object")
+
+ unknown = set(payload) - REQUEST_ALLOWED
+ if unknown:
+ raise ValidationError("support_rejected", f"unknown request fields: {sorted(unknown)}")
+ missing = REQUEST_REQUIRED - set(payload)
+ if missing:
+ raise ValidationError("support_rejected", f"missing request fields: {sorted(missing)}")
+ if payload["schema_version"] != SCHEMA_VERSION:
+ raise ValidationError("support_rejected", "unsupported schema_version")
+
+ message = plain_text(payload["message"], config.MAX_MESSAGE_CODEPOINTS, "message")
+ if not message:
+ raise ValidationError("support_invalid_message", "message is empty")
+ contact = plain_text(payload.get("contact", ""), config.MAX_CONTACT_CODEPOINTS, "contact")
+
+ key = payload["idempotency_key"]
+ if not isinstance(key, str) or not IDEMPOTENCY_RE.match(key):
+ raise ValidationError("support_rejected", "bad idempotency_key")
+
+ versions_raw = payload.get("versions", {})
+ if not isinstance(versions_raw, dict):
+ raise ValidationError("support_rejected", "versions must be an object")
+ unknown_versions = set(versions_raw) - VERSIONS_ALLOWED
+ if unknown_versions:
+ raise ValidationError("support_rejected", f"unknown versions: {sorted(unknown_versions)}")
+ versions: dict[str, str] = {}
+ for name, value in versions_raw.items():
+ text = str(value)
+ if not SAFE_VERSION_RE.match(text):
+ raise ValidationError("support_rejected", f"bad version value: {name}")
+ versions[name] = text
+
+ meta = payload.get("attachment", {})
+ if not isinstance(meta, dict):
+ raise ValidationError("support_rejected", "attachment meta must be an object")
+ unknown_meta = set(meta) - ATTACHMENT_META_ALLOWED
+ if unknown_meta:
+ raise ValidationError("support_rejected", f"unknown attachment meta: {sorted(unknown_meta)}")
+ size = meta.get("size", 0)
+ digest = meta.get("sha256", "")
+ if not isinstance(size, int) or isinstance(size, bool) or size < 0:
+ raise ValidationError("support_rejected", "attachment size must be a non-negative integer")
+ if size > config.MAX_ATTACHMENT_BYTES:
+ raise ValidationError("support_package_too_large", "attachment is too large")
+ if digest and not (isinstance(digest, str) and SHA256_RE.match(digest)):
+ raise ValidationError("support_rejected", "attachment sha256 must be lowercase hex")
+
+ return Request(
+ message=message,
+ contact=contact,
+ versions=versions,
+ idempotency_key=key,
+ attachment_size=size,
+ attachment_sha256=digest,
+ )
+
+
+def check_attachment(body: bytes, filename: str | None, content_type: str, request: Request) -> None:
+ if content_type != "application/json":
+ raise ValidationError("support_rejected", "attachment must be application/json")
+ if not filename or not FILENAME_RE.match(filename):
+ raise ValidationError("support_rejected", "unexpected attachment filename")
+ if len(body) > config.MAX_ATTACHMENT_BYTES:
+ raise ValidationError("support_package_too_large", "attachment is too large")
+ if request.attachment_size and len(body) != request.attachment_size:
+ raise ValidationError("support_rejected", "attachment size does not match the declared one")
+ if request.attachment_sha256:
+ actual = hashlib.sha256(body).hexdigest()
+ if actual != request.attachment_sha256:
+ raise ValidationError("support_rejected", "attachment hash does not match the declared one")
+ try:
+ package = json.loads(body.decode("utf-8"))
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+ raise ValidationError("support_rejected", "attachment is not valid JSON") from exc
+ if not isinstance(package, dict):
+ raise ValidationError("support_rejected", "attachment must be an object")
+ if package.get("format") != PACKAGE_FORMAT:
+ raise ValidationError("support_rejected", "attachment format is not recognised")
+ if package.get("version") != PACKAGE_VERSION:
+ raise ValidationError("support_rejected", "unsupported attachment version")
+ unknown = set(package) - PACKAGE_ALLOWED_TOP_LEVEL
+ if unknown:
+ raise ValidationError("support_rejected", f"unknown package sections: {sorted(unknown)}")
diff --git a/scripts/support-relay/relay.py b/scripts/support-relay/relay.py
new file mode 100755
index 00000000..b17c4d1f
--- /dev/null
+++ b/scripts/support-relay/relay.py
@@ -0,0 +1,36 @@
+#!/usr/bin/env python3
+"""Точка входа House Plan support relay (#43).
+
+Запуск: `HP_RELAY_SPOOL=… HP_RELAY_MODE=… python3 relay.py`.
+Подкоманда `purge` исполняет ретеншн и выходит — её зовёт systemd-таймер.
+"""
+
+from __future__ import annotations
+
+import logging
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+from hp_relay import app, config, ratelimit, store # noqa: E402
+
+
+def main(argv: list[str]) -> int:
+ logging.basicConfig(
+ level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s",
+ )
+ cfg = config.load()
+ if argv[1:2] == ["purge"]:
+ reports = store.Store(cfg.spool).purge(cfg.retention_days)
+ keys = ratelimit.Limiter(cfg.spool).purge()
+ logging.getLogger("hp-support-relay").info(
+ "purged reports=%s rate/idem=%s retention_days=%s", reports, keys, cfg.retention_days,
+ )
+ return 0
+ app.serve(cfg)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv))
diff --git a/scripts/support-relay/tests/test_relay.py b/scripts/support-relay/tests/test_relay.py
new file mode 100644
index 00000000..7b015ba0
--- /dev/null
+++ b/scripts/support-relay/tests/test_relay.py
@@ -0,0 +1,389 @@
+"""Тесты приёмщика (§14.3 ТЗ 043). Запуск: python3 -m unittest discover -s tests
+
+Проверки написаны так, чтобы каждая умела падать: рядом с положительным
+утверждением стоит отрицательное — «а вот на таком входе обязан быть отказ».
+"""
+
+from __future__ import annotations
+
+import io
+import json
+import logging
+import sys
+import threading
+import time
+import unittest
+import urllib.error
+import urllib.request
+from hashlib import sha256
+from http.server import ThreadingHTTPServer
+from pathlib import Path
+from tempfile import TemporaryDirectory
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from hp_relay import app, config, delivery, ratelimit, store, validate # noqa: E402
+
+PACKAGE = {
+ "format": "houseplan-support-package",
+ "version": 1,
+ "versions": {"card": "1.70.0", "integration": "1.70.0", "home_assistant": "2026.8.0",
+ "model": 9, "export_schema": 1},
+ "runtime": {"browser_family": "chromium"},
+ "revisions": {"config": 17, "layout": 24},
+ "summary": {}, "validation": {}, "repairs": [],
+ "plan_backup": {"config": {}, "layout": {}},
+}
+
+
+def package_bytes(extra: dict | None = None) -> bytes:
+ payload = dict(PACKAGE)
+ if extra:
+ payload.update(extra)
+ return (json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n").encode("utf-8")
+
+
+def build_body(request: dict, attachment: bytes | None, *, filename: str | None = None,
+ attachment_type: str = "application/json",
+ extra_part: tuple[str, bytes] | None = None) -> tuple[str, bytes]:
+ boundary = "----hp-test-boundary"
+ chunks = [
+ f'--{boundary}\r\nContent-Disposition: form-data; name="request"\r\n'
+ f"Content-Type: application/json\r\n\r\n".encode("utf-8"),
+ json.dumps(request, ensure_ascii=False).encode("utf-8"),
+ b"\r\n",
+ ]
+ if attachment is not None:
+ name = filename or "houseplan-support-test.json"
+ chunks += [
+ f'--{boundary}\r\nContent-Disposition: form-data; name="attachment"; filename="{name}"\r\n'
+ f"Content-Type: {attachment_type}\r\n\r\n".encode("utf-8"),
+ attachment,
+ b"\r\n",
+ ]
+ if extra_part is not None:
+ part_name, part_body = extra_part
+ chunks += [
+ f'--{boundary}\r\nContent-Disposition: form-data; name="{part_name}"\r\n\r\n'
+ .encode("utf-8"),
+ part_body,
+ b"\r\n",
+ ]
+ chunks.append(f"--{boundary}--\r\n".encode("utf-8"))
+ return f"multipart/form-data; boundary={boundary}", b"".join(chunks)
+
+
+def request_json(blob: bytes | None, *, key: str = "idem-key-0001", message: str = "не работает",
+ contact: str = "", **overrides) -> dict:
+ payload = {
+ "schema_version": 1,
+ "message": message,
+ "idempotency_key": key,
+ "versions": {"card": "1.70.0"},
+ }
+ if contact:
+ payload["contact"] = contact
+ if blob is not None:
+ payload["attachment"] = {"size": len(blob), "sha256": sha256(blob).hexdigest()}
+ payload.update(overrides)
+ return payload
+
+
+class RecordingDelivery:
+ """Поддельный провайдер: запоминает ровно то, что ему передали."""
+
+ def __init__(self) -> None:
+ self.calls: list[tuple[str, dict, bytes | None]] = []
+ self.ok = True
+
+ def send(self, report_id, meta, attachment):
+ self.calls.append((report_id, meta, attachment))
+ return delivery.Result(self.ok, "recorded")
+
+
+class captured_log:
+ """Собирает всё, что уходит в журнал relay, — включая транспортный слой."""
+
+ def __enter__(self):
+ self._stream = io.StringIO()
+ self._handler = logging.StreamHandler(self._stream)
+ self._logger = logging.getLogger("hp-support-relay")
+ self._logger.addHandler(self._handler)
+ self._previous = self._logger.level
+ self._logger.setLevel(logging.INFO)
+ return self._stream
+
+ def __exit__(self, *exc):
+ self._logger.removeHandler(self._handler)
+ self._logger.setLevel(self._previous)
+ return False
+
+
+class RelayTestCase(unittest.TestCase):
+ def setUp(self) -> None:
+ self._tmp = TemporaryDirectory()
+ self.spool = Path(self._tmp.name) / "spool"
+ self.cfg = config.load({"HP_RELAY_SPOOL": str(self.spool), "HP_RELAY_MODE": "discard"})
+ self.service = app.Service(self.cfg)
+ self.provider = RecordingDelivery()
+ self.service.delivery = self.provider
+
+ def tearDown(self) -> None:
+ self._tmp.cleanup()
+
+ def post(self, request: dict, attachment: bytes | None, source: str = "203.0.113.1", **kwargs):
+ content_type, body = build_body(request, attachment, **kwargs)
+ return self.service.handle_report(content_type, body, source)
+
+ # --- приём ---------------------------------------------------------
+ def test_accepts_a_well_formed_report(self):
+ blob = package_bytes()
+ status, payload = self.post(request_json(blob), blob)
+ self.assertEqual(status, 200, payload)
+ self.assertTrue(payload["report_id"].startswith("hpr-"))
+ self.assertEqual(len(self.provider.calls), 1)
+
+ def test_delivered_attachment_is_byte_identical(self):
+ blob = package_bytes({"revisions": {"config": 3, "layout": 4}})
+ self.post(request_json(blob), blob)
+ _, _, delivered = self.provider.calls[0]
+ self.assertEqual(delivered, blob)
+
+ def test_report_and_attachment_land_in_the_spool(self):
+ blob = package_bytes()
+ _, payload = self.post(request_json(blob), blob)
+ found = list(self.spool.glob(f"reports/*/{payload['report_id']}/*.json"))
+ names = sorted(path.name for path in found)
+ self.assertIn("report.json", names)
+ self.assertIn(f"houseplan-support-{payload['report_id']}.json", names)
+
+ # --- отказы --------------------------------------------------------
+ def test_unknown_part_is_rejected(self):
+ blob = package_bytes()
+ status, payload = self.post(request_json(blob), blob, extra_part=("evil", b"x"))
+ self.assertEqual((status, payload["error"]), (400, "support_rejected"))
+
+ def test_unknown_request_field_is_rejected(self):
+ status, payload = self.post(request_json(None, tracking_pixel="yes"), None)
+ self.assertEqual((status, payload["error"]), (400, "support_rejected"))
+
+ def test_wrong_hash_is_rejected(self):
+ blob = package_bytes()
+ request = request_json(blob)
+ request["attachment"]["sha256"] = "0" * 64
+ status, payload = self.post(request, blob)
+ self.assertEqual((status, payload["error"]), (400, "support_rejected"))
+
+ def test_wrong_size_is_rejected(self):
+ blob = package_bytes()
+ request = request_json(blob)
+ request["attachment"]["size"] = len(blob) + 1
+ status, payload = self.post(request, blob)
+ self.assertEqual((status, payload["error"]), (400, "support_rejected"))
+
+ def test_foreign_package_format_is_rejected(self):
+ blob = (json.dumps({"format": "something-else", "version": 1}) + "\n").encode("utf-8")
+ status, payload = self.post(request_json(blob), blob)
+ self.assertEqual((status, payload["error"]), (400, "support_rejected"))
+
+ def test_unknown_package_section_is_rejected(self):
+ blob = package_bytes({"exfiltrated": {"token": "secret"}})
+ status, payload = self.post(request_json(blob), blob)
+ self.assertEqual((status, payload["error"]), (400, "support_rejected"))
+
+ def test_oversized_attachment_is_rejected(self):
+ blob = package_bytes({"summary": {"pad": "x" * (config.MAX_ATTACHMENT_BYTES + 16)}})
+ status, payload = self.post(request_json(blob), blob)
+ self.assertEqual((status, payload["error"]), (413, "support_package_too_large"))
+
+ def test_empty_message_is_rejected(self):
+ status, payload = self.post(request_json(None, message=" "), None)
+ self.assertEqual((status, payload["error"]), (400, "support_invalid_message"))
+
+ def test_declared_attachment_without_body_is_rejected(self):
+ blob = package_bytes()
+ status, payload = self.post(request_json(blob), None)
+ self.assertEqual((status, payload["error"]), (400, "support_rejected"))
+
+ def test_disabled_relay_refuses_uniformly(self):
+ cfg = config.load({"HP_RELAY_SPOOL": str(self.spool), "HP_RELAY_ENABLED": "0"})
+ service = app.Service(cfg)
+ service.delivery = RecordingDelivery()
+ content_type, body = build_body(request_json(None), None)
+ status, payload = service.handle_report(content_type, body, "203.0.113.1")
+ self.assertEqual((status, payload["error"]), (503, "support_unavailable"))
+ self.assertEqual(service.delivery.calls, [])
+
+ def test_failed_delivery_does_not_promise_success(self):
+ self.provider.ok = False
+ blob = package_bytes()
+ status, payload = self.post(request_json(blob), blob)
+ self.assertEqual((status, payload["error"]), (503, "support_unavailable"))
+ # Обращение всё равно сохранено — терять его нельзя.
+ self.assertTrue(list(self.spool.glob("reports/*/*/report.json")))
+
+ # --- тексты --------------------------------------------------------
+ def test_markup_and_controls_stay_literal_text(self):
+ nasty = "bold\r\nSubject: injectedgnitpircs"
+ blob = package_bytes()
+ self.post(request_json(blob, message=nasty), blob)
+ _, meta, _ = self.provider.calls[0]
+ self.assertIn("bold", meta["message"]) # разметка не исполняется, а видна
+ self.assertNotIn("", meta["message"]) # bidi-переворот вычищен
+ self.assertNotIn("", meta["message"]) # управляющий символ вычищен
+ self.assertNotIn("\r", meta["message"]) # склейка заголовков невозможна
+
+ def test_summary_text_carries_no_markup_mode(self):
+ text = delivery.summary_text("hpr-1", {"message": "x", "versions": {"card": "1.70.0"}})
+ self.assertIn("x", text)
+
+ # --- лимиты и идемпотентность ---------------------------------------
+ def test_hourly_limit_stops_the_sixth_attempt(self):
+ blob = package_bytes()
+ for index in range(config.RATE_HOURLY):
+ status, _ = self.post(request_json(blob, key=f"idem-key-{index:04d}"), blob)
+ self.assertEqual(status, 200)
+ status, payload = self.post(request_json(blob, key="idem-key-9999"), blob)
+ self.assertEqual((status, payload["error"]), (429, "support_rate_limited"))
+
+ def test_other_source_is_not_limited_by_the_first(self):
+ blob = package_bytes()
+ for index in range(config.RATE_HOURLY):
+ self.post(request_json(blob, key=f"idem-key-{index:04d}"), blob)
+ status, _ = self.post(request_json(blob, key="idem-key-8888"), blob, source="198.51.100.7")
+ self.assertEqual(status, 200)
+
+ def test_replay_returns_the_original_id_without_spending_the_limit(self):
+ blob = package_bytes()
+ _, first = self.post(request_json(blob), blob)
+ _, second = self.post(request_json(blob), blob)
+ self.assertEqual(second["report_id"], first["report_id"])
+ self.assertTrue(second["duplicate"])
+ self.assertEqual(len(self.provider.calls), 1) # второй раз не доставляли
+ for index in range(config.RATE_HOURLY - 1):
+ status, _ = self.post(request_json(blob, key=f"idem-key-{index:04d}"), blob)
+ self.assertEqual(status, 200) # повтор лимит не потратил
+
+ def test_source_key_hides_the_address_and_rotates_daily(self):
+ secret = ratelimit.node_secret(self.spool)
+ address = "203.0.113.42"
+ today = ratelimit.source_key(secret, address)
+ tomorrow = ratelimit.source_key(secret, address, now=time.time() + 86400)
+ self.assertNotIn(address, today)
+ self.assertNotEqual(today, tomorrow)
+
+ # --- журналы --------------------------------------------------------
+ def test_logs_do_not_carry_the_message(self):
+ with captured_log() as stream:
+ blob = package_bytes()
+ self.post(request_json(blob, message="секретная жалоба"), blob, source="203.0.113.77")
+ self.assertNotIn("секретная жалоба", stream.getvalue())
+
+ # --- ретеншн --------------------------------------------------------
+ def test_purge_deletes_old_reports_and_keeps_fresh_ones(self):
+ blob = package_bytes()
+ _, fresh = self.post(request_json(blob), blob)
+ _, stale = self.post(request_json(blob, key="idem-key-0002"), blob)
+ stale_dir = next(self.spool.glob(f"reports/*/{stale['report_id']}"))
+ old = time.time() - 31 * 86400
+ import os
+ os.utime(stale_dir, (old, old))
+ removed = self.service.store.purge(self.cfg.retention_days)
+ self.assertGreaterEqual(removed, 1)
+ self.assertFalse(stale_dir.exists())
+ self.assertTrue(next(self.spool.glob(f"reports/*/{fresh['report_id']}")).exists())
+
+ def test_idempotency_record_expires_after_a_day(self):
+ self.service.store.remember("idem-key-old1", "hpr-old", now=time.time() - 25 * 3600)
+ self.assertIsNone(self.service.store.lookup("idem-key-old1"))
+
+ def test_rate_keys_expire_after_a_day(self):
+ limiter = ratelimit.Limiter(self.spool)
+ key = ratelimit.source_key(self.service.secret, "203.0.113.9")
+ limiter.check_and_count(key, now=time.time() - 25 * 3600)
+ self.assertEqual(limiter.purge(), 2) # ключ источника и глобальный
+
+ def test_retention_defaults_match_the_disclosed_policy(self):
+ self.assertEqual(self.cfg.retention_days, 30)
+ self.assertEqual(config.IDEMPOTENCY_TTL_SECONDS, 24 * 3600)
+ self.assertEqual(config.RATE_TTL_SECONDS, 24 * 3600)
+
+
+class HttpSurfaceTestCase(unittest.TestCase):
+ """Проверки, которые живут только на транспортном уровне."""
+
+ def setUp(self) -> None:
+ self._tmp = TemporaryDirectory()
+ cfg = config.load({"HP_RELAY_SPOOL": str(Path(self._tmp.name) / "s"), "HP_RELAY_PORT": "0"})
+ self.service = app.Service(cfg)
+ self.service.delivery = RecordingDelivery()
+ self.server = ThreadingHTTPServer(("127.0.0.1", 0), app.make_handler(self.service))
+ self.port = self.server.server_address[1]
+ self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
+ self.thread.start()
+
+ def tearDown(self) -> None:
+ self.server.shutdown()
+ self.server.server_close()
+ self._tmp.cleanup()
+
+ def call(self, method: str, path: str, body: bytes | None = None,
+ content_type: str = "application/octet-stream", headers: dict | None = None):
+ request = urllib.request.Request(
+ f"http://127.0.0.1:{self.port}{path}", data=body, method=method,
+ )
+ if body is not None:
+ request.add_header("Content-Type", content_type)
+ for name, value in (headers or {}).items():
+ request.add_header(name, value)
+ try:
+ with urllib.request.urlopen(request, timeout=10) as response:
+ return response.status, json.loads(response.read())
+ except urllib.error.HTTPError as error:
+ return error.code, json.loads(error.read())
+
+ def test_health_reports_mode_without_secrets(self):
+ status, payload = self.call("GET", "/health")
+ self.assertEqual(status, 200)
+ self.assertEqual(payload["status"], "ok")
+ self.assertNotIn("telegram_token", json.dumps(payload))
+
+ def test_unknown_route_is_not_described(self):
+ status, payload = self.call("GET", "/admin")
+ self.assertEqual((status, payload["error"]), (404, "not_found"))
+
+ def test_oversized_request_is_refused_before_reading(self):
+ content_type, body = build_body(request_json(None), None)
+ status, payload = self.call(
+ "POST", "/v1/reports", body, content_type,
+ headers={"Content-Length": str(config.MAX_REQUEST_BYTES + 1)},
+ )
+ self.assertEqual((status, payload["error"]), (413, "support_package_too_large"))
+
+ def test_transport_log_carries_no_client_address(self):
+ """Адрес пишет штатный логгер BaseHTTPRequestHandler — проверяем ЕГО.
+
+ Сервис-уровневый тест сюда не достаёт: `log_message` вызывается из
+ `send_response`, то есть только при настоящем HTTP-запросе.
+ """
+ blob = package_bytes()
+ content_type, body = build_body(request_json(blob), blob)
+ with captured_log() as stream:
+ status, _ = self.call("POST", "/v1/reports", body, content_type,
+ headers={"X-Forwarded-For": "198.51.100.5"})
+ self.assertEqual(status, 200)
+ written = stream.getvalue()
+ self.assertIn("POST /v1/reports", written) # журнал не пуст — проверка настоящая
+ self.assertNotIn("127.0.0.1", written) # адрес соединения
+ self.assertNotIn("198.51.100.5", written) # адрес из заголовка
+
+ def test_forwarded_for_is_used_as_the_source(self):
+ blob = package_bytes()
+ content_type, body = build_body(request_json(blob), blob)
+ status, _ = self.call("POST", "/v1/reports", body, content_type,
+ headers={"X-Forwarded-For": "198.51.100.5"})
+ self.assertEqual(status, 200)
+
+
+if __name__ == "__main__":
+ unittest.main()