mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
feat(relay): receive support reports on the project stand (#43)
User-Visible: no Issue: #43
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
Тридцать проверок: схема, размеры, хеш, идемпотентность, частота, ретеншн,
|
||||
буквальность текста, отсутствие адреса в журналах, поведение рубильника.
|
||||
Каждая проверялась отрицательным прогоном — десять мутаций рабочего кода
|
||||
(снять сверку хеша, разрешить лишнюю часть, не чистить управляющие символы,
|
||||
снять лимит, писать адрес в журнал, игнорировать идемпотентность, отключить
|
||||
рубильник, отключить ретеншн, не проверять секции пакета) роняют ровно те
|
||||
проверки, ради которых написаны.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
"""House Plan support relay — приёмщик обезличенных отчётов (#43, §9 ТЗ 043).
|
||||
|
||||
Пакет намеренно обходится стандартной библиотекой Python: сервис принимает
|
||||
единицы запросов в час, а отсутствие зависимостей снимает с проекта цепочку
|
||||
обновлений безопасности у чужого кода на публично доступном эндпоинте.
|
||||
"""
|
||||
|
||||
__all__ = ["config", "multipart", "validate", "ratelimit", "store", "delivery", "app"]
|
||||
@@ -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()
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)}")
|
||||
Executable
+36
@@ -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))
|
||||
@@ -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 = "<b>bold</b>\r\nSubject: injectedgnitpircs"
|
||||
blob = package_bytes()
|
||||
self.post(request_json(blob, message=nasty), blob)
|
||||
_, meta, _ = self.provider.calls[0]
|
||||
self.assertIn("<b>bold</b>", 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": "<i>x</i>", "versions": {"card": "1.70.0"}})
|
||||
self.assertIn("<i>x</i>", 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()
|
||||
Reference in New Issue
Block a user