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: injected‮gnitpircs" + 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()