From ca86e795012d8daf89d34aab8f10971829bf8ab2 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 2 Sep 2026 01:03:57 +0300 Subject: [PATCH] feat(relay): deliver through the maintainer's Home Assistant webhook (#43) User-Visible: no Issue: #43 --- docs/specs/043-private-support-report.md | 18 +++- scripts/support-relay/README.md | 39 +++++-- scripts/support-relay/deploy/env.example | 5 + scripts/support-relay/hp_relay/app.py | 5 +- scripts/support-relay/hp_relay/config.py | 13 ++- scripts/support-relay/hp_relay/delivery.py | 119 ++++++++++++++++++--- scripts/support-relay/tests/test_relay.py | 73 +++++++++++++ 7 files changed, 241 insertions(+), 31 deletions(-) diff --git a/docs/specs/043-private-support-report.md b/docs/specs/043-private-support-report.md index d72a1b6c..4164e05c 100644 --- a/docs/specs/043-private-support-report.md +++ b/docs/specs/043-private-support-report.md @@ -419,6 +419,18 @@ report is written to the relay spool **before** delivery is attempted, so a fail delivery costs a promise, not the user's request. No public issue is created and no e-mail provider participates. +**Two delivery channels, chosen by deployment.** `telegram` posts to the Bot API +directly. `ha_webhook` posts the summary to a private webhook of the maintainer's +own Home Assistant, which performs the last mile. The second channel exists +because the project node runs at a Russian hosting provider where every +`api.telegram.org` address is unreachable — direct delivery failed with «Network +is unreachable», not with a provider error. On this channel the package stays in +the relay spool and the summary names its path: the webhook carries text only, +so the address (which is its own access key) stays cheap to rotate, and the +geometry of a stranger's home does not travel through a messenger. Either +channel keeps the same contract: nothing is promised to the user until delivery +is confirmed. + Production URL is an immutable backend constant supplied after relay deployment. No placeholder, localhost URL or configurable arbitrary endpoint may pass release gates. A staging relay and exact production URL are dependencies of S5/implementation; @@ -445,7 +457,8 @@ and reviewed as a security trade-off; a hard-coded shared key is explicitly forb - Relay stores the accepted report (message, contact, safe metadata and the attachment) in a spool readable only by its own system user, and a daily timer - deletes everything older than **30 days**; maintainers may delete earlier. + deletes everything older than **30 days**; maintainers may delete earlier. On + the `ha_webhook` channel the attachment never leaves that spool. Storage is the price of the chosen channel: Telegram delivery leaves no archive the project controls, so the deletion rule has to live where the project can enforce and prove it. @@ -609,7 +622,8 @@ available and recorded in #43: 1. project-controlled relay deployment target and production HTTPS hostname; 2. private maintainer channel and its delivery credentials stored only in the - relay environment, as a path to a secret file rather than a value; + relay environment, as a path to a secret file rather than a value — for the + `ha_webhook` channel the webhook address is that credential; 3. configured and running 30-day deletion of the relay spool; 4. staging endpoint usable by CI without production delivery; 5. named maintainer responsible for relay alerts/disable switch. diff --git a/scripts/support-relay/README.md b/scripts/support-relay/README.md index 2db9bcc2..27a85bd8 100644 --- a/scripts/support-relay/README.md +++ b/scripts/support-relay/README.md @@ -49,9 +49,20 @@ ## Доставка -Канал — Telegram (решение владельца 2026-09-01): сводка сообщением, пакет — -документом. `parse_mode` не используется намеренно, текст пользователя -отображается буквально. Ответ провайдера наружу не отражается: клиент получает +Каналов два, `HP_RELAY_CHANNEL`. + +**`ha_webhook` — рабочий канал стенда.** Relay отдаёт сводку вебхуку Home +Assistant владельца, а последнюю милю до Telegram делает уже он. Так вышло не от +хорошей жизни: узел стоит у российского хостера, откуда `api.telegram.org` +недоступен по всем адресам — прямая доставка падала с «Network is unreachable». +Побочный выигрыш: пакет остаётся в спуле стенда и в мессенджер не уходит, то +есть геометрия чужого дома не гуляет по чатам. Сводка называет путь к пакету. + +**`telegram` — прямой канал.** Сводка сообщением, пакет документом. Годится для +узла, откуда Telegram доступен. `parse_mode` не используется намеренно: текст +пользователя отображается буквально. + +Ответ провайдера наружу не отражается ни в одном из каналов: клиент получает только `report_id` либо стабильный код отказа. Отчёт кладётся на диск **до** попытки доставки. Если доставка не удалась, @@ -73,9 +84,10 @@ ## Переменные окружения -См. `deploy/env.example`. Секрет доставки задаётся **путём к файлу** -(`HP_RELAY_TELEGRAM_TOKEN_FILE`), а не значением: так он не виден ни в -`systemctl show`, ни в `ps`, ни в дампе окружения. +См. `deploy/env.example`. Секреты доставки задаются **путём к файлу** +(`HP_RELAY_TELEGRAM_TOKEN_FILE`, `HP_RELAY_WEBHOOK_URL_FILE`), а не значением: +так они не видны ни в `systemctl show`, ни в `ps`, ни в дампе окружения. Адрес +вебхука — такой же секрет, как токен: он сам себе ключ доступа. ## Установка @@ -126,13 +138,19 @@ sudo systemctl restart hp-support-relay@prod важнее, чем кажется: принять и потерять — хуже, чем честно отказать, потому что пользователь считает обращение отправленным. -**Сменить токен доставки** +**Сменить секрет доставки** ```bash +# прямой Telegram sudo install -m 0400 -o hprelay -g hprelay /dev/stdin /etc/hp-support-relay/telegram.token +# канал через Home Assistant (адрес вебхука целиком) +sudo install -m 0400 -o hprelay -g hprelay /dev/stdin /etc/hp-support-relay/webhook.url sudo systemctl restart hp-support-relay@prod ``` +Ротация вебхука — это ещё и правка `webhook_id` в автоматизации Home Assistant +«House Plan: приёмщик обратной связи → личка»: адрес и есть ключ. + **Прочитать обращение** ```bash @@ -151,11 +169,12 @@ sudo -u hprelay cat /var/lib/hp-support-relay/prod/reports/2026-09/hpr-…/repor cd scripts/support-relay && python3 -m unittest discover -s tests -q ``` -Тридцать одна проверка: схема, размеры, хеш, идемпотентность, частота, +Тридцать пять проверок: схема, размеры, хеш, идемпотентность, частота, ретеншн, буквальность текста, отсутствие адреса в журналах, невозможность выбрать себе корзину лимита подделкой заголовка, поведение рубильника. -Каждая проверялась отрицательным прогоном — одиннадцать мутаций рабочего кода +Каждая проверялась отрицательным прогоном — тринадцать мутаций рабочего кода (снять сверку хеша, разрешить лишнюю часть, не чистить управляющие символы, снять лимит, писать адрес в журнал, игнорировать идемпотентность, отключить рубильник, отключить ретеншн, не проверять секции пакета, брать первый элемент -`X-Forwarded-For`) роняют ровно те проверки, ради которых написаны. +`X-Forwarded-For`, подменить `source` в вебхуке, приложить пакет к вебхуку) +роняют ровно те проверки, ради которых написаны. diff --git a/scripts/support-relay/deploy/env.example b/scripts/support-relay/deploy/env.example index b194338d..77a12045 100644 --- a/scripts/support-relay/deploy/env.example +++ b/scripts/support-relay/deploy/env.example @@ -3,6 +3,9 @@ HP_RELAY_PORT=8130 HP_RELAY_SPOOL=/var/lib/hp-support-relay/prod # deliver — отправлять мейнтейнеру; discard — принимать и складывать молча. HP_RELAY_MODE=deliver +# ha_webhook — последняя миля через Home Assistant владельца (нужен, когда с узла +# api.telegram.org недоступен); telegram — прямая отправка. +HP_RELAY_CHANNEL=ha_webhook # Рубильник: 0 переводит эндпоинт в единообразный 503, ничего не принимая. HP_RELAY_ENABLED=1 HP_RELAY_RETENTION_DAYS=30 @@ -10,5 +13,7 @@ HP_RELAY_RETENTION_DAYS=30 # владелец hprelay). Так он не попадает ни в `systemctl show`, ни в `ps`. HP_RELAY_TELEGRAM_TOKEN_FILE=/etc/hp-support-relay/telegram.token HP_RELAY_TELEGRAM_CHAT_ID= +# Адрес вебхука — такой же секрет, как токен: он сам себе ключ доступа. +HP_RELAY_WEBHOOK_URL_FILE=/etc/hp-support-relay/webhook.url # Источник берётся из X-Forwarded-For, потому что перед сервисом стоит Caddy. HP_RELAY_TRUSTED_PROXY=1 diff --git a/scripts/support-relay/hp_relay/app.py b/scripts/support-relay/hp_relay/app.py index 86a17e8c..a5c7170c 100644 --- a/scripts/support-relay/hp_relay/app.py +++ b/scripts/support-relay/hp_relay/app.py @@ -105,7 +105,10 @@ class Service: "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) + # Путь добавляется только для доставки: в самом отчёте он избыточен, а в + # сводке — единственный способ найти пакет, если канал его не несёт. + 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) diff --git a/scripts/support-relay/hp_relay/config.py b/scripts/support-relay/hp_relay/config.py index 2c57e513..6811af8b 100644 --- a/scripts/support-relay/hp_relay/config.py +++ b/scripts/support-relay/hp_relay/config.py @@ -32,15 +32,21 @@ class Config: port: int spool: Path mode: str # 'deliver' | 'discard' + channel: str # 'telegram' | 'ha_webhook' enabled: bool retention_days: int telegram_token: str # пусто = доставка выключена telegram_chat_id: str + webhook_url: str # адрес вебхука Home Assistant (секрет: он же ключ доступа) 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) + if self.mode != "deliver": + return False + if self.channel == "ha_webhook": + return bool(self.webhook_url) + return bool(self.telegram_token and self.telegram_chat_id) def _read_secret(path_value: str) -> str: @@ -57,16 +63,21 @@ def load(env: dict[str, str] | None = None) -> Config: 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'") + channel = env.get("HP_RELAY_CHANNEL", "telegram").strip().lower() + if channel not in {"telegram", "ha_webhook"}: + raise ValueError("HP_RELAY_CHANNEL must be 'telegram' or 'ha_webhook'") 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, + channel=channel, # Рубильник §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(), + webhook_url=_read_secret(env.get("HP_RELAY_WEBHOOK_URL_FILE", "")), trusted_proxy=env.get("HP_RELAY_TRUSTED_PROXY", "1").strip() not in {"0", "false", "no"}, ) diff --git a/scripts/support-relay/hp_relay/delivery.py b/scripts/support-relay/hp_relay/delivery.py index 83168d42..2ddaf9d7 100644 --- a/scripts/support-relay/hp_relay/delivery.py +++ b/scripts/support-relay/hp_relay/delivery.py @@ -1,7 +1,13 @@ """Доставка отчёта мейнтейнеру. -Канал доставки — Telegram (решение владельца 2026-09-01): сообщение с -идентификатором и безопасными версиями плюс сам пакет отдельным документом. +Каналов два, и второй появился не от любви к вариантам. Прямой Telegram +(`telegram`) — как задумывалось: сводка сообщением, пакет документом. Но узел +проекта стоит у российского хостера, откуда `api.telegram.org` недоступен по +всем адресам, — доставка молча падала с «Network is unreachable». Поэтому +основной канал стенда — `ha_webhook`: relay отдаёт сводку вебхуку Home Assistant +владельца, а последнюю милю до Telegram делает уже он, из сети, где Telegram +доступен. Пакет при этом остаётся в спуле стенда и в мессенджер не уходит — +геометрия чужого дома по чатам не гуляет. Разметка НЕ используется намеренно: без `parse_mode` Telegram показывает текст буквально, поэтому сообщение пользователя не может ничего разметить, подделать или скрыть. @@ -12,8 +18,10 @@ from __future__ import annotations +import http.client import json import os +import socket import urllib.error import urllib.request from dataclasses import dataclass @@ -28,16 +36,63 @@ class Result: detail: str +def _connect_ipv4(address, timeout, source_address=None) -> socket.socket: + """Соединение строго по IPv4. + + Узел проекта отдаёт для `api.telegram.org` и AAAA, и A, но связности по + IPv6 у него нет: обычный `urlopen` выбирал IPv6 и молча висел до таймаута — + доставка падала с «status 0», хотя сеть была в порядке. Явный выбор + семейства делает поведение независимым от порядка, в котором резолвер + вернул адреса. + """ + host, port = address + last: OSError | None = None + for family, kind, proto, _canon, sockaddr in socket.getaddrinfo( + host, port, socket.AF_INET, socket.SOCK_STREAM, + ): + sock = socket.socket(family, kind, proto) + try: + sock.settimeout(timeout) + if source_address: + sock.bind(source_address) + sock.connect(sockaddr) + return sock + except OSError as error: + last = error + sock.close() + raise last or OSError(f"no IPv4 address for {host}") + + +class _IPv4HTTPSConnection(http.client.HTTPSConnection): + def connect(self) -> None: + self.sock = _connect_ipv4((self.host, self.port), self.timeout, self.source_address) + if self._tunnel_host: + self._tunnel() + self.sock = self._context.wrap_socket( + self.sock, server_hostname=self._tunnel_host or self.host, + ) + + +class _IPv4HTTPSHandler(urllib.request.HTTPSHandler): + def https_open(self, req): # noqa: D102 - контракт базового класса + return self.do_open(_IPv4HTTPSConnection, req, context=self._context) + + 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] + openers = [urllib.request.build_opener(_IPv4HTTPSHandler()), urllib.request.build_opener()] + last_error = b"" + for opener in openers: + try: + with opener.open(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: + last_error = str(error).encode("utf-8", "replace")[:4096] + return 0, last_error def _multipart(fields: dict[str, str], filename: str, blob: bytes) -> tuple[bytes, str]: @@ -59,11 +114,16 @@ def _multipart(fields: dict[str, str], filename: str, blob: bytes) -> tuple[byte def summary_text(report_id: str, meta: dict) -> str: versions = meta.get("versions") or {} + attachment = "—" + if meta.get("attachment_size"): + attachment = f"{meta['attachment_size']} B" + if meta.get("spool_path"): + attachment += f" — {meta['spool_path']}" 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 "—"), + "attachment: " + attachment, "contact: " + (meta.get("contact") or "—"), "", "message:", @@ -89,9 +149,9 @@ class TelegramDelivery: "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") + status, detail = _post(f"{API}/bot{self._token}/sendMessage", body, "application/json") if status != 200: - return Result(False, f"sendMessage status {status}") + return Result(False, f"sendMessage status {status}: {detail.decode('utf-8', 'replace')[:200]}") if attachment is None: return Result(True, "message only") payload, content_type = _multipart( @@ -99,12 +159,35 @@ class TelegramDelivery: f"houseplan-support-{report_id}.json", attachment, ) - status, _ = _post(f"{API}/bot{self._token}/sendDocument", payload, content_type) + status, detail = _post(f"{API}/bot{self._token}/sendDocument", payload, content_type) if status != 200: - return Result(False, f"sendDocument status {status}") + return Result(False, f"sendDocument status {status}: {detail.decode('utf-8', 'replace')[:200]}") return Result(True, "message and document") +class HaWebhookDelivery: + """Последняя миля через Home Assistant владельца. + + Вебхук отдаёт только текст: адрес вебхука — сам себе ключ доступа, и чем + меньше через него проходит, тем дешевле его ротация. Вложение остаётся на + стенде, а сводка называет путь к нему. + """ + + def __init__(self, url: str) -> None: + self._url = url + + def send(self, report_id: str, meta: dict, attachment: bytes | None) -> Result: + body = json.dumps({ + "source": "houseplan-support-relay", + "report_id": report_id, + "text": summary_text(report_id, meta), + }, ensure_ascii=False).encode("utf-8") + status, detail = _post(self._url, body, "application/json") + if status != 200: + return Result(False, f"webhook status {status}: {detail.decode('utf-8', 'replace')[:200]}") + return Result(True, "forwarded through Home Assistant") + + class DiscardDelivery: """Staging: отчёт принимается и складывается, но никуда не уходит.""" @@ -113,6 +196,8 @@ class DiscardDelivery: def build(cfg) -> object: - if cfg.delivers: - return TelegramDelivery(cfg.telegram_token, cfg.telegram_chat_id) - return DiscardDelivery() + if not cfg.delivers: + return DiscardDelivery() + if cfg.channel == "ha_webhook": + return HaWebhookDelivery(cfg.webhook_url) + return TelegramDelivery(cfg.telegram_token, cfg.telegram_chat_id) diff --git a/scripts/support-relay/tests/test_relay.py b/scripts/support-relay/tests/test_relay.py index f4d2daa2..e4312955 100644 --- a/scripts/support-relay/tests/test_relay.py +++ b/scripts/support-relay/tests/test_relay.py @@ -309,6 +309,79 @@ class RelayTestCase(unittest.TestCase): self.assertEqual(config.RATE_TTL_SECONDS, 24 * 3600) +class WebhookChannelTestCase(unittest.TestCase): + """Канал «через Home Assistant»: что уходит и что остаётся.""" + + def setUp(self) -> None: + self._tmp = TemporaryDirectory() + secret = Path(self._tmp.name) / "webhook.url" + secret.write_text("https://ha.example/api/webhook/xyz\n", encoding="utf-8") + self.cfg = config.load({ + "HP_RELAY_SPOOL": str(Path(self._tmp.name) / "spool"), + "HP_RELAY_MODE": "deliver", + "HP_RELAY_CHANNEL": "ha_webhook", + "HP_RELAY_WEBHOOK_URL_FILE": str(secret), + }) + + def tearDown(self) -> None: + self._tmp.cleanup() + + def test_channel_is_live_with_only_the_webhook_url(self): + self.assertTrue(self.cfg.delivers) + self.assertIsInstance(delivery.build(self.cfg), delivery.HaWebhookDelivery) + + def test_webhook_sends_text_and_keeps_the_package_on_the_node(self): + posted: list[tuple[str, bytes, str]] = [] + real = delivery._post + delivery._post = lambda url, body, ctype: (posted.append((url, body, ctype)), (200, b"ok"))[1] + try: + channel = delivery.HaWebhookDelivery(self.cfg.webhook_url) + result = channel.send("hpr-42", {"message": "сломалось", "attachment_size": 12, + "spool_path": "/var/lib/x/hpr-42"}, b"{}") + finally: + delivery._post = real + self.assertTrue(result.ok) + url, body, ctype = posted[0] + self.assertEqual(url, "https://ha.example/api/webhook/xyz") + self.assertEqual(ctype, "application/json") + payload = json.loads(body) + self.assertEqual(payload["source"], "houseplan-support-relay") # условие автоматизации + self.assertIn("сломалось", payload["text"]) + self.assertIn("/var/lib/x/hpr-42", payload["text"]) # где искать пакет + self.assertNotIn("attachment_bytes", payload) # сам пакет не уходит + self.assertEqual(sorted(payload), ["report_id", "source", "text"]) + + def test_webhook_failure_is_not_reported_as_success(self): + real = delivery._post + delivery._post = lambda url, body, ctype: (502, b"bad gateway") + try: + result = delivery.HaWebhookDelivery(self.cfg.webhook_url).send("hpr-1", {"message": "x"}, None) + finally: + delivery._post = real + self.assertFalse(result.ok) + self.assertIn("502", result.detail) + + +class DeliveryTransportTestCase(unittest.TestCase): + """Транспорт доставки: узел без IPv6 не должен молча висеть.""" + + def test_connect_asks_for_ipv4_addresses_only(self): + seen: list[int] = [] + real = delivery.socket.getaddrinfo + + def fake(host, port, family=0, *args, **kwargs): + seen.append(family) + return real("127.0.0.1", port, family, *args, **kwargs) + + delivery.socket.getaddrinfo = fake + try: + with self.assertRaises(OSError): + delivery._connect_ipv4(("example.invalid", 9), 0.2) + finally: + delivery.socket.getaddrinfo = real + self.assertEqual(seen, [delivery.socket.AF_INET]) + + class HttpSurfaceTestCase(unittest.TestCase): """Проверки, которые живут только на транспортном уровне."""