feat(relay): deliver through the maintainer's Home Assistant webhook (#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 736a6c143e
commit ca86e79501
7 changed files with 241 additions and 31 deletions
+16 -2
View File
@@ -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.
+29 -10
View File
@@ -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` в вебхуке, приложить пакет к вебхуку)
роняют ровно те проверки, ради которых написаны.
+5
View File
@@ -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
+4 -1
View File
@@ -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)
+12 -1
View File
@@ -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"},
)
+102 -17
View File
@@ -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)
+73
View File
@@ -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):
"""Проверки, которые живут только на транспортном уровне."""