diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index e2e52a174f73..24d4d1c77122 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -77,7 +77,25 @@ _BUILTIN_DELIVER_PLATFORMS = { "qqbot", "yuanbao", } -DEFAULT_HOST = "0.0.0.0" +# Default bind host. ``None`` tells aiohttp/asyncio's ``create_server`` to bind +# BOTH address families (IPv4 + IPv6) — the portable dual-stack default. +# +# Why not "0.0.0.0" (the old default) or "::"? +# - "0.0.0.0" binds IPv4 ONLY. On IPv6-only private networks — notably Fly.io +# 6PN, where an agent's ``.internal`` name resolves to an ``fdaa:…`` +# IPv6 address — an IPv4-only listener is unreachable. That is exactly why +# hosted-agent webhook routes were publicly unreachable: the edge router +# reverse-proxies to ``.internal:8644`` over 6PN (IPv6) but the adapter +# was listening on 0.0.0.0 (v4 only) → connection refused. +# - "::" is NOT a safe fix: on hosts where the kernel sets IPV6_V6ONLY=1 +# (verified on Fly machines), binding "::" yields an IPv6-ONLY socket, which +# then breaks the IPv4 loopback health check (``curl 127.0.0.1:8644/health``) +# and the AF_INET port-conflict probe in connect(). +# - ``None`` asks the event loop to create a listening socket per resolved +# family, so both 127.0.0.1 (v4) and the 6PN fdaa (v6) are served regardless +# of the bindv6only sysctl. Users can still pin a specific host via +# ``platforms.webhook.extra.host``. +DEFAULT_HOST = None DEFAULT_PORT = 8644 _INSECURE_NO_AUTH = "INSECURE_NO_AUTH" _DYNAMIC_ROUTES_FILENAME = "webhook_subscriptions.json" @@ -93,7 +111,7 @@ _LOOPBACK_HOSTS = frozenset({ }) -def _is_loopback_host(host: str) -> bool: +def _is_loopback_host(host: Optional[str]) -> bool: """True when `host` binds only to the local machine. Covers IPv4 loopback, the standard `localhost` alias, IPv6 loopback in @@ -116,7 +134,11 @@ class WebhookAdapter(BasePlatformAdapter): def __init__(self, config: PlatformConfig): super().__init__(config, Platform.WEBHOOK) - self._host: str = config.extra.get("host", DEFAULT_HOST) + # ``host`` may be None (dual-stack default) or a user-pinned string. + # A config value of empty string / null is normalised to None so it + # also means "bind all families" rather than an invalid "" host. + _cfg_host = config.extra.get("host", DEFAULT_HOST) + self._host: Optional[str] = _cfg_host or None self._port: int = int(config.extra.get("port", DEFAULT_PORT)) self._global_secret: str = config.extra.get("secret", "") self._static_routes: Dict[str, dict] = config.extra.get("routes", {}) @@ -245,7 +267,7 @@ class WebhookAdapter(BasePlatformAdapter): route_names = ", ".join(self._routes.keys()) or "(none configured)" logger.info( "[webhook] Listening on %s:%d — routes: %s", - self._host, + self._host or "* (all interfaces, IPv4+IPv6)", self._port, route_names, ) diff --git a/tests/gateway/test_webhook_adapter.py b/tests/gateway/test_webhook_adapter.py index 40d15ecd49fc..4e83bd6bbde4 100644 --- a/tests/gateway/test_webhook_adapter.py +++ b/tests/gateway/test_webhook_adapter.py @@ -1525,3 +1525,86 @@ class TestInsecureNoAuthSafetyRail: assert result is True finally: await adapter.disconnect() + + +class TestDualStackBind: + """The default bind host must serve BOTH IPv4 and IPv6. + + Regression guard for the hosted-agent webhook reachability bug: Fly.io 6PN + (the private network the edge router reverse-proxies webhook traffic over) + is IPv6-only — an agent's ``.internal`` name resolves to an ``fdaa:…`` + address. The adapter used to default to ``host="0.0.0.0"`` (IPv4 only), so + the router's dial to ``.internal:8644`` hit an address nothing was + listening on → connection refused → public webhooks unreachable. + + The fix is ``DEFAULT_HOST = None`` (dual-stack). ``"::"`` is NOT a valid + substitute: on hosts with the ``bindv6only`` sysctl set (verified on Fly + machines) it yields an IPv6-ONLY socket, which would then break the IPv4 + loopback health check and the AF_INET port-conflict probe. + """ + + def test_default_host_is_none_for_dual_stack(self): + """The module default is None (bind all families), not 0.0.0.0/::.""" + from gateway.platforms.webhook import DEFAULT_HOST + assert DEFAULT_HOST is None + + def test_missing_host_key_resolves_to_none(self): + """Config with no host key → dual-stack (None), not a literal string.""" + cfg = PlatformConfig(enabled=True, extra={"port": 0, "routes": {}}) + adapter = WebhookAdapter(cfg) + assert adapter._host is None + + @pytest.mark.parametrize("empty", ["", None]) + def test_empty_host_normalises_to_none(self, empty): + """An explicit empty-string/null host means dual-stack, not host=''. + + Guards the old footgun where host='' was passed straight to TCPSite + AND treated as non-loopback — now it collapses to the None default. + """ + adapter = _make_adapter(host=empty, port=0) + assert adapter._host is None + + def test_pinned_host_is_preserved(self): + """A user can still pin a specific bind host via config.extra.host.""" + adapter = _make_adapter(host="127.0.0.1", port=0) + assert adapter._host == "127.0.0.1" + + @pytest.mark.asyncio + async def test_default_bind_serves_both_families(self): + """Binding the real server with the default host opens v4 AND v6 sockets. + + This is the behavioural proof: with host=None, asyncio.create_server + opens a listening socket per resolved family, so both 127.0.0.1 (v4) + and ::1 (v6) are reachable — exactly what 6PN needs. Uses a real bind + on an OS-assigned port (no mock) and inspects the runner's addresses. + """ + # Build config WITHOUT a host key so the real DEFAULT_HOST (None) + # applies — _make_adapter's helper injects host="0.0.0.0" by default, + # which would mask the dual-stack default under test here. + cfg = PlatformConfig( + enabled=True, + extra={ + "port": 0, + "routes": {"r1": {"secret": "real-secret-abc123", "prompt": "x"}}, + }, + ) + adapter = WebhookAdapter(cfg) + assert adapter._host is None + try: + with patch.object(adapter, "_reload_dynamic_routes"): + result = await adapter.connect() + assert result is True + # runner.addresses lists one bound address per listening socket. + # An IPv6 sockaddr is a 4-tuple (host, port, flowinfo, scopeid); + # an IPv4 sockaddr is a 2-tuple (host, port). With the dual-stack + # default we expect BOTH — that is precisely what makes the adapter + # reachable over 6PN (v6) AND on the loopback health check (v4). + addrs = list(adapter._runner.addresses) # type: ignore[union-attr] + has_v6 = any(len(a) == 4 for a in addrs) + has_v4 = any(len(a) == 2 for a in addrs) + assert has_v4, f"IPv4 bind missing — got {addrs}" + assert has_v6, ( + f"IPv6 bind missing (the 6PN reachability bug) — got {addrs}" + ) + finally: + await adapter.disconnect()