From 2c771be40626efd0cf54962af656583da5738f15 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:59:06 -0700 Subject: [PATCH] fix(gateway): dual-stack webhook bind for wecom/msgraph/whatsapp_cloud/teams/telegram siblings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same class of bug as the LINE adapter (NS-603): defaulting the webhook bind to "0.0.0.0" (or hardcoding it) binds IPv4 ONLY, so the listener is unreachable over IPv6-only private networks such as Fly.io 6PN. - wecom callback_adapter: DEFAULT_HOST None; config.py env seed no longer forces 0.0.0.0 when WECOM_CALLBACK_HOST is unset. - msgraph_webhook: DEFAULT_HOST None; the allowed_source_cidrs requirement still fires for the all-interfaces default (host=None is treated as network-accessible). - whatsapp_cloud: DEFAULT_WEBHOOK_HOST None. - teams: hardcoded 0.0.0.0 TCPSite bind → _DEFAULT_HOST=None with new TEAMS_HOST / extra.host override (mirrors LINE_HOST pattern). - telegram: hardcoded listen="0.0.0.0" → default "" (tornado bind_sockets opens one socket per address family; verified against PTB 22.6/tornado) with new TELEGRAM_WEBHOOK_HOST / extra.webhook_host override. Explicit host overrides everywhere are preserved; empty/unset collapses to the dual-stack default. "::" remains a bad substitute on bindv6only=1 hosts (see LINE adapter comment). --- gateway/config.py | 5 ++++- gateway/platforms/msgraph_webhook.py | 16 +++++++++++++--- gateway/platforms/whatsapp_cloud.py | 15 ++++++++++++--- plugins/platforms/teams/adapter.py | 14 ++++++++++++-- plugins/platforms/teams/plugin.yaml | 4 ++++ plugins/platforms/telegram/adapter.py | 21 ++++++++++++++++++--- plugins/platforms/wecom/callback_adapter.py | 11 +++++++++-- 7 files changed, 72 insertions(+), 14 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index 3c6a631f95c..759c255bd80 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -2323,7 +2323,10 @@ def _apply_env_overrides(config: GatewayConfig) -> None: "agent_id": getenv("WECOM_CALLBACK_AGENT_ID", ""), "token": getenv("WECOM_CALLBACK_TOKEN", ""), "encoding_aes_key": getenv("WECOM_CALLBACK_ENCODING_AES_KEY", ""), - "host": getenv("WECOM_CALLBACK_HOST", "0.0.0.0"), + # No default here: an unset WECOM_CALLBACK_HOST leaves extra.host + # falsy so the adapter's dual-stack DEFAULT_HOST=None applies + # (binds IPv4 + IPv6; "0.0.0.0" was IPv4-only, NS-603). + "host": getenv("WECOM_CALLBACK_HOST", ""), "port": getenv_int("WECOM_CALLBACK_PORT", 8645), }) diff --git a/gateway/platforms/msgraph_webhook.py b/gateway/platforms/msgraph_webhook.py index ab0249a40d3..3fea2ca13a8 100644 --- a/gateway/platforms/msgraph_webhook.py +++ b/gateway/platforms/msgraph_webhook.py @@ -30,7 +30,13 @@ from gateway.platforms.base import ( logger = logging.getLogger(__name__) -DEFAULT_HOST = "0.0.0.0" +# ``None`` → aiohttp/asyncio ``create_server`` binds one listening socket per +# address family (IPv4 + IPv6). The old "0.0.0.0" default bound IPv4 ONLY and +# was unreachable over IPv6-only private networks (e.g. Fly.io 6PN) — same +# bug as the LINE adapter (NS-603) and gateway/platforms/webhook.py +# (d542894ad). Pin a host via extra.host. The all-interfaces default still +# requires extra.allowed_source_cidrs (see _source_allowlist_required_but_missing). +DEFAULT_HOST = None DEFAULT_PORT = 8646 DEFAULT_WEBHOOK_PATH = "/msgraph/webhook" DEFAULT_MAX_SEEN_RECEIPTS = 5000 @@ -49,7 +55,9 @@ class MSGraphWebhookAdapter(BasePlatformAdapter): def __init__(self, config: PlatformConfig): super().__init__(config, Platform.MSGRAPH_WEBHOOK) extra = config.extra or {} - self._host: str = str(extra.get("host", DEFAULT_HOST)) + # Falsy host (None/"") collapses to the dual-stack default. + _raw_host = extra.get("host", DEFAULT_HOST) or DEFAULT_HOST + self._host: Optional[str] = str(_raw_host) if _raw_host else None self._port: int = int(extra.get("port", DEFAULT_PORT)) self._webhook_path: str = self._normalize_path( extra.get("webhook_path", DEFAULT_WEBHOOK_PATH) @@ -138,7 +146,9 @@ class MSGraphWebhookAdapter(BasePlatformAdapter): self._notification_scheduler = scheduler def _source_allowlist_required_but_missing(self) -> bool: - return is_network_accessible(self._host) and not self._allowed_source_networks + # host=None binds all interfaces (both families) — network-accessible. + host_is_public = self._host is None or is_network_accessible(self._host) + return host_is_public and not self._allowed_source_networks async def connect(self, *, is_reconnect: bool = False) -> bool: if self._client_state is None: diff --git a/gateway/platforms/whatsapp_cloud.py b/gateway/platforms/whatsapp_cloud.py index 23872809234..b401e941e9e 100644 --- a/gateway/platforms/whatsapp_cloud.py +++ b/gateway/platforms/whatsapp_cloud.py @@ -33,7 +33,7 @@ Optional / Phase-3+: - WHATSAPP_CLOUD_APP_SECRET (HMAC key for X-Hub-Signature-256) - WHATSAPP_CLOUD_WABA_ID (analytics / future use) - WHATSAPP_CLOUD_VERIFY_TOKEN (hub.verify_token shared secret) -- WHATSAPP_CLOUD_WEBHOOK_HOST (default 0.0.0.0) +- WHATSAPP_CLOUD_WEBHOOK_HOST (default: unset → dual-stack, all interfaces IPv4+IPv6) - WHATSAPP_CLOUD_WEBHOOK_PORT (default 8090) - WHATSAPP_CLOUD_WEBHOOK_PATH (default /whatsapp/webhook) - WHATSAPP_CLOUD_API_VERSION (default v20.0) @@ -86,7 +86,12 @@ logger = logging.getLogger(__name__) DEFAULT_API_VERSION = "v20.0" -DEFAULT_WEBHOOK_HOST = "0.0.0.0" +# ``None`` → aiohttp/asyncio ``create_server`` binds one listening socket per +# address family (IPv4 + IPv6). The old "0.0.0.0" default bound IPv4 ONLY and +# was unreachable over IPv6-only private networks (e.g. Fly.io 6PN) — same +# bug as the LINE adapter (NS-603) and gateway/platforms/webhook.py +# (d542894ad). Pin a host via WHATSAPP_CLOUD_WEBHOOK_HOST or extra.webhook_host. +DEFAULT_WEBHOOK_HOST = None DEFAULT_WEBHOOK_PORT = 8090 DEFAULT_WEBHOOK_PATH = "/whatsapp/webhook" GRAPH_API_BASE = "https://graph.facebook.com" @@ -217,7 +222,11 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): self._verify_token: str = str(extra.get("verify_token", "")).strip() # Webhook server config - self._webhook_host: str = str(extra.get("webhook_host", DEFAULT_WEBHOOK_HOST)) + # Falsy host (None/"") collapses to the dual-stack default. + _raw_webhook_host = extra.get("webhook_host", DEFAULT_WEBHOOK_HOST) or DEFAULT_WEBHOOK_HOST + self._webhook_host: Optional[str] = ( + str(_raw_webhook_host) if _raw_webhook_host else None + ) self._webhook_port: int = int(extra.get("webhook_port", DEFAULT_WEBHOOK_PORT)) self._webhook_path: str = self._normalize_path( extra.get("webhook_path", DEFAULT_WEBHOOK_PATH) diff --git a/plugins/platforms/teams/adapter.py b/plugins/platforms/teams/adapter.py index b516b019899..db3ae7775d3 100644 --- a/plugins/platforms/teams/adapter.py +++ b/plugins/platforms/teams/adapter.py @@ -105,6 +105,12 @@ _DEFAULT_PORT = 3978 # Bot Framework activities are JSON payloads well under 1 MiB; an explicit # aiohttp client_max_size keeps oversized/chunked request bodies bounded. _MAX_BODY_BYTES = 1_048_576 +# ``None`` → aiohttp/asyncio ``create_server`` binds one listening socket per +# address family (IPv4 + IPv6). The old hardcoded "0.0.0.0" bound IPv4 ONLY +# and was unreachable over IPv6-only private networks (e.g. Fly.io 6PN) — +# same bug as the LINE adapter (NS-603) and gateway/platforms/webhook.py +# (d542894ad). Pin a host via TEAMS_HOST or extra.host. +_DEFAULT_HOST = None _WEBHOOK_PATH = "/api/messages" @@ -705,6 +711,9 @@ class TeamsAdapter(BasePlatformAdapter): self._port = _coerce_port( extra.get("port") or os.getenv("TEAMS_PORT", str(_DEFAULT_PORT)) ) + # Falsy host (unset/"") collapses to the dual-stack default (None). + _raw_host = extra.get("host") or os.getenv("TEAMS_HOST", "") or _DEFAULT_HOST + self._host: Optional[str] = str(_raw_host) if _raw_host else None self._app: Optional["App"] = None self._runner: Optional["web.AppRunner"] = None self._dedup = MessageDeduplicator(max_size=1000) @@ -774,13 +783,14 @@ class TeamsAdapter(BasePlatformAdapter): self._runner = web.AppRunner(aiohttp_app) await self._runner.setup() - site = web.TCPSite(self._runner, "0.0.0.0", self._port) + site = web.TCPSite(self._runner, self._host, self._port) await site.start() self._running = True self._mark_connected() logger.info( - "[teams] Webhook server listening on 0.0.0.0:%d%s", + "[teams] Webhook server listening on %s:%d%s", + self._host or "* (all interfaces, IPv4+IPv6)", self._port, _WEBHOOK_PATH, ) diff --git a/plugins/platforms/teams/plugin.yaml b/plugins/platforms/teams/plugin.yaml index fd237560350..6d6e30171fe 100644 --- a/plugins/platforms/teams/plugin.yaml +++ b/plugins/platforms/teams/plugin.yaml @@ -30,6 +30,10 @@ optional_env: description: "Webhook listen port (Bot Framework default: 3978)" prompt: "Webhook port" password: false + - name: TEAMS_HOST + description: "Webhook bind host (default: unset → dual-stack, all interfaces IPv4+IPv6)" + prompt: "Webhook host" + password: false - name: TEAMS_ALLOWED_USERS description: "Comma-separated Teams user IDs / UPNs allowed to talk to the bot" prompt: "Allowed users (comma-separated)" diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index f0d0dfc0998..59829a49e35 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -3592,6 +3592,8 @@ class TelegramAdapter(BasePlatformAdapter): TELEGRAM_WEBHOOK_URL Public HTTPS URL (e.g. https://app.fly.dev/telegram) TELEGRAM_WEBHOOK_PORT Local listen port (default 8443) + TELEGRAM_WEBHOOK_HOST Bind host (default: unset → dual-stack, + all interfaces IPv4+IPv6) TELEGRAM_WEBHOOK_SECRET Secret token for update verification """ # Explicit connect() is the only operation allowed to reopen polling @@ -3944,6 +3946,16 @@ class TelegramAdapter(BasePlatformAdapter): # start rather than silently run in fail-open mode. # See GHSA-3vpc-7q5r-276h. webhook_port = env_int("TELEGRAM_WEBHOOK_PORT", 8443) + # Bind host. Default "" → tornado bind_sockets opens one + # listening socket per address family (IPv4 + IPv6). The old + # hardcoded "0.0.0.0" bound IPv4 ONLY and was unreachable + # over IPv6-only private networks (e.g. Fly.io 6PN) — same + # bug as the LINE adapter (NS-603). Pin via + # TELEGRAM_WEBHOOK_HOST or platforms.telegram.extra.webhook_host. + webhook_host = ( + os.getenv("TELEGRAM_WEBHOOK_HOST", "").strip() + or str((self.config.extra or {}).get("webhook_host") or "").strip() + ) webhook_secret = os.getenv("TELEGRAM_WEBHOOK_SECRET", "").strip() if not webhook_secret: raise RuntimeError( @@ -3962,7 +3974,7 @@ class TelegramAdapter(BasePlatformAdapter): webhook_path = urlparse(webhook_url).path or "/telegram" await self._app.updater.start_webhook( - listen="0.0.0.0", + listen=webhook_host, port=webhook_port, url_path=webhook_path, webhook_url=webhook_url, @@ -3978,8 +3990,11 @@ class TelegramAdapter(BasePlatformAdapter): self._polling_progress_accepting = False self._send_path_degraded = False logger.info( - "[%s] Webhook server listening on 0.0.0.0:%d%s", - self.name, webhook_port, webhook_path, + "[%s] Webhook server listening on %s:%d%s", + self.name, + webhook_host or "* (all interfaces, IPv4+IPv6)", + webhook_port, + webhook_path, ) else: # ── Polling mode (default) ─────────────────────────── diff --git a/plugins/platforms/wecom/callback_adapter.py b/plugins/platforms/wecom/callback_adapter.py index e358397249a..9c447f5ce5d 100644 --- a/plugins/platforms/wecom/callback_adapter.py +++ b/plugins/platforms/wecom/callback_adapter.py @@ -51,7 +51,12 @@ from plugins.platforms.wecom.wecom_crypto import WXBizMsgCrypt, WeComCryptoError logger = logging.getLogger(__name__) -DEFAULT_HOST = "0.0.0.0" +# ``None`` → aiohttp/asyncio ``create_server`` binds one listening socket per +# address family (IPv4 + IPv6). The old "0.0.0.0" default bound IPv4 ONLY and +# was unreachable over IPv6-only private networks (e.g. Fly.io 6PN) — same +# bug as the LINE adapter (NS-603) and gateway/platforms/webhook.py +# (d542894ad). Pin a host via WECOM_CALLBACK_HOST or extra.host. +DEFAULT_HOST = None DEFAULT_PORT = 8645 DEFAULT_PATH = "/wecom/callback" # Cap pre-auth request bodies. WeCom callbacks are small encrypted XML @@ -71,7 +76,9 @@ class WecomCallbackAdapter(BasePlatformAdapter): def __init__(self, config: PlatformConfig): super().__init__(config, Platform.WECOM_CALLBACK) extra = config.extra or {} - self._host = str(extra.get("host") or DEFAULT_HOST) + # Falsy host (None/"") collapses to the dual-stack default. + _raw_host = extra.get("host") or DEFAULT_HOST + self._host = str(_raw_host) if _raw_host else None self._port = int(extra.get("port") or DEFAULT_PORT) self._path = str(extra.get("path") or DEFAULT_PATH) self._apps: List[Dict[str, Any]] = self._normalize_apps(extra)