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
+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):
"""Проверки, которые живут только на транспортном уровне."""