Files
houseplan-card/scripts/support-relay/hp_relay/app.py

198 lines
9.4 KiB
Python

"""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)
# Путь добавляется только для доставки: в самом отчёте он избыточен, а в
# сводке — единственный способ найти пакет, если канал его не несёт.
delivered_meta = {**meta, "spool_path": str(stored.directory)}
result = self.delivery.send(report_id, delivered_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:
# ПОСЛЕДНИЙ элемент, а не первый. Первый — тот, что прислал
# клиент, и подделать его может кто угодно; последний
# проставлен ближайшим звеном, то есть нашим же Caddy.
# Caddy настроен перезаписывать заголовок целиком, но код не
# обязан полагаться на чужой конфиг: цена ошибки здесь —
# обход частотного лимита сменой одной строки в запросе.
return forwarded.split(",")[-1].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()