SagerNet / SagerNet/sing-box

Domains without AAAA record intermittently routed via proxy instead of matching geoip DNS rule, depending on A/AAAA resolution race

Open
#4,414 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Go
Stars
38.2k
Forks
4.6k
Avg merge
19d 15h
Merged PRs (30d)
1

Description

Operating system

macOS

System version

macOS Tahoe 26.6.1

Installation type

sing-box for macOS Graphical Client

If you are using a graphical client, please provide the version of the client.

1.13.18

Version

Description
Confirming the domain has no IPv6 (system resolver, no VPN)
dig msk-static-05.cdntogo.net A
dig msk-static-05.cdntogo.net AAAA

A query:
;; ANSWER SECTION:
msk-static-05.cdntogo.net. 300 IN A 88.218.240.226

AAAA query:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, ...
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 0

The AAAA response is NOERROR with zero answers (NODATA) — confirmed the domain
genuinely has no IPv6 address, this is not a resolver error or NXDOMAIN.

When a domain has no IPv6 address at all (no AAAA record), a rule_set (geoip/ip_cidr)
DNS rule with address-limit filtering can non-deterministically fail to route the
connection correctly, because:

  1. The AAAA query for such a domain returns NOERROR with an empty answer (NODATA).
  2. In route/rule/rule_item_cidr.go, IPCIDRItem.Match() handles an empty response like this:
   if metadata.DestinationAddressMatchFromResponse {
       addresses := metadata.DNSResponseAddressesForMatch()
       if len(addresses) == 0 {
           return metadata.IPCIDRAcceptEmpty
       }

IPCIDRAcceptEmpty defaults to false (confirmed in adapter/inbound.go), so an
empty/NODATA response is treated as "does not match the geoip filter" — even though
the domain simply has no IPv6 at all, which has nothing to do with geography.

  1. Because the address-limit check fails, the DNS rule loop in dns/router.go falls
    through to the next rule, which for me is query_type: [A, AAAA] -> fakeip.
  2. The fakeip server issues a synthetic AAAA answer (e.g. fc00::b7) unconditionally,
    regardless of whether the domain actually supports IPv6.
  3. The OS (macOS) receives both a real, correctly geoip-filtered A record and this
    synthetic AAAA record for the same domain, and races connecting over both
    (RFC 8305 Happy Eyeballs). Since the fake IPv6 answer is served instantly and
    in-process (no network round-trip), it frequently wins the race.
  4. If the fake IPv6 address wins, the connection enters the tunnel via fakeip. At the
    ROUTE level, sing-box resolves the fakeip address back to the domain name (not an IP),
    so the IP-based geoip-ru/geoip-by ROUTE rule can no longer evaluate correctly —
    there is no resolved IP to compare against the CIDR set. The connection falls through
    to final (my proxy selector) instead of being routed direct.
  5. If the real A address wins the race instead, everything works as expected and the
    connection correctly matches the geoip rule and goes direct.

The practical effect: the exact same domain, resolving to the exact same (correctly
geoip-matched) IPv4 address, is routed inconsistently — sometimes direct, sometimes
through the proxy — purely depending on which DNS answer wins the client-side
Happy Eyeballs race. Flushing the DNS cache or simply reloading the page changes the
race outcome and "fixes" it temporarily, which matches the nondeterministic pattern
I've been observing.

This reproduces reliably with any domain that is geoip-matched to my direct rule but
has no AAAA record at all — in my case, a Russian CDN provider's edge servers.

Reproduction

This can be reproduced with a minimal, self-contained config — no VPN backend required,
only a domain that has an A record matching a geoip rule_set but no AAAA record at all.

1. Find/confirm such a domain

Any domain with an IPv4-only CDN edge works. Example used below (public, third-party CDN,
not related to my own infrastructure):

dig msk-static-05.cdntogo.net A
dig msk-static-05.cdntogo.net AAAA

The A query returns a real IP (e.g. 88.218.240.226, RU-geoip). The AAAA query returns
NOERROR with an empty ANSWER section (NODATA) — confirmed no IPv6 exists for this domain.

2. Minimal config
{
  "log": { "level": "debug", "timestamp": true },
  "dns": {
    "servers": [
      { "type": "udp", "tag": "upstream", "server": "8.8.8.8" },
      { "type": "fakeip", "tag": "fakeip", "inet4_range": "198.18.0.0/15", "inet6_range": "fc00::/18" }
    ],
    "rules": [
      { "rule_set": "geoip-ru", "server": "upstream" },
      { "query_type": ["A", "AAAA"], "server": "fakeip" }
    ],
    "final": "upstream",
    "independent_cache": true
  },
  "inbounds": [
    { "type": "tun", "tag": "tun", "address": ["172.19.0.1/30", "fdfe:dead:beef::1/126"],
      "auto_route": true, "strict_route": true, "stack": "mixed" }
  ],
  "outbounds": [
    { "type": "direct", "tag": "direct" },
    { "type": "direct", "tag": "final-placeholder" }
  ],
  "route": {
    "rules": [
      { "protocol": "dns", "action": "hijack-dns" },
      { "rule_set": "geoip-ru", "outbound": "direct" }
    ],
    "rule_set": [
      { "type": "remote", "tag": "geoip-ru",
        "url": "https://cdn.jsdelivr.net/gh/Loyalsoldier/geoip@release/srs/ru.srs" }
    ],
    "final": "final-placeholder"
  }
}

Run: sing-box run -c config.json

3. Observe the DNS-layer inconsistency (deterministic)
dig @172.19.0.1 msk-static-05.cdntogo.net A
dig @172.19.0.1 msk-static-05.cdntogo.net AAAA

Debug log for the A query shows the geoip-ru rule succeeding normally. For the AAAA query:

dns: response rejected for msk-static-05.cdntogo.net. IN AAAA: response rejected: cached
dns: exchanged AAAA msk-static-05.cdntogo.net. 600 IN AAAA fc00::xx

The address-limit check on the empty AAAA response falls back to IPCIDRAcceptEmpty
(default false), so it's treated as "not matching geoip-ru" — even though the domain
has no IPv6 at all, which is unrelated to geography.

4. Observe the routing inconsistency (deterministic, no race needed)

Tested against my actual production config (not the minimal one above), same domain,
same two commands, run back to back:

curl -4 -v --interface utun4 --max-time 10 https://msk-static-05.cdntogo.net/
curl -6 -v --interface utun4 --max-time 10 https://msk-static-05.cdntogo.net/

(both fail with a TLS certificate name mismatch — expected, since the cert's CN is
msk.static.cdntogo.net, not this specific edge hostname — but the connection has
already been fully established and routed by that point, which is what matters here)

Debug log for the -4 request:

dns: exchange msk-static-05.cdntogo.net. IN A
dns: match[9] rule_set=[geoip-ru geoip-by] => route(dns4eu)
dns: exchanged A msk-static-05.cdntogo.net. 300 IN A 88.218.240.226
router: match[6] rule_set=[geoip-ru geoip-by] => route(direct)
outbound/direct[direct]: outbound connection to 88.218.240.226:443

Debug log for the -6 request:

dns: exchange msk-static-05.cdntogo.net. IN AAAA
dns: match[9] rule_set=[geoip-ru geoip-by] => route(dns4eu)
dns: response rejected for msk-static-05.cdntogo.net. IN AAAA: response rejected: cached (cached)
dns: match[11] query_type=[A AAAA] => route(remote)
dns: exchanged AAAA msk-static-05.cdntogo.net. 600 IN AAAA fc00::bc
router: found fakeip domain: msk-static-05.cdntogo.net
router: sniffed protocol: tls, domain: msk-static-05.cdntogo.net
outbound/vless[vless-out-de]: outbound connection to msk-static-05.cdntogo.net:443

Same domain, same real (geoip-matched) IPv4 address behind it — but the AAAA/fakeip path
never reaches the geoip-ru rule at all, because at the ROUTE level the destination is a
domain name (from the fakeip reverse lookup), not a resolved IP, so an IP-based rule_set
condition cannot evaluate it. The only difference between the two runs is which address
family curl was told to use — nothing else changed, no timing, no cache state, no client
behavior — which confirms this is a structural gap, not a transient race artifact.

5. Real-world trigger (why it's intermittent for end users)

With a dual-stack OS client (tested on macOS) making a normal HTTPS request, getaddrinfo
returns both records, and Happy Eyeballs (RFC 8305) races connecting over both. The fake
AAAA answer is served instantly (no network round-trip), so it frequently wins the race,
non-deterministically producing the same effect as step 4's curl -6 — even though the
domain, from the user's perspective, should always match the geoip-ru direct rule.
Reloading the page or flushing the DNS cache changes the race outcome, which is why the
same domain appears to work "sometimes."

Logs
Not applicable — this is not a crash, it's an incorrect (non-deterministic) routing
decision. The client runs normally throughout; no crash logs are produced.
Supporter
Integrity requirements
  • I confirm that I have read the documentation, understand the meaning of all the configuration items I wrote, and did not pile up seemingly useful options or default values.
  • I confirm that I have provided the server and client configuration files and process that can be reproduced locally, instead of a complicated client configuration file that has been stripped of sensitive data.
  • I confirm that I have provided the simplest configuration that can be used to reproduce the error I reported, instead of depending on remote servers, TUN, graphical interface clients, or other closed-source software.
  • I confirm that I have provided the complete configuration files and logs, rather than just providing parts I think are useful out of confidence in my own intelligence.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the minimal configuration and the A/AAAA dig commands in the issue, then read route/rule/rule_item_cidr.go, adapter/inbound.go, and dns/router.go. Trace how an empty AAAA response is handled by address-limit matching and how the DNS rule fallback reaches fakeip. Done means the reported IPv4-only domain no longer takes the incorrect fakeip or routing path, with regression coverage for the NODATA case.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
networking
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.