From be03dd4f0474dc40d86f8a494ec2cb2c9377b2e2 Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Sun, 9 Aug 2026 03:43:54 +0300 Subject: [PATCH] cli: force ipv4 for the egress_v4 identity lookup the shared http client has no address family pinned, so on a dual-stack host the 'egress_v4' gather could connect over ipv6 (os/happy-eyeballs preference) and hand back an ipv6 literal labeled as egress_v4, with a mismatched ip_version pulled from a different provider's own record. mirror the existing egress_v6 forcing so each slot can only resolve its own protocol. caught live during the --quick smoke test: the report was analyzing an unrelated ipv6 asn instead of the real isp. --- src/netcheck/cli.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/netcheck/cli.py b/src/netcheck/cli.py index 217bb2c..c842654 100644 --- a/src/netcheck/cli.py +++ b/src/netcheck/cli.py @@ -102,9 +102,27 @@ def _dedupe(findings: list[Finding]) -> list[Finding]: async def _identity(client: httpx.AsyncClient, settings: Settings, options: Options, raw: dict) -> dict: token = settings.ipinfo_token.get_secret_value() if settings.ipinfo_token else None lookup = options.target_value if options.target_kind in ("ip", "domain") else None - merged, cf, flags, payloads = await gather_identity( - client, settings.providers, ip=lookup, ipinfo_token=token - ) + if options.target_kind is None: + # The shared client has no address family pinned, so on a dual-stack host it can + # silently connect over IPv6 (OS/happy-eyeballs preference) and hand back an IPv6 + # literal mislabeled as egress_v4. Force IPv4 here the same way egress_v6 is forced + # below, so the two slots can never both resolve to the same protocol. + try: + transport = httpx.AsyncHTTPTransport(local_address="0.0.0.0") + async with httpx.AsyncClient( + transport=transport, timeout=settings.timeouts.http_seconds + ) as v4_client: + merged, cf, flags, payloads = await gather_identity( + v4_client, settings.providers, ip=lookup, ipinfo_token=token + ) + except (httpx.HTTPError, OSError): + merged, cf, flags, payloads = await gather_identity( + client, settings.providers, ip=lookup, ipinfo_token=token + ) + else: + merged, cf, flags, payloads = await gather_identity( + client, settings.providers, ip=lookup, ipinfo_token=token + ) raw.update(payloads) v6 = None if options.target_kind is None: