Skip to content

feat(service): add smart_dns_pool — local DNS server fronting many upstreams#20

Open
hiddifyafk wants to merge 2 commits into
hiddify:extendedfrom
hiddifyafk:feat/smart-dns-pool-service
Open

feat(service): add smart_dns_pool — local DNS server fronting many upstreams#20
hiddifyafk wants to merge 2 commits into
hiddify:extendedfrom
hiddifyafk:feat/smart-dns-pool-service

Conversation

@hiddifyafk
Copy link
Copy Markdown

Adds a sing-box service type: smart_dns_pool that listens on a local UDP+TCP address and fans incoming DNS queries out to many configured recursive resolvers (UDP / TCP / DoT / DoH) using github.com/hiddify/hmrd_multi_resolver_dns under the hood.

Replaces the closed #19 (which targeted the wrong layer). Integration shape now matches what was clarified offline: instead of dnstt asking sing-box's own DNS subsystem to resolve, you stand up this service locally and point dnstt's resolvers at udp://127.0.0.1:<listen_port> instead of 8.8.8.8.

Why this exists

dnstt's tunnel sends DNS queries to a single recursive resolver. When that resolver gets rate-limited or blocked by the censor, the tunnel stalls until the operator rotates manually. With this service the operator configures the recursive resolvers they want to distribute load across once, then sets dnstt's resolvers to the local listen address. dnstt sees a normal local resolver; the pool transparently handles:

  • Per-query failover within the overall deadline — if the first selected upstream fails inside the per-attempt timeout, the next candidate is tried before the deadline expires.
  • AIMD rate-limit throttlingRcode REFUSED / Rcode SERVFAIL / HTTP 429 (DoH) all decrement the upstream's RPM cap (multiplicative-decrease); sustained success grows it back (additive-increase).
  • Recovery probing — when an upstream is marked down (after N consecutive timeouts/network failures), a background prober re-issues the offending query every probe_interval until it answers, then promotes it back to healthy.
  • Pluggable LBroundrobin (default) / weighted / lowest_latency. weight: 0 means the upstream is a pure fallback (only reached when every weighted candidate is unavailable).

Sample config

{
  "services": [
    {
      "type": "smart_dns_pool",
      "tag":  "dnstt-pool",
      "listen": "127.0.0.1",
      "listen_port": 19876,
      "load_balance": "weighted",
      "upstreams": [
        { "type": "udp",   "address": "1.1.1.1:53",                              "weight": 5 },
        { "type": "doh",   "address": "https://cloudflare-dns.com/dns-query",    "weight": 3 },
        { "type": "tls",   "address": "1.1.1.1:853",                             "weight": 2 },
        { "type": "udp",   "address": "8.8.8.8:53",                              "weight": 0 }
      ]
    }
  ],
  "outbounds": [
    { "type": "dnstt", "tag": "dnstt-out",
      "resolvers": [ { "type": "udp", "address": "127.0.0.1:19876" } ],
      "...": "..."
    }
  ]
}

Plumbing

File Change
constant/proxy.go new TypeSmartDNSPool = "smart_dns_pool"
option/smart_dns_pool.go SmartDNSPoolServiceOptions + SmartDNSPoolUpstreamOptions (listen + upstreams + LB / timeout knobs)
service/smart_dns_pool/service.go Service implementing adapter.Service: Start binds the listener and starts the multidns.Manager with all upstreams; Close shuts the server and the manager cleanly
service/smart_dns_pool/service_test.go full integration tests (see below)
include/registry.go smartdnspool.RegisterService(registry)
go.mod adds github.com/hiddify/hmrd_multi_resolver_dns require

Test plan

  • go vet ./service/smart_dns_pool/ ./include/ ./option/ ./constant/ clean
  • go build ./service/smart_dns_pool/ ./include/ clean
  • go test -race -count=1 ./service/smart_dns_pool/:
    • TestSmartDNSPool_EndToEnd — builds service from real option.SmartDNSPoolServiceOptions, sends an A query to 127.0.0.1:<port>, verifies the answer routed through one of the configured upstreams. Round-robin reaches both upstreams over 10 queries.
    • TestSmartDNSPool_FailoverWithinDeadline — one dropping + one healthy upstream; queries through the local listener still get answered (the headline dnstt failure mode).
    • TestSmartDNSPool_RejectsBadConfig — validation surfaces at NewService time (no upstreams, missing port, unsupported upstream type).
  • Library carries 46 tests of its own under go test -race covering failover, AIMD, prober recovery, custom dialer on every protocol, and the net.PacketConn server path this service relies on.

Dependency note

go.mod pins:

github.com/hiddify/hmrd_multi_resolver_dns v0.0.0-20260427043414-4f1e2d512cf6

That's the tip of feat/packetconn-surface — the open library PR adding the net.PacketConn surface this service uses. Once that PR merges (and ideally a tag is cut on the library), this require can bump to the real version.

Merge order

  1. hiddify/hmrd_multi_resolver_dns#4 — adds the net.PacketConn surface to the library
  2. (Optional) tag a release on the library
  3. This PR — bump the require to the tagged version, then merge into extended

quanthiddify and others added 2 commits April 27, 2026 09:53
…streams

Adds a sing-box service `type: smart_dns_pool` that listens on a local
UDP+TCP address and fans incoming DNS queries out to many configured
recursive resolvers (UDP / TCP / DoT / DoH) using
github.com/hiddify/hmrd_multi_resolver_dns under the hood. The smart pool
provides:

  - deadline-aware retry across resolvers within a single query,
  - AIMD rate-limit throttling per resolver (REFUSED / SERVFAIL / HTTP
    429 all decrement the cap; sustained success grows it back),
  - a recovery prober that re-issues the offending query against a
    "down" resolver every few seconds until it answers,
  - round-robin / weighted / lowest-latency LB strategies.

Why: dnstt's tunnel sends DNS queries to a single recursive resolver
(e.g. `8.8.8.8`). When that resolver gets rate-limited or blocked by
the censor, the tunnel stalls until the operator rotates manually.
With this service, the operator configures the recursive resolvers
they want to distribute load across once, then sets dnstt's
`resolvers` to `udp://127.0.0.1:<listen_port>` instead of a fixed IP.
dnstt sees a normal local resolver; the pool transparently handles
failover and AIMD throttling for the real upstreams.

Sample config:

  {
    "services": [
      {
        "type": "smart_dns_pool",
        "tag":  "dnstt-pool",
        "listen": "127.0.0.1",
        "listen_port": 19876,
        "load_balance": "weighted",
        "upstreams": [
          { "type": "udp",   "address": "1.1.1.1:53",                              "weight": 5 },
          { "type": "doh",   "address": "https://cloudflare-dns.com/dns-query",     "weight": 3 },
          { "type": "tls",   "address": "1.1.1.1:853",                              "weight": 2 },
          { "type": "udp",   "address": "8.8.8.8:53",                               "weight": 0 }
        ]
      }
    ],
    "outbounds": [
      { "type": "dnstt",
        "tag":  "dnstt-out",
        "resolvers": [ { "type": "udp", "address": "127.0.0.1:19876" } ],
        ...
      }
    ]
  }

`weight: 0` means the upstream is a pure fallback — only reached when
every weighted candidate is unavailable.

Plumbing:
  - constant/proxy.go        — TypeSmartDNSPool = "smart_dns_pool"
  - option/smart_dns_pool.go — SmartDNSPoolServiceOptions, the upstream
                                struct, and timeout / LB knobs
  - service/smart_dns_pool/  — Service implementing adapter.Service:
                                Start binds the listener and starts the
                                multidns.Manager with all upstreams;
                                Close shuts the server and the manager
                                cleanly.
  - include/registry.go      — smartdnspool.RegisterService(registry)

Tests (`go test -race -count=1 ./service/smart_dns_pool/`):
  - TestSmartDNSPool_EndToEnd            full integration: builds the
                                          service from real options, dig
                                          @127.0.0.1 succeeds, round-robin
                                          reaches both upstreams.
  - TestSmartDNSPool_FailoverWithinDeadline  one dropping + one healthy
                                              upstream; queries through
                                              the local listener still get
                                              answered (the headline
                                              dnstt use case).
  - TestSmartDNSPool_RejectsBadConfig    validation surfaces at NewService
                                          time (no upstreams, missing port,
                                          unsupported type).

`go.mod` pins github.com/hiddify/hmrd_multi_resolver_dns at the open
feat/packetconn-surface PR's SHA (v0.0.0-20260427043414-4f1e2d512cf6);
bump to a tagged version once that PR merges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR hiddify#4 (the net.PacketConn surface) merged into hmrd_multi_resolver_dns
main as 838b38fc. Switching the require from the feat-branch
pseudo-version to a main-tip pseudo-version so the reference is from
the canonical branch instead of a now-merged feature branch:

  github.com/hiddify/hmrd_multi_resolver_dns
    v0.0.0-20260427043414-4f1e2d512cf6  →
    v0.0.0-20260427063951-838b38fc5cf9

Equivalent code (the old SHA is reachable from the new one), but a
cleaner audit trail. Tests still pass clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant