feat(relay): receive support reports on the project stand (#43)

User-Visible: no
Issue: #43
This commit is contained in:
Codex
2026-09-02 00:05:36 +00:00
committed by claude[bot]
parent 4bdd266fbb
commit f36ded7fc2
16 changed files with 1593 additions and 0 deletions
+148
View File
@@ -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
}
+14
View File
@@ -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"]
+188
View File
@@ -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()
+72
View File
@@ -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"},
)
+118
View File
@@ -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()
+130
View File
@@ -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
+123
View File
@@ -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
+114
View File
@@ -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
+175
View File
@@ -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)}")
+36
View File
@@ -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))
+389
View File
@@ -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: injected‮gnitpircs"
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()