From cb4333df4f9a6577c40fcf428571a539a4bb502f Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Thu, 5 Mar 2026 22:27:24 -0800 Subject: [PATCH] Fanout hitlist fixes: bugs, quality, tests, webhook HMAC signing --- app/fanout/apprise_mod.py | 4 +- app/fanout/base.py | 3 +- app/fanout/bot.py | 7 +- app/fanout/manager.py | 5 +- app/fanout/mqtt_community.py | 4 +- app/fanout/mqtt_private.py | 4 +- app/fanout/webhook.py | 21 +- app/routers/fanout.py | 4 +- .../settings/SettingsFanoutSection.tsx | 45 +- frontend/src/test/fanoutSection.test.tsx | 156 +++++ tests/e2e/specs/webhook.spec.ts | 10 +- tests/test_fanout_hitlist.py | 563 ++++++++++++++++++ tests/test_fanout_integration.py | 68 ++- 13 files changed, 840 insertions(+), 54 deletions(-) create mode 100644 frontend/src/test/fanoutSection.test.tsx create mode 100644 tests/test_fanout_hitlist.py diff --git a/app/fanout/apprise_mod.py b/app/fanout/apprise_mod.py index a94821b..0cbf31d 100644 --- a/app/fanout/apprise_mod.py +++ b/app/fanout/apprise_mod.py @@ -88,8 +88,8 @@ def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool) -> bool: class AppriseModule(FanoutModule): """Sends push notifications via Apprise for incoming messages.""" - def __init__(self, config_id: str, config: dict) -> None: - super().__init__(config_id, config) + def __init__(self, config_id: str, config: dict, *, name: str = "") -> None: + super().__init__(config_id, config, name=name) self._last_error: str | None = None async def on_message(self, data: dict) -> None: diff --git a/app/fanout/base.py b/app/fanout/base.py index 9aa4acb..f0af94c 100644 --- a/app/fanout/base.py +++ b/app/fanout/base.py @@ -12,9 +12,10 @@ class FanoutModule: Subclasses must override the ``status`` property. """ - def __init__(self, config_id: str, config: dict) -> None: + def __init__(self, config_id: str, config: dict, *, name: str = "") -> None: self.config_id = config_id self.config = config + self.name = name async def start(self) -> None: """Start the module (e.g. connect to broker). Override for persistent connections.""" diff --git a/app/fanout/bot.py b/app/fanout/bot.py index ac6c991..f46ea25 100644 --- a/app/fanout/bot.py +++ b/app/fanout/bot.py @@ -20,8 +20,7 @@ class BotModule(FanoutModule): """ def __init__(self, config_id: str, config: dict, *, name: str = "Bot") -> None: - super().__init__(config_id, config) - self._name = name + super().__init__(config_id, config, name=name) async def on_message(self, data: dict) -> None: """Kick off bot execution in a background task so we don't block dispatch.""" @@ -110,10 +109,10 @@ class BotModule(FanoutModule): timeout=BOT_EXECUTION_TIMEOUT, ) except asyncio.TimeoutError: - logger.warning("Bot '%s' execution timed out", self._name) + logger.warning("Bot '%s' execution timed out", self.name) return except Exception as e: - logger.warning("Bot '%s' execution error: %s", self._name, e) + logger.warning("Bot '%s' execution error: %s", self.name, e) return if response: diff --git a/app/fanout/manager.py b/app/fanout/manager.py index 23cda0e..c083360 100644 --- a/app/fanout/manager.py +++ b/app/fanout/manager.py @@ -108,10 +108,7 @@ class FanoutManager: return try: - if config_type == "bot": - module = cls(config_id, config_blob, name=cfg.get("name", "Bot")) - else: - module = cls(config_id, config_blob) + module = cls(config_id, config_blob, name=cfg.get("name", "")) await module.start() self._modules[config_id] = (module, scope) logger.info( diff --git a/app/fanout/mqtt_community.py b/app/fanout/mqtt_community.py index f470cd1..982efa6 100644 --- a/app/fanout/mqtt_community.py +++ b/app/fanout/mqtt_community.py @@ -29,8 +29,8 @@ def _config_to_settings(config: dict) -> SimpleNamespace: class MqttCommunityModule(FanoutModule): """Wraps a CommunityMqttPublisher for community packet sharing.""" - def __init__(self, config_id: str, config: dict) -> None: - super().__init__(config_id, config) + def __init__(self, config_id: str, config: dict, *, name: str = "") -> None: + super().__init__(config_id, config, name=name) self._publisher = CommunityMqttPublisher() async def start(self) -> None: diff --git a/app/fanout/mqtt_private.py b/app/fanout/mqtt_private.py index 9b2905a..2169589 100644 --- a/app/fanout/mqtt_private.py +++ b/app/fanout/mqtt_private.py @@ -29,8 +29,8 @@ def _config_to_settings(config: dict) -> SimpleNamespace: class MqttPrivateModule(FanoutModule): """Wraps an MqttPublisher instance for private MQTT forwarding.""" - def __init__(self, config_id: str, config: dict) -> None: - super().__init__(config_id, config) + def __init__(self, config_id: str, config: dict, *, name: str = "") -> None: + super().__init__(config_id, config, name=name) self._publisher = MqttPublisher() async def start(self) -> None: diff --git a/app/fanout/webhook.py b/app/fanout/webhook.py index 4f9a798..536c6d0 100644 --- a/app/fanout/webhook.py +++ b/app/fanout/webhook.py @@ -2,6 +2,8 @@ from __future__ import annotations +import hashlib +import hmac import logging import httpx @@ -14,8 +16,8 @@ logger = logging.getLogger(__name__) class WebhookModule(FanoutModule): """Delivers message data to an HTTP endpoint via POST (or configurable method).""" - def __init__(self, config_id: str, config: dict) -> None: - super().__init__(config_id, config) + def __init__(self, config_id: str, config: dict, *, name: str = "") -> None: + super().__init__(config_id, config, name=name) self._client: httpx.AsyncClient | None = None self._last_error: str | None = None @@ -44,18 +46,25 @@ class WebhookModule(FanoutModule): method = self.config.get("method", "POST").upper() extra_headers = self.config.get("headers", {}) - secret = self.config.get("secret", "") + hmac_secret = self.config.get("hmac_secret", "") + hmac_header = self.config.get("hmac_header", "X-Webhook-Signature") headers = { "Content-Type": "application/json", "X-Webhook-Event": event_type, **extra_headers, } - if secret: - headers["X-Webhook-Secret"] = secret + + import json as _json + + body_bytes = _json.dumps(data, separators=(",", ":"), sort_keys=True).encode() + + if hmac_secret: + sig = hmac.new(hmac_secret.encode(), body_bytes, hashlib.sha256).hexdigest() + headers[hmac_header or "X-Webhook-Signature"] = f"sha256={sig}" try: - resp = await self._client.request(method, url, json=data, headers=headers) + resp = await self._client.request(method, url, content=body_bytes, headers=headers) resp.raise_for_status() self._last_error = None except httpx.HTTPStatusError as exc: diff --git a/app/routers/fanout.py b/app/routers/fanout.py index a4dc357..e3826c9 100644 --- a/app/routers/fanout.py +++ b/app/routers/fanout.py @@ -44,10 +44,10 @@ def _validate_mqtt_private_config(config: dict) -> None: def _validate_mqtt_community_config(config: dict) -> None: """Validate mqtt_community config blob.""" iata = config.get("iata", "") - if iata and not _IATA_RE.fullmatch(iata.upper().strip()): + if not iata or not _IATA_RE.fullmatch(iata.upper().strip()): raise HTTPException( status_code=400, - detail="IATA code must be exactly 3 uppercase alphabetic characters", + detail="IATA code is required and must be exactly 3 uppercase alphabetic characters", ) diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx index bac4560..baf1ecc 100644 --- a/frontend/src/components/settings/SettingsFanoutSection.tsx +++ b/frontend/src/components/settings/SettingsFanoutSection.tsx @@ -777,19 +777,43 @@ function WebhookConfigEditor({ + -
- - onChange({ ...config, secret: e.target.value })} - /> + + +
+ +

+ When a secret is set, each request includes an HMAC-SHA256 signature of the JSON body in + the specified header (e.g. sha256=ab12cd... + ). +

+
+
+ + onChange({ ...config, hmac_secret: e.target.value })} + /> +
+
+ + onChange({ ...config, hmac_header: e.target.value })} + /> +
+ +