Files
dnshelper/specs/002-server-resolution-modes/research.md
T

29 KiB
Raw Blame History

Research: Server Resolution Modes

Feature Branch: 002-server-resolution-modes
Date: 2026-03-04


R-000: Parallel DNS Fan-out Patterns (Go 1.20)

Decision: Goroutines + Buffered Channel

Use goroutines writing to a buffered channel sized to N×M (resolvers × label levels) with a collecting loop that reads exactly N×M results. No mutex, no WaitGroup needed.

Rationale

  • Each goroutine writes exactly one result; the collector reads exactly N×M values. Bounded channel means goroutines never block.
  • Alternative (WaitGroup + shared slice + Mutex) adds unnecessary synchronization complexity for no benefit.

Per-Query vs Overall Timeout

Use nested context.WithTimeout — parent context sets overall operation deadline (10s), child contexts set per-query deadline (3s from -timeout flag). Parent cancellation propagates to all children automatically. All context APIs available since Go 1.7.

UDP Transport with Context (Go 1.20)

Use net.Dialer.DialContext() (available since Go 1.7) for context-aware dialing. After dialing, use conn.SetDeadline() derived from context deadline. There is no conn.ReadContext() in Go 1.20 — SetDeadline is the standard approach.

NS Query Construction

Identical to existing A query in resolver.go — only Type changes from dnsmessage.TypeA to dnsmessage.TypeNS. Parse responses with parser.NSResource() which returns NSResource{NS: dnsmessage.Name}.

Authoritative Query (RD=false)

Set RecursionDesired: false in dnsmessage.Header. Check Authoritative flag in response for validation/logging. Handle RCodeRefused as "try next NS". Handle empty answers with RCodeSuccess as possible referral — try next NS.

CNAME in NS Responses

Uncommon but possible (zone misconfiguration or CDN aliases). Extend NS response parser to return both NS records and CNAME target. If CNAME received instead of NS, the caller restarts NS discovery for the CNAME target domain. Shared depth counter prevents infinite loops (max 10, matching existing maxCNAMEDepth).

Label-Level Extraction

Split hostname on ., generate all suffixes with ≥2 labels (from most-specific to least-specific). host1.sub.example.com["host1.sub.example.com", "sub.example.com", "example.com"]. Single-label hostnames return nil. No public suffix list needed — specificity-based NS selection handles multi-part TLDs naturally.

Go 1.20 Limitations

  • context.WithTimeoutCause (1.21+): Not available. Use context.WithTimeout + manual error wrapping.
  • slices package (1.21+): Not available. Use sort.Slice for sorting.
  • maps package (1.21+): Not available. Manual map iteration.
  • slog (1.21+): Not available. Use fmt.Fprintf(os.Stderr, ...) for verbose output.
  • errors.Join (1.20): Available for aggregating errors from parallel queries.
  • Generics (1.18): Available in Go 1.20 but not required for this feature.

Alternatives Considered

  • sync.WaitGroup + shared slice: Requires mutex, separate done signal. More moving parts for the same result. Rejected.
  • errgroup from golang.org/x/sync: Good API but adds an approved-but-unnecessary dependency. The buffered channel pattern is simpler and requires no imports. Rejected.
  • Manual deadline tracking with time.After: Error-prone, doesn't propagate cancellation. context.WithTimeout is superior. Rejected.

R-001: Windows — Local Resolver and Gateway Discovery

Decision

Use a three-command netsh pipeline via os/exec:

  1. netsh interface ipv4 show route → find the 0.0.0.0/0 row to extract the interface index (Idx) and gateway IP.
  2. netsh interface ipv4 show interfaces → map the interface index to an interface name.
  3. netsh interface ipv4 show dnsservers name="<interface name>" → extract DNS server IPs.

Rationale

  • netsh is present on every Windows version from Vista through Windows 11/Server 2025. It does not require PowerShell, elevated privileges, or any runtime beyond the base OS.
  • The numeric data we parse (IP addresses, interface indices, 0.0.0.0/0 prefix) is never localized. Column headers and labels are localized on non-English Windows, but we do not parse them — we parse by pattern (IP regex, prefix match) and by column position.
  • Each command returns rapidly (< 50ms) and produces small output.

Go 1.20 Compatibility

  • os/exec.Command(), cmd.Output(): available since Go 1.0.
  • strings, regexp, bufio, net: all available in Go 1.20.
  • No CGo, no WMI bindings, no PowerShell dependency.

Output Formats and Parsing Strategy

Step 1: Default Route — netsh interface ipv4 show route

Verified output (live, 2026-03-04):

Publish  Type      Met  Prefix                    Idx  Gateway/Interface Name
-------  --------  ---  ------------------------  ---  ------------------------
No       Manual    0    0.0.0.0/0                   4  10.26.1.1
No       System    256  10.26.0.0/16                4  vEthernet (Bridge)
No       System    256  127.0.0.0/8                 1  Loopback Pseudo-Interface 1

Parsing:

  • Scan each line for 0.0.0.0/0 (literal match; never localized).
  • When found, extract fields by splitting on whitespace. The row format is:
    <Publish> <Type> <Metric> <Prefix> <Idx> <Gateway or Interface Name>
  • Field 4 (0-indexed) is the interface index. Field 5+ is the gateway IP (an IP address) or the interface name (a string with spaces). We only need the gateway when it's an IP address; for the 0.0.0.0/0 route there is always a gateway IP.
  • Use a regex on the gateway field to match an IPv4 address: (\d{1,3}\.){3}\d{1,3}.
  • If multiple 0.0.0.0/0 rows exist (e.g., VPN active), take the one with the lowest metric (field 2).

Extracted values: Interface Index (int), Gateway IP (string).

Step 2: Interface Name — netsh interface ipv4 show interfaces

Verified output (live, 2026-03-04):

Idx     Met         MTU          State                Name
---  ----------  ----------  ------------  ---------------------------
  1          75  4294967295  connected     Loopback Pseudo-Interface 1
 13           1        1500  disconnected  Local Area Connection
  4          25        1500  connected     vEthernet (Bridge)

Parsing:

  • Skip header lines (first two).
  • Split each line on whitespace. Field 0 is Idx, field 4+ is the interface name (may contain spaces — take everything from field 4 onward, trimmed).
  • Match the row where Idx equals the interface index from Step 1.

Extracted value: Interface Name (string).

Step 3: DNS Servers — netsh interface ipv4 show dnsservers name="<name>"

Verified output (live, 2026-03-04, DHCP-configured):

Configuration for interface "vEthernet (Bridge)"
    DNS servers configured through DHCP:  10.26.1.1
    Register with which suffix:           Primary only

Known format for static configuration with multiple servers:

Configuration for interface "Ethernet"
    Statically Configured DNS Servers:    10.0.0.53
                                          8.8.8.8
                                          8.8.4.4
    Register with which suffix:           Primary only

Parsing:

  • Do not parse the label text (it is localized). Instead, scan each line for an IPv4 address regex: ^\s*(?:\S.*:\s+)?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s*$.
  • This matches both the first DNS server (after the label) and continuation lines (indented IPs on subsequent lines).
  • Validate each match with net.ParseIP() to reject false positives.
  • Collect all IPs in order — the first is primary, second is secondary, etc.

Extracted values: Ordered list of DNS server IPs.

Alternatives Considered

  1. ipconfig /all: Outputs all adapter info in one shot, but labels ("Default Gateway", "DNS Servers") are fully localized on non-English Windows. Parsing is fragile because there is no fixed column format — values can wrap to multiple lines with inconsistent indentation, and adapter sections are delimited only by blank lines. Rejected for localization and parsing reliability.

  2. route print 0.0.0.0: Returns the IPv4 route table including the default route. Shows Gateway and Interface (as IP, not name), but the output mixes freeform text (Interface List) with a columnar table, making parsing more complex than netsh interface ipv4 show route. Also, the interface is shown as an IP address, not an index or name, requiring an additional correlation step. Rejected for added complexity with no benefit.

  3. PowerShell Get-DnsClientServerAddress / Get-NetIPConfiguration: Powerful and structured, but requires PowerShell to be present and invocable. On Server Core or minimal installs, PowerShell may not be available. Invoking PowerShell from os/exec incurs a ~500ms startup penalty. The output of Get-NetIPConfiguration is CIM object formatting (e.g., MSFT_NetRoute (InstanceID = ...)) that is not human-parseable without -Format switches. Rejected for startup cost and deployment fragility.

  4. Registry (HKLM\...\Tcpip\Parameters\Interfaces): The NameServer (static) and DhcpNameServer (DHCP) registry values contain DNS server IPs, and DhcpDefaultGateway contains the gateway. This is locale-independent and fast. However, correlating the interface GUID with the interface that holds the default route requires reading the route table anyway (the registry stores routes per-interface-GUID, not in a centralized table). The approach was considered as a backup strategy but rejected for primary use because the GUID→default-route correlation is complex and brittle. It remains a viable fallback if netsh is ever unavailable.

  5. WMI/CIM (Go bindings): No maintained Go WMI library works under Go 1.20. github.com/StackExchange/wmi requires go-ole which has compatibility issues. Out of scope per Go 1.20 constraint.

Edge Cases

Case Handling
DHCP configuration Verified: netsh shows DHCP-assigned DNS identically to static — just the label differs. Our IP-regex parsing handles both.
Multiple default routes (VPN, dual NIC) Parse all 0.0.0.0/0 rows, select the one with the lowest metric. This matches Windows routing behavior.
No default route No 0.0.0.0/0 row found. Return empty resolver list and empty gateway. FR-009 handles gracefully.
No DNS servers on default-route adapter Step 3 output contains no IP addresses. Return empty resolver list.
IPv6-only adapter We query ipv4 show route. No IPv4 default route found → return empty. IPv6 DNS discovery is out of scope.
Interface name with spaces/parens Verified: interface names like vEthernet (Bridge) work correctly when quoted in netsh commands. Always quote the name.
netsh not found (Server Nano/Container) exec.Command returns error. Return empty list. FR-009 handles gracefully.
Localized Windows All parsed data (IPs, indices, 0.0.0.0/0) is numeric/standard notation. Label text is ignored. Safe on all locales.

R-002: Linux — Local Resolver and Gateway Discovery

Decision

Use a two-phase approach:

  1. ip route show default → extract gateway IP and interface name.
  2. Parse /etc/resolv.conf for nameserver lines → extract DNS server IPs.
  3. If all nameservers are stub-resolver addresses (127.0.0.53, 127.0.0.1, 127.0.1.1), attempt resolvectl status <interface> to discover upstream DNS servers.

Rationale

  • ip route is part of iproute2, present on every mainstream Linux distribution (Debian, Ubuntu, RHEL, Fedora, Arch, Alpine, SUSE) since ~2010. It replaced the deprecated route command.
  • /etc/resolv.conf is the POSIX-standard DNS configuration file, present on every Linux system.
  • The resolvectl fallback handles the systemd-resolved case cleanly without adding complexity for non-systemd distributions.

Go 1.20 Compatibility

  • os/exec.Command(): available since Go 1.0.
  • os.Open(), bufio.Scanner: available since Go 1.0/1.1.
  • os.Readlink(): available since Go 1.0 (used for symlink detection).
  • No CGo required.

Output Formats and Parsing Strategy

Step 1: Default Route — ip route show default

Standard output:

default via 192.168.1.1 dev eth0 proto dhcp metric 100

Multiple default routes (rare):

default via 192.168.1.1 dev eth0 proto dhcp metric 100
default via 10.0.0.1 dev wlan0 proto dhcp metric 600

Parsing:

  • Split each line on whitespace.
  • Look for lines starting with default.
  • Extract via <IP> for gateway and dev <interface> for interface name.
  • If multiple default routes exist, take the one with the lowest metric (parse metric <N>).
  • The via and dev keywords are not localized (they are part of the ip command's fixed syntax).

Extracted values: Gateway IP (string), Interface Name (string).

Step 2: DNS Servers — /etc/resolv.conf

Standard format (RFC 2136):

# Generated by NetworkManager
nameserver 192.168.1.1
nameserver 8.8.8.8

systemd-resolved stub format:

# This is /run/systemd/resolve/stub-resolv.conf managed by systemd-resolved.
nameserver 127.0.0.53
options edns0 trust-ad
search lan

Parsing:

  • Read file line by line.
  • Skip lines starting with # or ; (comments).
  • For lines starting with nameserver, extract the IP address after the keyword.
  • Validate with net.ParseIP().
  • Collect in order — the order in resolv.conf represents priority.

Extracted values: Ordered list of DNS server IPs.

Step 3 (conditional): systemd-resolved — resolvectl status <interface>

Triggered when all nameservers from Step 2 are loopback addresses (127.0.0.53, 127.0.0.1, 127.0.1.1).

Standard output:

Link 2 (eth0)
      Current Scopes: DNS
DefaultRoute setting: yes
       LLMNR setting: yes
MulticastDNS setting: no
  DNSOverTLS setting: no
      DNSSEC setting: no
    DNSSEC supported: no
  Current DNS Server: 192.168.1.1
         DNS Servers: 192.168.1.1
                      8.8.8.8
          DNS Domain: lan

Parsing:

  • Look for the DNS Servers: label (or fall back to Current DNS Server: if DNS Servers: is absent).
  • Extract the IP after the colon on the DNS Servers: line.
  • Continue reading subsequent lines that are indented (continuation IPs).
  • Validate each with net.ParseIP().

Note on localization: resolvectl output is not localized — the keywords are hardcoded in the systemd source. Safe to parse literally.

Alternatives Considered

  1. resolvectl as primary approach (skip /etc/resolv.conf): Would miss DNS configuration on non-systemd systems (Alpine, older Debian, container distros, WSL1). /etc/resolv.conf is universally present. Rejected as primary; used only as fallback for stub detection.

  2. nmcli dev show <interface>: NetworkManager-specific. Returns IP4.DNS[1]: 192.168.1.1 etc. Works well on desktop Linux with NetworkManager but absent on servers, containers, and Alpine. Considered as a tertiary fallback after resolvectl but rejected to avoid cascading fallback complexity. If resolvectl fails on a stub-resolver system, the stub address itself (127.0.0.53) is still a valid local resolver — it just forwards to upstream. Acceptable for the tool's purposes.

  3. systemd-resolve --status: Deprecated alias for resolvectl status. Using resolvectl directly is forward-compatible. If resolvectl is not found, systemd-resolve could be tried, but this adds complexity for diminishing returns. Rejected.

  4. Parse /run/systemd/resolve/resolv.conf (the non-stub version): Contains actual upstream nameservers without the 127.0.0.53 stub. However, this file's existence and path are implementation details of systemd-resolved, not a stable contract. Rejected in favor of resolvectl which is a proper CLI API.

  5. route -n (deprecated net-tools command): Not present on minimal installs. ip route from iproute2 is the modern replacement and universally available. Rejected.

The systemd-resolved Question

Q: Is /etc/resolv.conf sufficient on Linux, or does systemd-resolved require special handling?

A: /etc/resolv.conf is sufficient for basic functionality, but systemd-resolved benefits from special handling for optimal results.

The detailed analysis:

Scenario /etc/resolv.conf contents Behavior if we use it as-is With resolvectl fallback
No systemd-resolved Real upstream IPs (e.g., 192.168.1.1) Correct. These are the actual DNS servers. N/A — fallback not triggered.
systemd-resolved (stub mode, default) 127.0.0.53 Functional but suboptimal. The tool queries the stub resolver, which forwards to upstream. For -server local mode, the user expects to query the network's DNS directly, not a local forwarder. For the resolver pool in default mode, the stub is fine — it provides access to the same DNS infrastructure. Discovers actual upstream servers (e.g., 192.168.1.1). Better fidelity to FR-010 ("resolvers configured on the network adapter").
systemd-resolved (non-stub symlink) Real upstream IPs Correct. /etc/resolv.conf/run/systemd/resolve/resolv.conf which has real IPs. N/A — fallback not triggered.
dnsmasq local forwarder 127.0.0.1 Similar to systemd-resolved stub. Functional but indirect. resolvectl won't help here (different subsystem). The 127.0.0.1 address is the best we can do — it's the system's DNS path.

Recommendation: Implement the resolvectl fallback to handle the common systemd-resolved stub case. For other local forwarders (dnsmasq, unbound), accept the loopback address as the local resolver — it IS the system-configured DNS path and will function correctly.

Edge Cases

Case Handling
DHCP configuration ip route shows DHCP routes identically to static. /etc/resolv.conf is updated by DHCP client or NetworkManager. Transparent.
No default route ip route show default returns empty stdout. Return empty list.
Multiple default routes Take the lowest-metric route.
/etc/resolv.conf missing os.Open() returns error. Return empty resolver list.
/etc/resolv.conf is a broken symlink os.Open() returns error. Return empty resolver list.
WSL (Windows Subsystem for Linux) /etc/resolv.conf typically contains the Windows host IP (e.g., 172.x.x.1). ip route show default works. This is the correct local resolver for WSL.
Alpine / BusyBox ip from BusyBox supports ip route show default. /etc/resolv.conf is standard. No systemd-resolved. Works correctly.
Container (Docker/Podman) /etc/resolv.conf is injected by the runtime. ip route show default works. Standard behavior.
resolvectl not found exec.LookPath("resolvectl") fails. Skip fallback, use /etc/resolv.conf entries as-is (the stub 127.0.0.53 is still functional).
ip command not found Extremely rare (would need a broken minimal install). exec.Command returns error. Return empty list.

R-003: macOS — Local Resolver and Gateway Discovery

Decision

Use a two-command approach:

  1. route -n get default → extract gateway IP and interface name.
  2. scutil --dns → extract DNS server IPs by matching resolver entries to the default-route interface.

Rationale

  • route and scutil are built-in macOS system utilities, present on every macOS version from at least 10.6 (Snow Leopard) through the latest releases.
  • scutil --dns provides the most complete DNS configuration view on macOS, including both manually configured and DHCP-assigned DNS servers, with interface correlation.
  • Neither command requires elevated privileges for reading configuration.

Go 1.20 Compatibility

  • os/exec.Command(), cmd.Output(): available since Go 1.0.
  • strings, regexp, bufio, net: all available in Go 1.20.
  • No CGo, no Objective-C runtime, no Swift.

Output Formats and Parsing Strategy

Step 1: Default Route — route -n get default

Standard output:

   route to: default
destination: default
       mask: default
    gateway: 192.168.1.1
  interface: en0
      flags: <UP,GATEWAY,DONE,STATIC,PRCLONING,AUTOCONF>
 recvpipe  sendpipe  ssthresh  rtt,msec    rttvar  hopcount      mtu     expire
       0         0         0         0         0         0      1500         0

Parsing:

  • Scan lines for gateway: → extract the IP address after the colon (trimmed).
  • Scan lines for interface: → extract the interface name after the colon (trimmed).
  • Both keywords are fixed (macOS route output is not localized).
  • Validate gateway with net.ParseIP().

Extracted values: Gateway IP (string), Interface Name (string, e.g., en0).

Step 2: DNS Servers — scutil --dns

Standard output (example with Wi-Fi on en0):

DNS configuration

resolver #1
  nameserver[0] : 192.168.1.1
  nameserver[1] : 8.8.8.8
  if_index : 6 (en0)
  flags    : Request A records
  reach    : 0x00020002 (Reachable,Directly Reachable Address)

resolver #2
  domain   : local
  options  : mdns
  timeout  : 5
  flags    : Request A records
  reach    : 0x00000000 (Not Reachable)
  order    : 300000

DNS configuration (for scoped queries)

resolver #1
  nameserver[0] : 192.168.1.1
  nameserver[1] : 8.8.8.8
  if_index : 6 (en0)
  flags    : Scoped, Request A records
  reach    : 0x00020002 (Reachable,Directly Reachable Address)

Parsing strategy:

The output is organized into resolver blocks separated by resolver #N headers. The parsing approach:

  1. Split the output into resolver blocks (delimited by resolver #<N> lines).
  2. For each block, extract:
    • Nameserver IPs: lines matching nameserver\[\d+\]\s*:\s*(\S+)
    • Interface: line matching if_index\s*:\s*\d+\s*\((\w+)\) → extract the interface name from parentheses
  3. Primary strategy: Find the resolver block in the top section (before "DNS configuration (for scoped queries)") whose if_index matches the default-route interface from Step 1. Use its nameservers.
  4. Fallback strategy: If no interface match is found, use the nameservers from resolver #1 in the top section — this is the system's primary resolver and is almost always the correct one.
  5. Ignore the "scoped queries" section (below the second DNS configuration header) — these are duplicate entries for interface-scoped resolution.
  6. Ignore resolver blocks with domain : local and options : mdns — these are mDNS/Bonjour resolvers, not general-purpose DNS.

Extracted values: Ordered list of DNS server IPs.

Alternatives Considered

  1. networksetup -getdnsservers <service>: Returns manually configured DNS servers for a named network service (e.g., "Wi-Fi", "Thunderbolt Ethernet"). Problems: (a) Service names are potentially localized. (b) Only returns manually-set DNS servers — returns "There aren't any DNS Servers set on Wi-Fi." when DNS is DHCP-assigned. (c) Requires a separate networksetup -listallhardwareports call to map the interface device (e.g., en0) to a service name. Rejected for DHCP blindness and localization risk.

  2. ipconfig getpacket <interface>: Returns the DHCP packet for an interface, including domain_name_server option with DNS IPs. Works for DHCP but does not work for statically configured DNS. Would need a separate path for static DNS. Rejected for complexity.

  3. Parse /etc/resolv.conf on macOS: macOS does have /etc/resolv.conf, but it is managed by configd and may not reflect the actual DNS configuration accurately (especially with split DNS, VPN, or multiple interfaces). scutil --dns is the authoritative source on macOS. Rejected for accuracy concerns.

  4. dns-sd -G v4 <hostname>: The dns-sd command performs mDNS/DNS-SD lookups, not general DNS configuration discovery. Not relevant. Rejected.

  5. System Configuration framework via CGo: Could call SCDynamicStoreCopyValue directly for State:/Network/Global/DNS. Would require CGo and Objective-C bridging. Violates the os/exec approach constraint and adds build complexity. Rejected.

Edge Cases

Case Handling
DHCP configuration scutil --dns shows DHCP-assigned DNS servers identically to static ones. Transparent.
Manually set DNS scutil --dns shows manually configured DNS servers. Transparent.
VPN active VPN may add resolver blocks and change the default route. route -n get default will return the VPN gateway. scutil --dns will show VPN-specific resolvers. The correct behavior is to follow the default route — if VPN is active, its DNS is the right choice.
Wi-Fi + Ethernet both active route -n get default returns the active path (typically Ethernet due to lower metric). scutil --dns interface matching selects the correct resolver block.
No default route route -n get default outputs an error or no gateway: line. Return empty list.
No DNS configured scutil --dns shows resolver blocks with no nameserver lines. Return empty list.
mDNS-only resolver entries Filtered out by ignoring blocks with domain : local + options : mdns.
Split DNS (e.g., search domains) The tool uses the primary resolver block, not domain-specific blocks. This is correct for general-purpose DNS queries.

R-004: Gateway IP Discovery (All Platforms)

Decision

Gateway IP is discovered as a byproduct of the default-route discovery command on each platform:

Platform Command Gateway extraction
Windows netsh interface ipv4 show route Gateway column of the 0.0.0.0/0 row
Linux ip route show default The IP after via keyword
macOS route -n get default The IP on the gateway: line

No additional commands needed. The gateway IP is always available from the same command that identifies the default-route interface.


R-005: Implementation Interface Design

Decision

Define a NetworkDiscoverer interface in the platform package for testability. Each platform file implements it. Tests use a fake implementation.

// NetworkInfo holds discovered network configuration.
type NetworkInfo struct {
    // DNSServers is the ordered list of DNS server IPs configured on the
    // default-route adapter. May be empty if discovery fails.
    DNSServers []string
    
    // Gateway is the default gateway IP. May be empty if no default route.
    Gateway string
    
    // Interface is the name of the adapter holding the default route.
    Interface string
}

// NetworkDiscoverer discovers local network configuration.
type NetworkDiscoverer interface {
    Discover() (NetworkInfo, error)
}

The implementations call os/exec internally. For unit tests, a FakeNetworkDiscoverer returns predetermined values, enabling deterministic testing of the resolver pool construction and mode dispatch logic without running real OS commands.

Platform-specific integration tests (build-tagged) can test the actual os/exec parsing against the real system — these run only on CI machines matching the target OS.

Error Handling

  • Each os/exec call wraps errors with context: fmt.Errorf("discovering default route: %w", err).
  • If the default-route command succeeds but returns no default route, return NetworkInfo{} with empty fields and a nil error — this is a valid state (FR-009).
  • If os/exec fails (command not found, permission denied), return NetworkInfo{} with an error. The caller (resolver pool construction) logs the error in verbose mode and proceeds with only the bootstrap resolver set.

Quick Reference

Platform Default Route + Gateway DNS Servers Commands
Windows netsh interface ipv4 show route0.0.0.0/0 row (Idx + Gateway) netsh interface ipv4 show interfaces (Idx→Name) then netsh interface ipv4 show dnsservers name="<Name>" (IPs) 3 commands
Linux ip route show defaultvia <gw> dev <iface> /etc/resolv.conf (nameserver lines); if stub (127.0.0.53), resolvectl status <iface> 1 command + 1 file read (+ 1 conditional command)
macOS route -n get defaultgateway: + interface: lines scutil --dns → nameserver lines in matching resolver block 2 commands

Confidence Assessment

Platform Approach Confidence Key Risk
Windows High — Verified on live system. netsh is the canonical non-PowerShell network CLI. Output format is stable across Windows versions. Numeric data is locale-independent. netsh deprecated in future Windows versions (no announced timeline).
Linux Highip route and /etc/resolv.conf are universal. resolvectl handles the systemd-resolved stub case. resolvectl not present on non-systemd distros; mitigated by stub-address fallback being functional.
macOS Highroute and scutil are stable system utilities. scutil --dns is the documented way to inspect DNS configuration. Apple could change scutil output format in a future macOS version (no precedent for this).