cli: add network fallback and explicit-only Internal Domain modes

Internal Domains now support three modes. "os" (network default) is
unchanged. "resolvers" becomes explicit resolver with network fallback,
the default explicit selection: the configured resolvers are tried
first, and a timeout, unreachable resolver, SERVFAIL, NXDOMAIN, REFUSED
or NOTIMP hands the query to the network resolvers - matching VPN DNS
servers, then domain-less VPN DNS servers, then the OS resolver's LAN
nameservers. A valid empty answer stays final. "resolvers_only" keeps
the previous strict behavior.

The OS step uses a new LanOnlyQueryCtx: the OS resolver then asks only
its LAN-classified nameservers (private, loopback, link-local, CGNAT)
and sends nothing when it has none, so the private name never reaches a
public nameserver, whether DHCP supplied it or it is ctrld's own public
fallback. LanQueryCtx only dropped the latter. LAN-only queries get
their own singleflight and hot-cache key, so they are never answered
with an ordinary query's public answer.

In fallback mode a configured resolver that the upstream monitor has
marked down is skipped instead of waited on, so an endpoint off the
organization network reaches the fallback without paying the
resolver's 2s timeout on every query. Nothing else marks a generated
resolver up again, so skipping one re-checks it in the background, at
most once every 30s; an answer marks it up. Strict mode never skips.

An absent mode with resolvers gets the fallback mode. An unrecognized
mode with resolvers fails closed to explicit resolver only, so a wire
value this build does not know never sends the domain to the Control D
upstream; without resolvers it is dropped. Switching between the
explicit modes is detected on refresh.

Generated upstreams are now identified by an UpstreamConfig.InternalDomain
marker that records their mode and that no configuration file can set,
instead of by the internal_ key prefix. An upstream a local ctrld.toml or
custom config defines as internal_foo, or as a copy of a generated one,
is an ordinary upstream again: it keeps the OS-resolver catch-all,
recovery and query-health grading.

The "resolvers_only" wire value is provisional until the API agrees on
it; the dashboard does not offer strict mode yet.
This commit is contained in:
Cuong Manh Le committed 2026-10-06 14:53:19 +07:00
1 parent 9064f0e758
commit 5978ddfe35
16 files changed
+1292 -142

No files matched your search

+13 -7
View File
@@ -64,25 +64,31 @@ type ResolverConfig struct {
}
// Internal Domain resolution modes. Mode is what the administrator selected;
// Resolvers is only meaningful under SplitDNSModeResolvers.
// Resolvers is only meaningful under SplitDNSModeResolvers and
// SplitDNSModeResolversOnly.
const (
// SplitDNSModeOS resolves the domain through the endpoint's own OS/default
// resolver, so it follows DHCP and VPN resolver changes.
SplitDNSModeOS = "os"
// SplitDNSModeResolvers resolves the domain through the addresses in
// Resolvers and nothing else.
// Resolvers first. When none of them answers, or they answer SERVFAIL or
// NXDOMAIN, the query falls back to the resolvers of the endpoint's active
// network and VPN. This is the default explicit selection.
SplitDNSModeResolvers = "resolvers"
// SplitDNSModeResolversOnly resolves the domain through the addresses in
// Resolvers and nothing else: their answer, or their failure, is final.
SplitDNSModeResolversOnly = "resolvers_only"
)
// SplitDNS is one organization Internal Domain: a domain suffix, the resolution
// mode the organization administrator selected for it, and the resolver
// addresses that mode may need.
//
// Mode decides the routing. Resolvers is read only under
// SplitDNSModeResolvers, so addresses left behind by an earlier selection
// cannot resurrect themselves once the administrator switches back to the OS
// resolver. An empty Mode is a deployment that predates the field; the caller
// infers the mode from Resolvers in that case.
// Mode decides the routing. Resolvers is read only under the explicit modes,
// so addresses left behind by an earlier selection cannot resurrect themselves
// once the administrator switches back to the OS resolver. An empty Mode is a
// deployment that predates the field; the caller infers the mode from
// Resolvers in that case.
type SplitDNS struct {
Domain string `json:"domain"`
Mode string `json:"mode"`
+9 -1
View File
@@ -112,6 +112,11 @@ func TestResolverConfigParsesSplitDNSMode(t *testing.T) {
"domain": "office.example.com",
"mode": "os",
"resolvers": []
},
{
"domain": "vault.example.com",
"mode": "resolvers_only",
"resolvers": ["10.0.0.55"]
}
]
}
@@ -122,7 +127,7 @@ func TestResolverConfigParsesSplitDNSMode(t *testing.T) {
t.Fatal(err)
}
got := resp.Body.Resolver.SplitDNS
if len(got) != 2 {
if len(got) != 3 {
t.Fatalf("split_dns = %+v", got)
}
if got[0].Domain != "corp.example.com" || got[0].Mode != SplitDNSModeResolvers {
@@ -137,6 +142,9 @@ func TestResolverConfigParsesSplitDNSMode(t *testing.T) {
if len(got[1].Resolvers) != 0 {
t.Errorf("[1].Resolvers = %v, want empty", got[1].Resolvers)
}
if got[2].Mode != SplitDNSModeResolversOnly || len(got[2].Resolvers) != 1 {
t.Errorf("[2] = %+v", got[2])
}
}
// A response without the field must still decode, so a deployment that predates