From 055de84f312999c6e2dcf573af065fa629e286c0 Mon Sep 17 00:00:00 2001 From: Matthew DeVenny Date: Tue, 15 Sep 2026 09:20:47 -0700 Subject: [PATCH 1/4] #126 answer systemd-resolved synthetic names in the DNS proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit systemd-resolved answers _gateway, _outbound, _localdnsstub and _localdnsproxy from local state and never sends them upstream. The redirect DNATs the stub to the proxy, and the proxy's upstream is deliberately the resolver behind resolved, so every one of them NXDOMAINed for the length of a run: REFUSED under enforce, silently under audit, where no connection is ever attempted and nothing is logged. localhost survives only because nsswitch consults /etc/hosts first. The proxy now answers them itself (pkg/dns/synthetic.go), ahead of the filter gate and the cache — resolved's own precedence — on host listeners only, and only while resolved is running, via a new network.SystemdResolvedRunning shared with the cache flush. Answer shape matches resolved: authoritative, TTL 0, A/AAAA by family, NODATA for other types, SERVFAIL when the route table cannot be read. _gateway comes from /proc/net/route and /proc/net/ipv6_route ordered by metric; _outbound is the address on the route's interface sharing the gateway's subnet. Resolution only, never egress: the answer feeds neither hostname tracking nor the firewall. Reaching the gateway stays an explicit CIDR rule. The search-expanded form gets the same treatment. With a search list in resolv.conf every stub resolver asks _gateway. before _gateway, and the filter gate REFUSED it. glibc and Go treat REFUSED as "try the next form"; c-ares ends its search on a REFUSED multi-label attempt, so _gateway was unresolvable for c-ares clients under enforce (pycares: error 6 "DNS server refused query", while getent succeeded in the same second). The proxy now answers that form NXDOMAIN itself — what the upstream says natively, since resolved does not synthesize it — with no upstream round trip and no block record for a name the client is about to abandon. Only suffixes on the host's own resolv.conf search list qualify; _gateway.example.com stays an ordinary query. Verified live in the Lima VM under enforce with query filtering and "search lan": glibc (getent), Go's pure and cgo resolvers and c-ares (pycares) all resolve _gateway and _outbound through the real DNAT; _gateway.lan answers NXDOMAIN; example.com is REFUSED alongside; the proxy records no block for either name. Signed-off-by: Matthew DeVenny --- design.md | 1 + pkg/dns/server.go | 24 ++ pkg/dns/synthetic.go | 402 +++++++++++++++++++++++++++++++ pkg/dns/synthetic_test.go | 394 ++++++++++++++++++++++++++++++ pkg/network/dns_redirect.go | 34 ++- pkg/network/dns_redirect_test.go | 43 ++++ 6 files changed, 889 insertions(+), 9 deletions(-) create mode 100644 pkg/dns/synthetic.go create mode 100644 pkg/dns/synthetic_test.go diff --git a/design.md b/design.md index 03f5625..2dcca73 100644 --- a/design.md +++ b/design.md @@ -697,6 +697,7 @@ documented residuals. - Scoped to dport 53: a full table flush would churn unrelated NAT state (Docker MASQUERADE bindings, established flows). Collateral within scope is deliberate: an in-flight UDP transaction costs one retry, while an established DNAT'd DNS-over-TCP stream is reset — its later packets miss the NAT verdict — which is the point at both call sites, since such a stream is either bypassing the proxy or aimed at a dead one. Loopback-destination entries (the DNAT'd `127.0.0.53` stub flows, direct `127.0.0.1` proxy flows) cost at most the same one-retry/reset when deleted: an in-flight reply recreates the entry — possibly reversed, which on `lo` matters not at all, since a reversed 127.x entry has loopback addresses on both sides and can never match a later external-resolver query. They stay in scope to keep the predicate simple, and the live test targets loopback for exactly that harmlessness. The proxy's own marked upstream flows re-match the mark RETURN rules on recreation - Reverse-direction guard: those costs only hold if the client speaks first. If the first packet after a delete comes from upstream (a reply in flight across the flush, a server-side segment on a DNS-over-TCP stream), conntrack re-creates the flow with upstream as ORIGINAL, stamps a null NAT binding (no nat rules face inbound), and every later client packet rides the reply direction — which never traverses nat OUTPUT — with an original tuple (dport = client's ephemeral port) no dport-53 flush can select. For a socket-reusing resolver (c-ares/Node holds one UDP socket per server) that is a persistent, invisible bypass. The redirect therefore installs `INPUT -p udp/tcp ! -i lo --sport 53 -m conntrack --ctstate NEW -j DROP` alongside the DNAT: dropping the packet destroys its unconfirmed entry, so the client's next packet is NEW forward and takes the DNAT — the already-budgeted retry/reset, now guaranteed regardless of which side speaks first. Legitimate replies ride ESTABLISHED entries and never match. Loopback is exempt (`! -i lo`): a reversed 127.x entry has loopback addresses on both sides, so it can only ever match loopback tuples — it can never capture a later external query, even though stub-destined `lo` traffic is now DNAT'd — and without the exemption a stub or proxy lookup in flight across the install flush would lose its reply and sit out the resolver timeout (5s for glibc) - Best-effort with a Warn at both call sites: idle entries age out in 30–120s, but an actively-used flow (an open DNS-over-TCP stream) refreshes its entry indefinitely — exactly the flow the flush exists to kill, which is why a failed flush is warned loudly rather than ignored + - Synthetic names (#126): systemd-resolved answers `_gateway`, `_outbound`, `_localdnsstub` and `_localdnsproxy` from local state and never sends them upstream. With the stub DNAT'd and the proxy's upstream deliberately the resolver *behind* resolved, they would NXDOMAIN for the whole run — REFUSED under enforce, silently under audit, where no connection is ever attempted and nothing is logged (`localhost` is synthetic too but survives via `/etc/hosts`). The proxy answers them itself (`pkg/dns/synthetic.go`): ahead of the filter gate and the cache, resolved's own precedence; host listeners only, since a container's native path never resolved them and the host's gateway is the wrong answer in a container netns; and only while resolved is running (`network.SystemdResolvedRunning`, the cache flush's probe), so no name is invented that the host's resolver would not have answered. The search-expanded form a stub resolver tries first (`_gateway.`, for a suffix on the host's own `resolv.conf` search list) is answered NXDOMAIN locally — what the upstream says natively — rather than REFUSED, which c-ares treats as terminal on a multi-label attempt and which therefore left `_gateway` unresolvable for c-ares clients under enforce. Resolution only — the answer feeds neither hostname tracking nor the firewall; reaching the gateway stays an explicit CIDR rule - **Sudo lockdown:** writes `/etc/sudoers.d/zz-cargowall-lockdown` with a NOPASSWD allowlist; removes the runner user from sudo-granting groups (`sudo`, `admin`, `wheel`) and the `docker` group; disables competing sudoers.d files by renaming them to `*.cargowall-disabled` - **Auto-infrastructure:** `EnsureInfraAllowed()` and `EnsureHostnameAllowed()` add rules for platform services (Azure IMDS, GitHub API, etc.) - **Logging:** `slog.Handler` that formats messages as GitHub workflow commands (`::error::`, `::warning::`, `::debug::`) diff --git a/pkg/dns/server.go b/pkg/dns/server.go index 496cbe8..dbb1f72 100644 --- a/pkg/dns/server.go +++ b/pkg/dns/server.go @@ -112,6 +112,11 @@ type Server struct { // recentDNSBlocks buffers refused QUERIES for the same reconciliation on // the DNS side (#119); see reconcileRefusedQueries. recentDNSBlocks *events.RecentDNSBlocks + + // synthetic answers systemd-resolved's synthetic names locally (#126). + // Decided once in Start, before any listener serves, so handlers read + // it without synchronization. See synthetic.go. + synthetic bool } // dnsCacheEntry holds a cached DNS response @@ -385,6 +390,10 @@ func (s *Server) Start(ctx context.Context) error { // No TTL cleanup needed - IPs persist until updated by new DNS responses // DNS cache uses lazy expiration - no cleanup goroutine needed + // Decide ahead of the listeners whether to answer resolved's synthetic + // names (#126); written here and never again. + s.synthetic = s.probeSynthetic() + // Collect all addresses to listen on allAddrs := []string{s.listenAddr} allAddrs = append(allAddrs, s.additionalAddrs...) @@ -529,6 +538,21 @@ func (s *Server) handleDNSQuery(w dns.ResponseWriter, r *dns.Msg) { "type", queryType, "upstream", s.upstream) + // systemd-resolved's synthetic names (#126) are answered locally, ahead + // of the filter gate and the cache — resolved's own precedence — and only + // on host listeners. The search-expanded form a resolver tries first is + // NXDOMAIN, as it is natively. See synthetic.go. + if s.synthetic && len(r.Question) > 0 { + if name, expanded, ok := syntheticQuery(r.Question[0].Name); ok && s.attributionMode(w) != attributeContainerIP { + if expanded { + s.answerSyntheticNXDomain(w, r, name) + } else { + s.answerSynthetic(w, r, name) + } + return + } + } + // DNS Query Filtering: Block queries for non-allowed domains (prevents // DNS tunneling). isQueryAllowed handles both the full and the // search-domain-stripped form internally with the right precedence. diff --git a/pkg/dns/synthetic.go b/pkg/dns/synthetic.go new file mode 100644 index 0000000..cfb1fcf --- /dev/null +++ b/pkg/dns/synthetic.go @@ -0,0 +1,402 @@ +// Copyright 2026 BoxBuild Inc DBA CodeCargo +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package dns + +import ( + "bufio" + "cmp" + "encoding/hex" + "errors" + "fmt" + "io" + "net" + "os" + "slices" + "strconv" + "strings" + + "github.com/miekg/dns" + + cargowallNet "github.com/code-cargo/cargowall/pkg/network" +) + +// systemd-resolved's synthetic names (#126). +// +// resolved answers these from its own state and never sends them upstream: +// "_gateway" is the default-route gateway(s) ordered by metric, "_outbound" +// the local address(es) used towards them, "_localdnsstub" and +// "_localdnsproxy" its two loopback listeners. The redirect DNATs the stub +// (127.0.0.53) to this proxy, and the proxy's upstream is deliberately the +// resolver BEHIND resolved (the action's detectDnsUpstream skips the stub so +// the proxy cannot loop into itself), so without this file every one of them +// NXDOMAINs for the length of a run — REFUSED under enforce, silently under +// audit, where no connection is ever attempted and nothing is logged. +// "localhost" is synthetic too but survives: nsswitch consults /etc/hosts +// first. +// +// Answered ahead of the filter gate and the cache, mirroring resolved's own +// precedence, and only while resolved is actually running (the same probe the +// cache flush uses), so the proxy never invents a name the host's resolver +// would not have answered. Host listeners only: a container's native path +// (embedded DNS → the host's real upstream) never resolved these either, and +// the host's gateway is the wrong answer inside a container netns anyway. +// +// The search-expanded form is answered too, with NXDOMAIN: every stub +// resolver tries "_gateway." before "_gateway" on a host whose +// resolv.conf carries a search list, so that is the first query on the wire +// for the name — see answerSyntheticNXDomain for why the rcode matters. +// +// Resolution only, never egress. The answer feeds neither hostnameIPs nor the +// firewall: reaching the gateway stays an explicit CIDR decision for the +// operator, since a route-derived allow would grant every job the host. + +const ( + syntheticGateway = "_gateway" + syntheticOutbound = "_outbound" + syntheticDNSStub = "_localdnsstub" + syntheticDNSProxy = "_localdnsproxy" +) + +// syntheticNames is the set resolved synthesizes from local state. +var syntheticNames = []string{syntheticGateway, syntheticOutbound, syntheticDNSStub, syntheticDNSProxy} + +var ( + localDNSStubIP = net.IPv4(127, 0, 0, 53) + localDNSProxyIP = net.IPv4(127, 0, 0, 54) +) + +// Route-table sources, the interface-address lookup and the client resolver +// config, vars so tests can point them at fixtures. +var ( + resolvConfPath = "/etc/resolv.conf" + procNetRoute = "/proc/net/route" + procNetIPv6Route = "/proc/net/ipv6_route" + interfaceAddrs = func(name string) ([]net.Addr, error) { + ifi, err := net.InterfaceByName(name) + if err != nil { + return nil, err + } + return ifi.Addrs() + } +) + +// Route flags shared by both /proc tables. +const ( + rtfUp = 0x1 + rtfGateway = 0x2 +) + +// syntheticQuery classifies a wire-form query name (trailing dot): the bare +// synthetic name, its search-expanded form — first label synthetic, the rest +// a suffix on the host's own search list — or neither. Only the expansions +// a resolver on THIS host would generate count: "_gateway.example.com" is an +// ordinary query, since that could be a real owner name in someone's zone. +// resolv.conf is consulted only once the first label has matched, so +// ordinary queries never touch it, and reading it per hit rather than once +// at Start keeps a DHCP-rewritten search list current. +func syntheticQuery(qname string) (name string, expanded, ok bool) { + full := strings.ToLower(strings.TrimSuffix(qname, ".")) + first, rest, hasRest := strings.Cut(full, ".") + if !slices.Contains(syntheticNames, first) { + return "", false, false + } + if !hasRest { + return first, false, true + } + if slices.Contains(hostSearchDomains(resolvConfPath), rest) { + return first, true, true + } + return "", false, false +} + +// hostSearchDomains reads the search list clients expand single-label names +// with: the last "search" or "domain" directive wins, as glibc and Go read +// it. Lowercased, trailing dots trimmed. An unreadable file is an empty +// list — no expansion is then recognised, and the query takes the ordinary +// path rather than the proxy guessing at one. +func hostSearchDomains(path string) []string { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + var domains []string + sc := bufio.NewScanner(f) + for sc.Scan() { + fields := strings.Fields(sc.Text()) + if len(fields) < 2 || (fields[0] != "search" && fields[0] != "domain") { + continue + } + domains = domains[:0] + for _, d := range fields[1:] { + if strings.HasPrefix(d, "#") || strings.HasPrefix(d, ";") { + break + } + domains = append(domains, strings.ToLower(strings.TrimSuffix(d, "."))) + } + } + return domains +} + +// probeSynthetic decides at Start whether to answer synthetic names: only +// when systemd-resolved is running. A probe failure disables the feature +// with a Warn rather than guessing either way. +func (s *Server) probeSynthetic() bool { + running, err := cargowallNet.SystemdResolvedRunning() + if err != nil { + s.logger.Warn("Could not probe systemd-resolved; not answering its synthetic names", "error", err) + return false + } + if running { + s.logger.Info("Answering systemd-resolved synthetic names locally", "names", syntheticNames) + } + return running +} + +// answerSynthetic writes the local answer for a synthetic name. The shape +// matches resolved's: authoritative, TTL 0 (the route can change mid-run), +// A/AAAA carry the addresses of that family, every other qtype is NODATA. +// A route-table read failure is SERVFAIL — "try again", which is what a +// broken resolver should say — while "no default route" is an honest empty +// answer, exactly as resolved reports it. +func (s *Server) answerSynthetic(w dns.ResponseWriter, r *dns.Msg, name string) { + m := new(dns.Msg) + m.SetReply(r) + m.Authoritative = true + + ips, err := syntheticAddrs(name) + if err != nil { + s.logger.Warn("Failed to derive synthetic DNS answer", "name", name, "error", err) + m.Rcode = dns.RcodeServerFailure + w.WriteMsg(m) + return + } + + q := r.Question[0] + for _, ip := range ips { + hdr := dns.RR_Header{Name: q.Name, Class: dns.ClassINET, Ttl: 0} + switch { + case q.Qtype == dns.TypeA && ip.To4() != nil: + hdr.Rrtype = dns.TypeA + m.Answer = append(m.Answer, &dns.A{Hdr: hdr, A: ip.To4()}) + case q.Qtype == dns.TypeAAAA && ip.To4() == nil: + hdr.Rrtype = dns.TypeAAAA + m.Answer = append(m.Answer, &dns.AAAA{Hdr: hdr, AAAA: ip.To16()}) + } + } + s.logger.Debug("DNS synthetic answer", + "name", name, + "type", dns.TypeToString[q.Qtype], + "answers", len(m.Answer)) + w.WriteMsg(m) +} + +// answerSyntheticNXDomain answers the search-expanded form of a synthetic +// name — "_gateway.lan" on a host whose resolv.conf carries "search lan". +// Every stub resolver tries the expanded forms of a single-label name +// before the name itself, so this is the first query on the wire for +// "_gateway", not a name the client wanted. Natively it reaches the upstream +// and NXDOMAINs — resolved does not synthesize it — and the proxy says the +// same thing itself: no upstream round trip, no block record for a name the +// client is about to abandon, and NXDOMAIN rather than REFUSED because +// NXDOMAIN is the one rcode every resolver treats as "try the next form". +// c-ares ends its search on a REFUSED multi-label attempt (its issue #852 +// carve-out is single-label only), so the REFUSED the filter gate returned +// here left "_gateway" unresolvable for c-ares clients under enforce while +// glibc and Go fell through to the bare name. +func (s *Server) answerSyntheticNXDomain(w dns.ResponseWriter, r *dns.Msg, name string) { + m := new(dns.Msg) + m.SetRcode(r, dns.RcodeNameError) + m.Authoritative = true + s.logger.Debug("DNS synthetic search-expanded form answered NXDOMAIN", + "query", r.Question[0].Name, + "name", name) + w.WriteMsg(m) +} + +// syntheticAddrs returns the addresses a synthetic name resolves to. +func syntheticAddrs(name string) ([]net.IP, error) { + switch name { + case syntheticDNSStub: + return []net.IP{localDNSStubIP}, nil + case syntheticDNSProxy: + return []net.IP{localDNSProxyIP}, nil + } + routes, err := defaultRoutes() + if err != nil { + return nil, err + } + if name == syntheticGateway { + ips := make([]net.IP, 0, len(routes)) + for _, rt := range routes { + ips = append(ips, rt.gateway) + } + return ips, nil + } + return outboundAddrs(routes), nil +} + +// defaultRoute is one gateway default route from the kernel tables. +type defaultRoute struct { + iface string + gateway net.IP + metric uint32 +} + +// defaultRoutes reads both route tables and returns the gateway default +// routes ordered by metric, IPv4 before IPv6 at equal metric — resolved's +// "_gateway" order. The IPv6 table is optional: it is absent when the stack +// is disabled. +func defaultRoutes() ([]defaultRoute, error) { + f, err := os.Open(procNetRoute) + if err != nil { + return nil, err + } + routes, err := parseIPv4Routes(f) + f.Close() + if err != nil { + return nil, fmt.Errorf("%s: %w", procNetRoute, err) + } + + if f, err := os.Open(procNetIPv6Route); err == nil { + v6, err := parseIPv6Routes(f) + f.Close() + if err != nil { + return nil, fmt.Errorf("%s: %w", procNetIPv6Route, err) + } + routes = append(routes, v6...) + } else if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + + slices.SortStableFunc(routes, func(a, b defaultRoute) int { return cmp.Compare(a.metric, b.metric) }) + return routes, nil +} + +// parseIPv4Routes reads /proc/net/route: one header line, then "Iface +// Destination Gateway Flags RefCnt Use Metric Mask ..." with addresses as +// little-endian hex and the counters decimal. A default route has a zero +// destination and mask; only up gateway routes count. +func parseIPv4Routes(r io.Reader) ([]defaultRoute, error) { + var routes []defaultRoute + sc := bufio.NewScanner(r) + header := true + for sc.Scan() { + if header { + header = false + continue + } + f := strings.Fields(sc.Text()) + if len(f) < 8 || f[1] != "00000000" || f[7] != "00000000" { + continue + } + flags, err := strconv.ParseUint(f[3], 16, 32) + if err != nil { + return nil, fmt.Errorf("flags %q: %w", f[3], err) + } + if flags&rtfUp == 0 || flags&rtfGateway == 0 { + continue + } + gw, err := hex.DecodeString(f[2]) + if err != nil || len(gw) != net.IPv4len { + return nil, fmt.Errorf("gateway %q: not a little-endian IPv4 address", f[2]) + } + metric, err := strconv.ParseUint(f[6], 10, 32) + if err != nil { + return nil, fmt.Errorf("metric %q: %w", f[6], err) + } + routes = append(routes, defaultRoute{ + iface: f[0], + gateway: net.IPv4(gw[3], gw[2], gw[1], gw[0]), + metric: uint32(metric), + }) + } + return routes, sc.Err() +} + +// parseIPv6Routes reads /proc/net/ipv6_route: no header, "dst dstlen src +// srclen nexthop metric refcnt use flags iface", every field hex. A default +// route has a zero destination of prefix length 0; the kernel's unreachable +// default on lo carries no gateway flag and is skipped with the rest. +func parseIPv6Routes(r io.Reader) ([]defaultRoute, error) { + var routes []defaultRoute + sc := bufio.NewScanner(r) + zero := strings.Repeat("0", 32) + for sc.Scan() { + f := strings.Fields(sc.Text()) + if len(f) < 10 || f[0] != zero || f[1] != "00" { + continue + } + flags, err := strconv.ParseUint(f[8], 16, 32) + if err != nil { + return nil, fmt.Errorf("flags %q: %w", f[8], err) + } + if flags&rtfUp == 0 || flags&rtfGateway == 0 { + continue + } + gw, err := hex.DecodeString(f[4]) + if err != nil || len(gw) != net.IPv6len { + return nil, fmt.Errorf("gateway %q: not an IPv6 address", f[4]) + } + metric, err := strconv.ParseUint(f[5], 16, 32) + if err != nil { + return nil, fmt.Errorf("metric %q: %w", f[5], err) + } + routes = append(routes, defaultRoute{iface: f[9], gateway: net.IP(gw), metric: uint32(metric)}) + } + return routes, sc.Err() +} + +// outboundAddrs derives "_outbound": for each default route, the address on +// its interface that shares the gateway's subnet — the source the kernel +// picks for traffic towards it — falling back to the interface's first +// global unicast address of that family. Order follows the routes; +// duplicates (two routes out of one interface) collapse. An interface that +// cannot be read contributes nothing rather than failing the answer. +func outboundAddrs(routes []defaultRoute) []net.IP { + var out []net.IP + seen := make(map[string]bool) + for _, rt := range routes { + addrs, err := interfaceAddrs(rt.iface) + if err != nil { + continue + } + v4 := rt.gateway.To4() != nil + var pick, fallback net.IP + for _, a := range addrs { + ipn, ok := a.(*net.IPNet) + if !ok || (ipn.IP.To4() != nil) != v4 { + continue + } + if ipn.Contains(rt.gateway) { + pick = ipn.IP + break + } + if fallback == nil && ipn.IP.IsGlobalUnicast() { + fallback = ipn.IP + } + } + if pick == nil { + pick = fallback + } + if pick != nil && !seen[pick.String()] { + seen[pick.String()] = true + out = append(out, pick) + } + } + return out +} diff --git a/pkg/dns/synthetic_test.go b/pkg/dns/synthetic_test.go new file mode 100644 index 0000000..85e86d4 --- /dev/null +++ b/pkg/dns/synthetic_test.go @@ -0,0 +1,394 @@ +// Copyright 2026 BoxBuild Inc DBA CodeCargo +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package dns + +import ( + "fmt" + "net" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/code-cargo/cargowall/pkg/config" + "github.com/code-cargo/cargowall/pkg/firewall" +) + +// Lima's /proc/net/route verbatim, plus a second default route at a LOWER +// metric out of eth1 listed AFTER it, so ordering by metric rather than +// table order is pinned. Gateways are little-endian: 0205A8C0 is 192.168.5.2. +const fixtureIPv4Routes = `Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT +eth0 00000000 0205A8C0 0003 0 0 200 00000000 0 0 0 +docker0 000011AC 00000000 0001 0 0 0 0000FFFF 0 0 0 +eth0 0005A8C0 00000000 0001 0 0 200 00FFFFFF 0 0 0 +eth1 00000000 010A0A0A 0003 0 0 100 00000000 0 0 0 +` + +// A gateway default route (flags UP|GATEWAY, metric 0x400 = 1024) beside the +// kernel's unreachable default on lo, which carries RTF_REJECT and no +// gateway bit — the row Lima actually has, and the one that must be skipped. +const fixtureIPv6Routes = `00000000000000000000000000000000 00 00000000000000000000000000000000 00 fe800000000000000000000000000001 00000400 00000001 00000000 00000003 eth0 +00000000000000000000000000000000 00 00000000000000000000000000000000 00 00000000000000000000000000000000 ffffffff 00000001 00000000 00200200 lo +` + +// withRouteFixtures points the /proc readers at fixture files. An empty v6 +// leaves that table absent, as on a host with the IPv6 stack disabled. +func withRouteFixtures(t *testing.T, v4, v6 string) { + t.Helper() + dir := t.TempDir() + prev4, prev6 := procNetRoute, procNetIPv6Route + procNetRoute = filepath.Join(dir, "route") + require.NoError(t, os.WriteFile(procNetRoute, []byte(v4), 0o644)) + procNetIPv6Route = filepath.Join(dir, "ipv6_route") + if v6 != "" { + require.NoError(t, os.WriteFile(procNetIPv6Route, []byte(v6), 0o644)) + } + t.Cleanup(func() { procNetRoute, procNetIPv6Route = prev4, prev6 }) +} + +// withInterfaceAddrs fakes the interface-address lookup from CIDRs, the +// host-IP-plus-mask shape net.Interface.Addrs returns. +func withInterfaceAddrs(t *testing.T, addrs map[string][]string) { + t.Helper() + prev := interfaceAddrs + interfaceAddrs = func(name string) ([]net.Addr, error) { + cidrs, ok := addrs[name] + if !ok { + return nil, fmt.Errorf("no such interface %q", name) + } + var out []net.Addr + for _, c := range cidrs { + ip, ipn, err := net.ParseCIDR(c) + require.NoError(t, err) + out = append(out, &net.IPNet{IP: ip, Mask: ipn.Mask}) + } + return out, nil + } + t.Cleanup(func() { interfaceAddrs = prev }) +} + +// withResolvConf points the client resolver config at a fixture; an empty +// string leaves it absent. +func withResolvConf(t *testing.T, content string) { + t.Helper() + prev := resolvConfPath + resolvConfPath = filepath.Join(t.TempDir(), "resolv.conf") + if content != "" { + require.NoError(t, os.WriteFile(resolvConfPath, []byte(content), 0o644)) + } + t.Cleanup(func() { resolvConfPath = prev }) +} + +// syntheticServer is a filtering, default-deny server with synthetic answers +// on — the configuration under which these names would otherwise be REFUSED, +// so every answer below also proves precedence over the gate. The mock +// firewall carries no expectations: any enforcement side effect would fail +// the test, pinning "resolution only". The resolver config starts with no +// search list; tests that need one set it. +func syntheticServer(t *testing.T) *Server { + t.Helper() + withResolvConf(t, "nameserver 127.0.0.53\n") + cfg := config.NewConfigManager() + require.NoError(t, cfg.LoadConfigFromRules(nil, config.ActionDeny)) + s := newTestServer(t, cfg, firewall.NewMockFirewall(t)) + s.filterQueries = true + s.synthetic = true + return s +} + +func askSynthetic(t *testing.T, s *Server, w *MockResponseWriter, name string, qtype uint16) *dns.Msg { + t.Helper() + q := new(dns.Msg) + q.SetQuestion(name, qtype) + q.Id = 4242 + w.On("WriteMsg", mock.AnythingOfType("*dns.Msg")).Return(nil).Once() + s.handleDNSQuery(w, q) + w.AssertExpectations(t) + require.NotNil(t, w.msg) + assert.Equal(t, uint16(4242), w.msg.Id) + return w.msg +} + +func answerIPs(t *testing.T, m *dns.Msg) []string { + t.Helper() + var ips []string + for _, rr := range m.Answer { + assert.Equal(t, uint32(0), rr.Header().Ttl, "synthetic answers carry TTL 0") + switch a := rr.(type) { + case *dns.A: + ips = append(ips, a.A.String()) + case *dns.AAAA: + ips = append(ips, a.AAAA.String()) + default: + t.Fatalf("unexpected RR type %T", rr) + } + } + return ips +} + +func TestHostSearchDomains(t *testing.T) { + withResolvConf(t, "# generated\nnameserver 127.0.0.53\noptions edns0 trust-ad\n"+ + "domain old.example\n"+ // superseded: last directive wins + "search LAN corp.example. # trailing comment\n") + assert.Equal(t, []string{"lan", "corp.example"}, hostSearchDomains(resolvConfPath)) + + withResolvConf(t, "") + assert.Nil(t, hostSearchDomains(resolvConfPath), "unreadable file: no expansion recognised") +} + +func TestSyntheticQuery(t *testing.T) { + withResolvConf(t, "search lan vm.blacksmith.sh\n") + for _, tc := range []struct { + qname string + want string + expanded bool + ok bool + }{ + {"_gateway.", "_gateway", false, true}, + {"_GATEWAY.", "_gateway", false, true}, + {"_outbound.", "_outbound", false, true}, + {"_localdnsstub.", "_localdnsstub", false, true}, + {"_localdnsproxy.", "_localdnsproxy", false, true}, + {"_gateway.lan.", "_gateway", true, true}, + {"_GATEWAY.LAN.", "_gateway", true, true}, + {"_outbound.vm.blacksmith.sh.", "_outbound", true, true}, + {"_gateway.example.com.", "", false, false}, // not a host search suffix + {"_gateway.blacksmith.sh.", "", false, false}, // partial suffix is not the suffix + {"gateway.lan.", "", false, false}, + {"example.com.", "", false, false}, + } { + got, expanded, ok := syntheticQuery(tc.qname) + assert.Equal(t, tc.ok, ok, tc.qname) + assert.Equal(t, tc.expanded, expanded, tc.qname) + assert.Equal(t, tc.want, got, tc.qname) + } + + withResolvConf(t, "") + _, _, ok := syntheticQuery("_gateway.lan.") + assert.False(t, ok, "with no search list the expanded form is an ordinary query") +} + +func TestParseIPv4Routes(t *testing.T) { + routes, err := parseIPv4Routes(strings.NewReader(fixtureIPv4Routes)) + require.NoError(t, err) + require.Len(t, routes, 2, "only up gateway default routes; docker0 and the link routes are skipped") + assert.Equal(t, defaultRoute{iface: "eth0", gateway: net.IPv4(192, 168, 5, 2), metric: 200}, routes[0]) + assert.Equal(t, defaultRoute{iface: "eth1", gateway: net.IPv4(10, 10, 10, 1), metric: 100}, routes[1]) +} + +func TestParseIPv6Routes(t *testing.T) { + routes, err := parseIPv6Routes(strings.NewReader(fixtureIPv6Routes)) + require.NoError(t, err) + require.Len(t, routes, 1, "the unreachable default on lo has no gateway bit") + assert.Equal(t, "eth0", routes[0].iface) + assert.Equal(t, "fe80::1", routes[0].gateway.String()) + assert.Equal(t, uint32(1024), routes[0].metric) +} + +func TestDefaultRoutes_OrderedByMetricAcrossFamilies(t *testing.T) { + withRouteFixtures(t, fixtureIPv4Routes, fixtureIPv6Routes) + routes, err := defaultRoutes() + require.NoError(t, err) + var got []string + for _, rt := range routes { + got = append(got, rt.gateway.String()) + } + assert.Equal(t, []string{"10.10.10.1", "192.168.5.2", "fe80::1"}, got) +} + +func TestDefaultRoutes_IPv6TableAbsent(t *testing.T) { + withRouteFixtures(t, fixtureIPv4Routes, "") + routes, err := defaultRoutes() + require.NoError(t, err) + assert.Len(t, routes, 2) +} + +func TestDefaultRoutes_TableUnreadable(t *testing.T) { + withRouteFixtures(t, fixtureIPv4Routes, "") + procNetRoute = filepath.Join(t.TempDir(), "absent") + _, err := defaultRoutes() + assert.Error(t, err) +} + +func TestOutboundAddrs(t *testing.T) { + withInterfaceAddrs(t, map[string][]string{ + // The gateway-subnet address wins over an earlier unrelated one. + "eth0": {"10.99.0.7/24", "192.168.5.15/24", "fe80::2/64"}, + "eth1": {"10.10.10.20/24"}, + }) + routes := []defaultRoute{ + {iface: "eth1", gateway: net.IPv4(10, 10, 10, 1), metric: 100}, + {iface: "eth0", gateway: net.IPv4(192, 168, 5, 2), metric: 200}, + {iface: "eth0", gateway: net.ParseIP("fe80::1"), metric: 1024}, + {iface: "wg0", gateway: net.IPv4(10, 8, 0, 1), metric: 2000}, // unreadable interface contributes nothing + } + var got []string + for _, ip := range outboundAddrs(routes) { + got = append(got, ip.String()) + } + assert.Equal(t, []string{"10.10.10.20", "192.168.5.15", "fe80::2"}, got) +} + +func TestOutboundAddrs_FallsBackToGlobalUnicast(t *testing.T) { + // No address shares the gateway's subnet (a /32 point-to-point uplink): + // the interface's global unicast address is the answer, not link-local. + withInterfaceAddrs(t, map[string][]string{"eth0": {"169.254.1.2/16", "203.0.113.9/32"}}) + got := outboundAddrs([]defaultRoute{{iface: "eth0", gateway: net.IPv4(203, 0, 113, 1)}}) + require.Len(t, got, 1) + assert.Equal(t, "203.0.113.9", got[0].String()) +} + +func TestHandleDNSQuery_SyntheticGatewayBeatsFilterGate(t *testing.T) { + withRouteFixtures(t, fixtureIPv4Routes, fixtureIPv6Routes) + s := syntheticServer(t) + + m := askSynthetic(t, s, &MockResponseWriter{}, "_gateway.", dns.TypeA) + assert.Equal(t, dns.RcodeSuccess, m.Rcode) + assert.True(t, m.Authoritative) + assert.Equal(t, []string{"10.10.10.1", "192.168.5.2"}, answerIPs(t, m), "gateways by metric, IPv4 only for an A query") + + // Resolution only: nothing is tracked for the name. + s.hostnameIPsMutex.RLock() + defer s.hostnameIPsMutex.RUnlock() + assert.Empty(t, s.hostnameIPs) +} + +func TestHandleDNSQuery_SyntheticGatewayAAAA(t *testing.T) { + withRouteFixtures(t, fixtureIPv4Routes, fixtureIPv6Routes) + m := askSynthetic(t, syntheticServer(t), &MockResponseWriter{}, "_gateway.", dns.TypeAAAA) + assert.Equal(t, dns.RcodeSuccess, m.Rcode) + assert.Equal(t, []string{"fe80::1"}, answerIPs(t, m)) +} + +func TestHandleDNSQuery_SyntheticNoDataForOtherTypes(t *testing.T) { + withRouteFixtures(t, fixtureIPv4Routes, fixtureIPv6Routes) + m := askSynthetic(t, syntheticServer(t), &MockResponseWriter{}, "_gateway.", dns.TypeTXT) + assert.Equal(t, dns.RcodeSuccess, m.Rcode, "NODATA, as resolved answers it") + assert.True(t, m.Authoritative) + assert.Empty(t, m.Answer) +} + +func TestHandleDNSQuery_SyntheticNoDefaultRoute(t *testing.T) { + withRouteFixtures(t, "Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT\n"+ + "docker0\t000011AC\t00000000\t0001\t0\t0\t0\t0000FFFF\t0\t0\t0\n", "") + m := askSynthetic(t, syntheticServer(t), &MockResponseWriter{}, "_gateway.", dns.TypeA) + assert.Equal(t, dns.RcodeSuccess, m.Rcode, "no default route is an honest empty answer, not a failure") + assert.Empty(t, m.Answer) +} + +func TestHandleDNSQuery_SyntheticStubAndProxy(t *testing.T) { + s := syntheticServer(t) + m := askSynthetic(t, s, &MockResponseWriter{}, "_localdnsstub.", dns.TypeA) + assert.Equal(t, []string{"127.0.0.53"}, answerIPs(t, m)) + + m = askSynthetic(t, s, &MockResponseWriter{}, "_localdnsproxy.", dns.TypeA) + assert.Equal(t, []string{"127.0.0.54"}, answerIPs(t, m)) + + m = askSynthetic(t, s, &MockResponseWriter{}, "_localdnsstub.", dns.TypeAAAA) + assert.Equal(t, dns.RcodeSuccess, m.Rcode) + assert.Empty(t, m.Answer, "the stub has no IPv6 listener") +} + +func TestHandleDNSQuery_SyntheticOutbound(t *testing.T) { + withRouteFixtures(t, fixtureIPv4Routes, "") + withInterfaceAddrs(t, map[string][]string{ + "eth0": {"192.168.5.15/24"}, + "eth1": {"10.10.10.20/24"}, + }) + m := askSynthetic(t, syntheticServer(t), &MockResponseWriter{}, "_outbound.", dns.TypeA) + assert.Equal(t, []string{"10.10.10.20", "192.168.5.15"}, answerIPs(t, m)) +} + +func TestHandleDNSQuery_SyntheticRouteTableUnreadable(t *testing.T) { + withRouteFixtures(t, fixtureIPv4Routes, "") + procNetRoute = filepath.Join(t.TempDir(), "absent") + m := askSynthetic(t, syntheticServer(t), &MockResponseWriter{}, "_gateway.", dns.TypeA) + assert.Equal(t, dns.RcodeServerFailure, m.Rcode, "a table we cannot read is 'try again', not 'no such name'") +} + +// The search-expanded form is the first query a resolver sends for the +// bare name: answered NXDOMAIN, not REFUSED — the rcode every client, +// c-ares included, treats as "try the next form". +func TestHandleDNSQuery_SyntheticExpandedIsNXDomain(t *testing.T) { + withRouteFixtures(t, fixtureIPv4Routes, "") + s := syntheticServer(t) + withResolvConf(t, "search lan vm.blacksmith.sh\n") + + for _, q := range []string{"_gateway.lan.", "_gateway.vm.blacksmith.sh.", "_outbound.lan."} { + m := askSynthetic(t, s, &MockResponseWriter{}, q, dns.TypeA) + assert.Equal(t, dns.RcodeNameError, m.Rcode, q) + assert.True(t, m.Authoritative, q) + assert.Empty(t, m.Answer, q) + } +} + +// Only the host's own expansions are answered: a suffix that is not on the +// search list, or no search list at all, leaves the query on the ordinary +// path — REFUSED here — rather than the proxy inventing a negative answer. +func TestHandleDNSQuery_SyntheticExpandedOtherwiseOrdinary(t *testing.T) { + withRouteFixtures(t, fixtureIPv4Routes, "") + s := syntheticServer(t) + + m := askSynthetic(t, s, &MockResponseWriter{}, "_gateway.lan.", dns.TypeA) + assert.Equal(t, dns.RcodeRefused, m.Rcode, "no search list") + + withResolvConf(t, "search lan\n") + m = askSynthetic(t, s, &MockResponseWriter{}, "_gateway.example.com.", dns.TypeA) + assert.Equal(t, dns.RcodeRefused, m.Rcode, "suffix not on the search list") +} + +// Off (resolved not running), the name takes the ordinary path — and under +// filtering with default deny that is REFUSED, which is exactly the enforce +// symptom in #126. Pins that the feature, not something else, is what +// answers the name above. +func TestHandleDNSQuery_SyntheticDisabledIsRefused(t *testing.T) { + withRouteFixtures(t, fixtureIPv4Routes, "") + s := syntheticServer(t) + s.synthetic = false + m := askSynthetic(t, s, &MockResponseWriter{}, "_gateway.", dns.TypeA) + assert.Equal(t, dns.RcodeRefused, m.Rcode) +} + +// containerListenerWriter answers on the docker-bridge listener. +type containerListenerWriter struct{ MockResponseWriter } + +func (c *containerListenerWriter) LocalAddr() net.Addr { + return &net.UDPAddr{IP: net.ParseIP("172.17.0.1"), Port: 53} +} + +// A container's native path never resolved these names, and the host's +// gateway is the wrong answer in a container netns: container listeners +// fall through to the ordinary path. +func TestHandleDNSQuery_SyntheticSkippedOnContainerListener(t *testing.T) { + withRouteFixtures(t, fixtureIPv4Routes, "") + s := syntheticServer(t) + s.listenerModes = map[string]listenerAttribution{"172.17.0.1": attributeContainerIP} + + q := new(dns.Msg) + q.SetQuestion("_gateway.", dns.TypeA) + w := &containerListenerWriter{} + w.On("WriteMsg", mock.AnythingOfType("*dns.Msg")).Return(nil).Once() + s.handleDNSQuery(w, q) + w.AssertExpectations(t) + require.NotNil(t, w.msg) + assert.Equal(t, dns.RcodeRefused, w.msg.Rcode) +} diff --git a/pkg/network/dns_redirect.go b/pkg/network/dns_redirect.go index 4f04813..9d6a126 100644 --- a/pkg/network/dns_redirect.go +++ b/pkg/network/dns_redirect.go @@ -121,6 +121,22 @@ func SetupDNSRedirect(logger *slog.Logger) error { // caller). A var so tests can point it at a controllable path. var resolvedRuntimeDir = "/run/systemd/resolve" +// SystemdResolvedRunning reports whether systemd-resolved is running, by the +// presence of its runtime directory. A genuine "not there" is a clean false; +// any other stat failure (EACCES, EIO) is surfaced rather than folded into +// false, so a host where resolved IS running is never misreported as one +// where it is not. Shared by the cache flush and the DNS proxy's synthetic +// answers (#126) so both read the same signal. +func SystemdResolvedRunning() (bool, error) { + if _, err := os.Stat(resolvedRuntimeDir); err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, fmt.Errorf("probing %s failed: %w", resolvedRuntimeDir, err) + } + return true, nil +} + // flushResolvedTimeout bounds the resolvectl call so a wedged systemd-resolved // (or its D-Bus endpoint) cannot stall startup before the eBPF program // attaches and the firewall begins enforcing. A var so tests can shorten it. @@ -155,15 +171,15 @@ func FlushResolvedCache(ctx context.Context, logger *slog.Logger) error { // resolvectl can be installed on hosts that don't actually run // systemd-resolved (a different resolver is in use); its runtime dir is // absent there, so skip quietly rather than warn on every startup. Only a - // genuine "not there" is benign — a stat failure such as EACCES/EIO is real - // and surfaced, mirroring the LookPath classification above (otherwise a - // host where resolved *is* running would be misreported as a correct skip). - if _, err := os.Stat(resolvedRuntimeDir); err != nil { - if errors.Is(err, os.ErrNotExist) { - logger.Debug("systemd-resolved not running; skipping cache flush", "probe", resolvedRuntimeDir) - return nil - } - return fmt.Errorf("probing %s failed: %w", resolvedRuntimeDir, err) + // genuine "not there" is benign — SystemdResolvedRunning surfaces every + // other stat failure, mirroring the LookPath classification above. + running, err := SystemdResolvedRunning() + if err != nil { + return err + } + if !running { + logger.Debug("systemd-resolved not running; skipping cache flush", "probe", resolvedRuntimeDir) + return nil } flushCtx, cancel := context.WithTimeout(ctx, flushResolvedTimeout) diff --git a/pkg/network/dns_redirect_test.go b/pkg/network/dns_redirect_test.go index 2b7c9b3..72d4ada 100644 --- a/pkg/network/dns_redirect_test.go +++ b/pkg/network/dns_redirect_test.go @@ -57,6 +57,49 @@ func withResolvedRunning(t *testing.T) { t.Cleanup(func() { resolvedRuntimeDir = prev }) } +// TestSystemdResolvedRunning: the runtime dir present → true; a genuine +// "not there" → false with no error. Both are clean outcomes; only a +// non-ENOENT stat failure is surfaced (see the permission case below). +func TestSystemdResolvedRunning(t *testing.T) { + withResolvedRunning(t) + running, err := SystemdResolvedRunning() + if err != nil || !running { + t.Fatalf("runtime dir present: want (true, nil), got (%v, %v)", running, err) + } + + prev := resolvedRuntimeDir + resolvedRuntimeDir = filepath.Join(t.TempDir(), "absent") + t.Cleanup(func() { resolvedRuntimeDir = prev }) + running, err = SystemdResolvedRunning() + if err != nil || running { + t.Fatalf("runtime dir absent: want (false, nil), got (%v, %v)", running, err) + } +} + +// TestSystemdResolvedRunning_StatFailureSurfaced: a probe that fails for any +// reason other than ENOENT must not read as "not running" — otherwise a host +// where resolved IS running would be misreported. Root bypasses directory +// permissions, so the case is only testable unprivileged. +func TestSystemdResolvedRunning_StatFailureSurfaced(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root ignores directory permissions; EACCES cannot be provoked") + } + parent := t.TempDir() + if err := os.Chmod(parent, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(parent, 0o700) }) + + prev := resolvedRuntimeDir + resolvedRuntimeDir = filepath.Join(parent, "resolve") + t.Cleanup(func() { resolvedRuntimeDir = prev }) + + running, err := SystemdResolvedRunning() + if err == nil || running { + t.Fatalf("EACCES probe: want (false, error), got (%v, %v)", running, err) + } +} + // TestFlushResolvedCache_NotInstalled: resolvectl absent from PATH → quiet skip. func TestFlushResolvedCache_NotInstalled(t *testing.T) { t.Setenv("PATH", t.TempDir()) // empty dir, no resolvectl From de279569c8de1c02dcf3d0635dcc0b78241eab01 Mon Sep 17 00:00:00 2001 From: Matthew DeVenny Date: Tue, 15 Sep 2026 09:46:20 -0700 Subject: [PATCH 2/4] #126 relay synthetic names to the resolved stub instead of cloning it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review on #128: the proxy already reaches the stub on a marked socket (the startup cache peek), and the redirect's RETURN rules pass that mark through the stub DNAT. So the bare names go to 127.0.0.53 on s.client and resolved's own answer comes back — exact rcode, flags and TTL, including NXDOMAIN with no default route and NODATA for the other family, which the hand-rolled version got wrong. The /proc route parsers and the _outbound source heuristic are gone (402 → 151 lines). Also from review: the probe runs per hit rather than as a Start-time snapshot, so a resolved restart mid-run is honoured; the intercept is IN class only, so a CHAOS query takes the ordinary path; the three-way branch in handleDNSQuery is one serveSynthetic early return, keyed off a hostListener helper rather than the attribution enum; and the comments name the constraints instead of retelling the ticket. The search-expanded form stays a local NXDOMAIN: relaying it to the stub would send it upstream unmarked and DNAT it straight back. Same live verification in the Lima VM under enforce + filtering with "search lan": c-ares, glibc, Go pure and Go cgo all resolve _gateway and _outbound through the real DNAT; the relayed answer carries resolved's own flags (aa ra ad, EDNS); no loop, no stub failure, no block record; example.com REFUSED alongside; rules torn down. Signed-off-by: Matthew DeVenny --- design.md | 2 +- pkg/dns/server.go | 26 +-- pkg/dns/synthetic.go | 399 +++++++------------------------------- pkg/dns/synthetic_test.go | 353 ++++++++++++--------------------- 4 files changed, 208 insertions(+), 572 deletions(-) diff --git a/design.md b/design.md index 2dcca73..03226ba 100644 --- a/design.md +++ b/design.md @@ -697,7 +697,7 @@ documented residuals. - Scoped to dport 53: a full table flush would churn unrelated NAT state (Docker MASQUERADE bindings, established flows). Collateral within scope is deliberate: an in-flight UDP transaction costs one retry, while an established DNAT'd DNS-over-TCP stream is reset — its later packets miss the NAT verdict — which is the point at both call sites, since such a stream is either bypassing the proxy or aimed at a dead one. Loopback-destination entries (the DNAT'd `127.0.0.53` stub flows, direct `127.0.0.1` proxy flows) cost at most the same one-retry/reset when deleted: an in-flight reply recreates the entry — possibly reversed, which on `lo` matters not at all, since a reversed 127.x entry has loopback addresses on both sides and can never match a later external-resolver query. They stay in scope to keep the predicate simple, and the live test targets loopback for exactly that harmlessness. The proxy's own marked upstream flows re-match the mark RETURN rules on recreation - Reverse-direction guard: those costs only hold if the client speaks first. If the first packet after a delete comes from upstream (a reply in flight across the flush, a server-side segment on a DNS-over-TCP stream), conntrack re-creates the flow with upstream as ORIGINAL, stamps a null NAT binding (no nat rules face inbound), and every later client packet rides the reply direction — which never traverses nat OUTPUT — with an original tuple (dport = client's ephemeral port) no dport-53 flush can select. For a socket-reusing resolver (c-ares/Node holds one UDP socket per server) that is a persistent, invisible bypass. The redirect therefore installs `INPUT -p udp/tcp ! -i lo --sport 53 -m conntrack --ctstate NEW -j DROP` alongside the DNAT: dropping the packet destroys its unconfirmed entry, so the client's next packet is NEW forward and takes the DNAT — the already-budgeted retry/reset, now guaranteed regardless of which side speaks first. Legitimate replies ride ESTABLISHED entries and never match. Loopback is exempt (`! -i lo`): a reversed 127.x entry has loopback addresses on both sides, so it can only ever match loopback tuples — it can never capture a later external query, even though stub-destined `lo` traffic is now DNAT'd — and without the exemption a stub or proxy lookup in flight across the install flush would lose its reply and sit out the resolver timeout (5s for glibc) - Best-effort with a Warn at both call sites: idle entries age out in 30–120s, but an actively-used flow (an open DNS-over-TCP stream) refreshes its entry indefinitely — exactly the flow the flush exists to kill, which is why a failed flush is warned loudly rather than ignored - - Synthetic names (#126): systemd-resolved answers `_gateway`, `_outbound`, `_localdnsstub` and `_localdnsproxy` from local state and never sends them upstream. With the stub DNAT'd and the proxy's upstream deliberately the resolver *behind* resolved, they would NXDOMAIN for the whole run — REFUSED under enforce, silently under audit, where no connection is ever attempted and nothing is logged (`localhost` is synthetic too but survives via `/etc/hosts`). The proxy answers them itself (`pkg/dns/synthetic.go`): ahead of the filter gate and the cache, resolved's own precedence; host listeners only, since a container's native path never resolved them and the host's gateway is the wrong answer in a container netns; and only while resolved is running (`network.SystemdResolvedRunning`, the cache flush's probe), so no name is invented that the host's resolver would not have answered. The search-expanded form a stub resolver tries first (`_gateway.`, for a suffix on the host's own `resolv.conf` search list) is answered NXDOMAIN locally — what the upstream says natively — rather than REFUSED, which c-ares treats as terminal on a multi-label attempt and which therefore left `_gateway` unresolvable for c-ares clients under enforce. Resolution only — the answer feeds neither hostname tracking nor the firewall; reaching the gateway stays an explicit CIDR rule + - Synthetic names (#126): systemd-resolved answers `_gateway`, `_outbound`, `_localdnsstub` and `_localdnsproxy` from local state and never sends them upstream. With the stub DNAT'd and the proxy's upstream deliberately the resolver *behind* resolved, they would NXDOMAIN for the whole run — REFUSED under enforce, silently under audit, where no connection is ever attempted and nothing is logged (`localhost` is synthetic too but survives via `/etc/hosts`). The proxy relays the bare names to the stub on its marked client socket (`pkg/dns/synthetic.go`; the mark RETURN rules pass it through the stub DNAT, as they do the startup cache peek) and writes resolved's own answer back — uncached, unenforced, never fed to hostname tracking or the firewall, so reaching the gateway stays an explicit CIDR rule. Ahead of the filter gate; host listeners and IN class only (a container's native path never resolved them); and only while resolved is running (`network.SystemdResolvedRunning`, probed per hit so a restart mid-run is honoured). The search-expanded form a stub resolver tries first (`_gateway.`, for a suffix on the host's own `resolv.conf` search list) is answered NXDOMAIN locally — what the upstream says natively — rather than REFUSED, which c-ares treats as terminal on a multi-label attempt and which therefore left `_gateway` unresolvable for c-ares clients under enforce; it is never relayed to the stub, which would send it upstream unmarked and DNAT it back into the proxy - **Sudo lockdown:** writes `/etc/sudoers.d/zz-cargowall-lockdown` with a NOPASSWD allowlist; removes the runner user from sudo-granting groups (`sudo`, `admin`, `wheel`) and the `docker` group; disables competing sudoers.d files by renaming them to `*.cargowall-disabled` - **Auto-infrastructure:** `EnsureInfraAllowed()` and `EnsureHostnameAllowed()` add rules for platform services (Azure IMDS, GitHub API, etc.) - **Logging:** `slog.Handler` that formats messages as GitHub workflow commands (`::error::`, `::warning::`, `::debug::`) diff --git a/pkg/dns/server.go b/pkg/dns/server.go index dbb1f72..eca3084 100644 --- a/pkg/dns/server.go +++ b/pkg/dns/server.go @@ -112,11 +112,6 @@ type Server struct { // recentDNSBlocks buffers refused QUERIES for the same reconciliation on // the DNS side (#119); see reconcileRefusedQueries. recentDNSBlocks *events.RecentDNSBlocks - - // synthetic answers systemd-resolved's synthetic names locally (#126). - // Decided once in Start, before any listener serves, so handlers read - // it without synchronization. See synthetic.go. - synthetic bool } // dnsCacheEntry holds a cached DNS response @@ -390,10 +385,6 @@ func (s *Server) Start(ctx context.Context) error { // No TTL cleanup needed - IPs persist until updated by new DNS responses // DNS cache uses lazy expiration - no cleanup goroutine needed - // Decide ahead of the listeners whether to answer resolved's synthetic - // names (#126); written here and never again. - s.synthetic = s.probeSynthetic() - // Collect all addresses to listen on allAddrs := []string{s.listenAddr} allAddrs = append(allAddrs, s.additionalAddrs...) @@ -538,19 +529,10 @@ func (s *Server) handleDNSQuery(w dns.ResponseWriter, r *dns.Msg) { "type", queryType, "upstream", s.upstream) - // systemd-resolved's synthetic names (#126) are answered locally, ahead - // of the filter gate and the cache — resolved's own precedence — and only - // on host listeners. The search-expanded form a resolver tries first is - // NXDOMAIN, as it is natively. See synthetic.go. - if s.synthetic && len(r.Question) > 0 { - if name, expanded, ok := syntheticQuery(r.Question[0].Name); ok && s.attributionMode(w) != attributeContainerIP { - if expanded { - s.answerSyntheticNXDomain(w, r, name) - } else { - s.answerSynthetic(w, r, name) - } - return - } + // systemd-resolved's synthetic names (#126): relayed to the stub, ahead + // of the filter gate and the cache. See synthetic.go. + if s.serveSynthetic(w, r) { + return } // DNS Query Filtering: Block queries for non-allowed domains (prevents diff --git a/pkg/dns/synthetic.go b/pkg/dns/synthetic.go index cfb1fcf..fc2c1da 100644 --- a/pkg/dns/synthetic.go +++ b/pkg/dns/synthetic.go @@ -18,15 +18,8 @@ package dns import ( "bufio" - "cmp" - "encoding/hex" - "errors" - "fmt" - "io" - "net" "os" "slices" - "strconv" "strings" "github.com/miekg/dns" @@ -34,80 +27,86 @@ import ( cargowallNet "github.com/code-cargo/cargowall/pkg/network" ) -// systemd-resolved's synthetic names (#126). -// -// resolved answers these from its own state and never sends them upstream: -// "_gateway" is the default-route gateway(s) ordered by metric, "_outbound" -// the local address(es) used towards them, "_localdnsstub" and -// "_localdnsproxy" its two loopback listeners. The redirect DNATs the stub -// (127.0.0.53) to this proxy, and the proxy's upstream is deliberately the -// resolver BEHIND resolved (the action's detectDnsUpstream skips the stub so -// the proxy cannot loop into itself), so without this file every one of them -// NXDOMAINs for the length of a run — REFUSED under enforce, silently under -// audit, where no connection is ever attempted and nothing is logged. -// "localhost" is synthetic too but survives: nsswitch consults /etc/hosts -// first. -// -// Answered ahead of the filter gate and the cache, mirroring resolved's own -// precedence, and only while resolved is actually running (the same probe the -// cache flush uses), so the proxy never invents a name the host's resolver -// would not have answered. Host listeners only: a container's native path -// (embedded DNS → the host's real upstream) never resolved these either, and -// the host's gateway is the wrong answer inside a container netns anyway. -// -// The search-expanded form is answered too, with NXDOMAIN: every stub -// resolver tries "_gateway." before "_gateway" on a host whose -// resolv.conf carries a search list, so that is the first query on the wire -// for the name — see answerSyntheticNXDomain for why the rcode matters. -// -// Resolution only, never egress. The answer feeds neither hostnameIPs nor the -// firewall: reaching the gateway stays an explicit CIDR decision for the -// operator, since a route-derived allow would grant every job the host. - -const ( - syntheticGateway = "_gateway" - syntheticOutbound = "_outbound" - syntheticDNSStub = "_localdnsstub" - syntheticDNSProxy = "_localdnsproxy" -) - -// syntheticNames is the set resolved synthesizes from local state. -var syntheticNames = []string{syntheticGateway, syntheticOutbound, syntheticDNSStub, syntheticDNSProxy} - +// syntheticNames are the names systemd-resolved synthesizes from local state +// and never sends upstream. The redirect DNATs the stub to this proxy and the +// proxy's upstream is the resolver behind resolved, so without special +// handling they NXDOMAIN for the whole run — REFUSED under enforce, silently +// under audit (#126). The proxy relays the bare names to the stub on its +// marked client socket, which the redirect's RETURN rules pass through the +// stub DNAT (as they do the startup cache peek), and writes resolved's own +// answer back: uncached, unenforced, never fed to hostnameIPs or the +// firewall. Reaching the gateway remains an explicit CIDR decision. +var syntheticNames = []string{"_gateway", "_outbound", "_localdnsstub", "_localdnsproxy"} + +// Injection points for tests: the stub address, the client resolver config +// whose search list is honoured, and the resolved-running probe. var ( - localDNSStubIP = net.IPv4(127, 0, 0, 53) - localDNSProxyIP = net.IPv4(127, 0, 0, 54) + resolvedStubAddr = "127.0.0.53:53" + resolvConfPath = "/etc/resolv.conf" + resolvedRunning = cargowallNet.SystemdResolvedRunning ) -// Route-table sources, the interface-address lookup and the client resolver -// config, vars so tests can point them at fixtures. -var ( - resolvConfPath = "/etc/resolv.conf" - procNetRoute = "/proc/net/route" - procNetIPv6Route = "/proc/net/ipv6_route" - interfaceAddrs = func(name string) ([]net.Addr, error) { - ifi, err := net.InterfaceByName(name) +// serveSynthetic answers a synthetic-name query, reporting whether it wrote +// a response. Host listeners and IN class only, ahead of the filter gate and +// the cache — resolved's own precedence. The probe runs per hit rather than +// once at Start so a resolved restart mid-run is honoured; a probe error +// reads as not running and the query takes the ordinary path. +func (s *Server) serveSynthetic(w dns.ResponseWriter, r *dns.Msg) bool { + if len(r.Question) == 0 || r.Question[0].Qclass != dns.ClassINET || !s.hostListener(w) { + return false + } + name, expanded, ok := syntheticQuery(r.Question[0].Name) + if !ok { + return false + } + if running, err := resolvedRunning(); err != nil || !running { if err != nil { - return nil, err + s.logger.Debug("systemd-resolved probe failed; synthetic name takes the ordinary path", + "name", name, "error", err) } - return ifi.Addrs() + return false } -) -// Route flags shared by both /proc tables. -const ( - rtfUp = 0x1 - rtfGateway = 0x2 -) + m := new(dns.Msg) + if expanded { + // The search-expanded form a stub resolver tries before the bare + // name. NXDOMAIN — what the upstream says natively — rather than the + // gate's REFUSED, which c-ares treats as terminal on a multi-label + // attempt and so never went on to ask "_gateway". Never relayed to + // the stub: resolved would send it upstream unmarked, and the DNAT + // would bring it straight back here. + m.SetRcode(r, dns.RcodeNameError) + m.Authoritative = true + w.WriteMsg(m) + return true + } + + resp, _, err := s.client.Exchange(r, resolvedStubAddr) + if err != nil { + s.logger.Warn("systemd-resolved stub query failed", "name", name, "error", err) + m.SetRcode(r, dns.RcodeServerFailure) + w.WriteMsg(m) + return true + } + resp.Id = r.Id + w.WriteMsg(resp) + return true +} + +// hostListener reports whether the query arrived on a listener created for +// host-netns clients rather than via AddContainerListenAddr. A container's +// native resolver path never answered these names, and the host's gateway is +// the wrong answer inside a container netns. +func (s *Server) hostListener(w dns.ResponseWriter) bool { + return s.attributionMode(w) == attributeHostSockdiag +} -// syntheticQuery classifies a wire-form query name (trailing dot): the bare -// synthetic name, its search-expanded form — first label synthetic, the rest -// a suffix on the host's own search list — or neither. Only the expansions -// a resolver on THIS host would generate count: "_gateway.example.com" is an -// ordinary query, since that could be a real owner name in someone's zone. -// resolv.conf is consulted only once the first label has matched, so -// ordinary queries never touch it, and reading it per hit rather than once -// at Start keeps a DHCP-rewritten search list current. +// syntheticQuery classifies a wire-form query name: a bare synthetic name, +// its search-expanded form (first label synthetic, remainder a suffix on the +// host's own search list), or neither. Only expansions a resolver on this +// host generates count — "_gateway.example.com" is an ordinary query. +// resolv.conf is read only once the first label has matched, per hit, so a +// DHCP-rewritten search list stays current. func syntheticQuery(qname string) (name string, expanded, ok bool) { full := strings.ToLower(strings.TrimSuffix(qname, ".")) first, rest, hasRest := strings.Cut(full, ".") @@ -125,9 +124,8 @@ func syntheticQuery(qname string) (name string, expanded, ok bool) { // hostSearchDomains reads the search list clients expand single-label names // with: the last "search" or "domain" directive wins, as glibc and Go read -// it. Lowercased, trailing dots trimmed. An unreadable file is an empty -// list — no expansion is then recognised, and the query takes the ordinary -// path rather than the proxy guessing at one. +// it. Lowercased, trailing dots trimmed. An unreadable file yields nil, so +// no expansion is recognised. func hostSearchDomains(path string) []string { f, err := os.Open(path) if err != nil { @@ -151,252 +149,3 @@ func hostSearchDomains(path string) []string { } return domains } - -// probeSynthetic decides at Start whether to answer synthetic names: only -// when systemd-resolved is running. A probe failure disables the feature -// with a Warn rather than guessing either way. -func (s *Server) probeSynthetic() bool { - running, err := cargowallNet.SystemdResolvedRunning() - if err != nil { - s.logger.Warn("Could not probe systemd-resolved; not answering its synthetic names", "error", err) - return false - } - if running { - s.logger.Info("Answering systemd-resolved synthetic names locally", "names", syntheticNames) - } - return running -} - -// answerSynthetic writes the local answer for a synthetic name. The shape -// matches resolved's: authoritative, TTL 0 (the route can change mid-run), -// A/AAAA carry the addresses of that family, every other qtype is NODATA. -// A route-table read failure is SERVFAIL — "try again", which is what a -// broken resolver should say — while "no default route" is an honest empty -// answer, exactly as resolved reports it. -func (s *Server) answerSynthetic(w dns.ResponseWriter, r *dns.Msg, name string) { - m := new(dns.Msg) - m.SetReply(r) - m.Authoritative = true - - ips, err := syntheticAddrs(name) - if err != nil { - s.logger.Warn("Failed to derive synthetic DNS answer", "name", name, "error", err) - m.Rcode = dns.RcodeServerFailure - w.WriteMsg(m) - return - } - - q := r.Question[0] - for _, ip := range ips { - hdr := dns.RR_Header{Name: q.Name, Class: dns.ClassINET, Ttl: 0} - switch { - case q.Qtype == dns.TypeA && ip.To4() != nil: - hdr.Rrtype = dns.TypeA - m.Answer = append(m.Answer, &dns.A{Hdr: hdr, A: ip.To4()}) - case q.Qtype == dns.TypeAAAA && ip.To4() == nil: - hdr.Rrtype = dns.TypeAAAA - m.Answer = append(m.Answer, &dns.AAAA{Hdr: hdr, AAAA: ip.To16()}) - } - } - s.logger.Debug("DNS synthetic answer", - "name", name, - "type", dns.TypeToString[q.Qtype], - "answers", len(m.Answer)) - w.WriteMsg(m) -} - -// answerSyntheticNXDomain answers the search-expanded form of a synthetic -// name — "_gateway.lan" on a host whose resolv.conf carries "search lan". -// Every stub resolver tries the expanded forms of a single-label name -// before the name itself, so this is the first query on the wire for -// "_gateway", not a name the client wanted. Natively it reaches the upstream -// and NXDOMAINs — resolved does not synthesize it — and the proxy says the -// same thing itself: no upstream round trip, no block record for a name the -// client is about to abandon, and NXDOMAIN rather than REFUSED because -// NXDOMAIN is the one rcode every resolver treats as "try the next form". -// c-ares ends its search on a REFUSED multi-label attempt (its issue #852 -// carve-out is single-label only), so the REFUSED the filter gate returned -// here left "_gateway" unresolvable for c-ares clients under enforce while -// glibc and Go fell through to the bare name. -func (s *Server) answerSyntheticNXDomain(w dns.ResponseWriter, r *dns.Msg, name string) { - m := new(dns.Msg) - m.SetRcode(r, dns.RcodeNameError) - m.Authoritative = true - s.logger.Debug("DNS synthetic search-expanded form answered NXDOMAIN", - "query", r.Question[0].Name, - "name", name) - w.WriteMsg(m) -} - -// syntheticAddrs returns the addresses a synthetic name resolves to. -func syntheticAddrs(name string) ([]net.IP, error) { - switch name { - case syntheticDNSStub: - return []net.IP{localDNSStubIP}, nil - case syntheticDNSProxy: - return []net.IP{localDNSProxyIP}, nil - } - routes, err := defaultRoutes() - if err != nil { - return nil, err - } - if name == syntheticGateway { - ips := make([]net.IP, 0, len(routes)) - for _, rt := range routes { - ips = append(ips, rt.gateway) - } - return ips, nil - } - return outboundAddrs(routes), nil -} - -// defaultRoute is one gateway default route from the kernel tables. -type defaultRoute struct { - iface string - gateway net.IP - metric uint32 -} - -// defaultRoutes reads both route tables and returns the gateway default -// routes ordered by metric, IPv4 before IPv6 at equal metric — resolved's -// "_gateway" order. The IPv6 table is optional: it is absent when the stack -// is disabled. -func defaultRoutes() ([]defaultRoute, error) { - f, err := os.Open(procNetRoute) - if err != nil { - return nil, err - } - routes, err := parseIPv4Routes(f) - f.Close() - if err != nil { - return nil, fmt.Errorf("%s: %w", procNetRoute, err) - } - - if f, err := os.Open(procNetIPv6Route); err == nil { - v6, err := parseIPv6Routes(f) - f.Close() - if err != nil { - return nil, fmt.Errorf("%s: %w", procNetIPv6Route, err) - } - routes = append(routes, v6...) - } else if !errors.Is(err, os.ErrNotExist) { - return nil, err - } - - slices.SortStableFunc(routes, func(a, b defaultRoute) int { return cmp.Compare(a.metric, b.metric) }) - return routes, nil -} - -// parseIPv4Routes reads /proc/net/route: one header line, then "Iface -// Destination Gateway Flags RefCnt Use Metric Mask ..." with addresses as -// little-endian hex and the counters decimal. A default route has a zero -// destination and mask; only up gateway routes count. -func parseIPv4Routes(r io.Reader) ([]defaultRoute, error) { - var routes []defaultRoute - sc := bufio.NewScanner(r) - header := true - for sc.Scan() { - if header { - header = false - continue - } - f := strings.Fields(sc.Text()) - if len(f) < 8 || f[1] != "00000000" || f[7] != "00000000" { - continue - } - flags, err := strconv.ParseUint(f[3], 16, 32) - if err != nil { - return nil, fmt.Errorf("flags %q: %w", f[3], err) - } - if flags&rtfUp == 0 || flags&rtfGateway == 0 { - continue - } - gw, err := hex.DecodeString(f[2]) - if err != nil || len(gw) != net.IPv4len { - return nil, fmt.Errorf("gateway %q: not a little-endian IPv4 address", f[2]) - } - metric, err := strconv.ParseUint(f[6], 10, 32) - if err != nil { - return nil, fmt.Errorf("metric %q: %w", f[6], err) - } - routes = append(routes, defaultRoute{ - iface: f[0], - gateway: net.IPv4(gw[3], gw[2], gw[1], gw[0]), - metric: uint32(metric), - }) - } - return routes, sc.Err() -} - -// parseIPv6Routes reads /proc/net/ipv6_route: no header, "dst dstlen src -// srclen nexthop metric refcnt use flags iface", every field hex. A default -// route has a zero destination of prefix length 0; the kernel's unreachable -// default on lo carries no gateway flag and is skipped with the rest. -func parseIPv6Routes(r io.Reader) ([]defaultRoute, error) { - var routes []defaultRoute - sc := bufio.NewScanner(r) - zero := strings.Repeat("0", 32) - for sc.Scan() { - f := strings.Fields(sc.Text()) - if len(f) < 10 || f[0] != zero || f[1] != "00" { - continue - } - flags, err := strconv.ParseUint(f[8], 16, 32) - if err != nil { - return nil, fmt.Errorf("flags %q: %w", f[8], err) - } - if flags&rtfUp == 0 || flags&rtfGateway == 0 { - continue - } - gw, err := hex.DecodeString(f[4]) - if err != nil || len(gw) != net.IPv6len { - return nil, fmt.Errorf("gateway %q: not an IPv6 address", f[4]) - } - metric, err := strconv.ParseUint(f[5], 16, 32) - if err != nil { - return nil, fmt.Errorf("metric %q: %w", f[5], err) - } - routes = append(routes, defaultRoute{iface: f[9], gateway: net.IP(gw), metric: uint32(metric)}) - } - return routes, sc.Err() -} - -// outboundAddrs derives "_outbound": for each default route, the address on -// its interface that shares the gateway's subnet — the source the kernel -// picks for traffic towards it — falling back to the interface's first -// global unicast address of that family. Order follows the routes; -// duplicates (two routes out of one interface) collapse. An interface that -// cannot be read contributes nothing rather than failing the answer. -func outboundAddrs(routes []defaultRoute) []net.IP { - var out []net.IP - seen := make(map[string]bool) - for _, rt := range routes { - addrs, err := interfaceAddrs(rt.iface) - if err != nil { - continue - } - v4 := rt.gateway.To4() != nil - var pick, fallback net.IP - for _, a := range addrs { - ipn, ok := a.(*net.IPNet) - if !ok || (ipn.IP.To4() != nil) != v4 { - continue - } - if ipn.Contains(rt.gateway) { - pick = ipn.IP - break - } - if fallback == nil && ipn.IP.IsGlobalUnicast() { - fallback = ipn.IP - } - } - if pick == nil { - pick = fallback - } - if pick != nil && !seen[pick.String()] { - seen[pick.String()] = true - out = append(out, pick) - } - } - return out -} diff --git a/pkg/dns/synthetic_test.go b/pkg/dns/synthetic_test.go index 85e86d4..1a62b39 100644 --- a/pkg/dns/synthetic_test.go +++ b/pkg/dns/synthetic_test.go @@ -17,11 +17,10 @@ package dns import ( - "fmt" "net" "os" "path/filepath" - "strings" + "sync" "testing" "github.com/miekg/dns" @@ -33,57 +32,11 @@ import ( "github.com/code-cargo/cargowall/pkg/firewall" ) -// Lima's /proc/net/route verbatim, plus a second default route at a LOWER -// metric out of eth1 listed AFTER it, so ordering by metric rather than -// table order is pinned. Gateways are little-endian: 0205A8C0 is 192.168.5.2. -const fixtureIPv4Routes = `Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT -eth0 00000000 0205A8C0 0003 0 0 200 00000000 0 0 0 -docker0 000011AC 00000000 0001 0 0 0 0000FFFF 0 0 0 -eth0 0005A8C0 00000000 0001 0 0 200 00FFFFFF 0 0 0 -eth1 00000000 010A0A0A 0003 0 0 100 00000000 0 0 0 -` - -// A gateway default route (flags UP|GATEWAY, metric 0x400 = 1024) beside the -// kernel's unreachable default on lo, which carries RTF_REJECT and no -// gateway bit — the row Lima actually has, and the one that must be skipped. -const fixtureIPv6Routes = `00000000000000000000000000000000 00 00000000000000000000000000000000 00 fe800000000000000000000000000001 00000400 00000001 00000000 00000003 eth0 -00000000000000000000000000000000 00 00000000000000000000000000000000 00 00000000000000000000000000000000 ffffffff 00000001 00000000 00200200 lo -` - -// withRouteFixtures points the /proc readers at fixture files. An empty v6 -// leaves that table absent, as on a host with the IPv6 stack disabled. -func withRouteFixtures(t *testing.T, v4, v6 string) { +func withResolvedRunning(t *testing.T, running bool) { t.Helper() - dir := t.TempDir() - prev4, prev6 := procNetRoute, procNetIPv6Route - procNetRoute = filepath.Join(dir, "route") - require.NoError(t, os.WriteFile(procNetRoute, []byte(v4), 0o644)) - procNetIPv6Route = filepath.Join(dir, "ipv6_route") - if v6 != "" { - require.NoError(t, os.WriteFile(procNetIPv6Route, []byte(v6), 0o644)) - } - t.Cleanup(func() { procNetRoute, procNetIPv6Route = prev4, prev6 }) -} - -// withInterfaceAddrs fakes the interface-address lookup from CIDRs, the -// host-IP-plus-mask shape net.Interface.Addrs returns. -func withInterfaceAddrs(t *testing.T, addrs map[string][]string) { - t.Helper() - prev := interfaceAddrs - interfaceAddrs = func(name string) ([]net.Addr, error) { - cidrs, ok := addrs[name] - if !ok { - return nil, fmt.Errorf("no such interface %q", name) - } - var out []net.Addr - for _, c := range cidrs { - ip, ipn, err := net.ParseCIDR(c) - require.NoError(t, err) - out = append(out, &net.IPNet{IP: ip, Mask: ipn.Mask}) - } - return out, nil - } - t.Cleanup(func() { interfaceAddrs = prev }) + prev := resolvedRunning + resolvedRunning = func() (bool, error) { return running, nil } + t.Cleanup(func() { resolvedRunning = prev }) } // withResolvConf points the client resolver config at a fixture; an empty @@ -98,27 +51,72 @@ func withResolvConf(t *testing.T, content string) { t.Cleanup(func() { resolvConfPath = prev }) } -// syntheticServer is a filtering, default-deny server with synthetic answers -// on — the configuration under which these names would otherwise be REFUSED, -// so every answer below also proves precedence over the gate. The mock -// firewall carries no expectations: any enforcement side effect would fail -// the test, pinning "resolution only". The resolver config starts with no -// search list; tests that need one set it. -func syntheticServer(t *testing.T) *Server { +// withFakeStub runs a stand-in for the resolved stub that answers the way +// resolved does — "_gateway" and "_outbound" A with AA and TTL 0, AAAA as +// NODATA, anything it does not know as NXDOMAIN — and returns a snapshot +// function for the questions it was asked. +func withFakeStub(t *testing.T) func() []dns.Question { + t.Helper() + pc, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + var ( + mu sync.Mutex + seen []dns.Question + ) + answers := map[string]string{"_gateway.": "192.168.5.2", "_outbound.": "192.168.5.15"} + stub := &dns.Server{ + PacketConn: pc, + Handler: dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) { + q := r.Question[0] + mu.Lock() + seen = append(seen, q) + mu.Unlock() + m := new(dns.Msg) + m.SetReply(r) + m.Authoritative = true + switch ip, known := answers[q.Name]; { + case !known: + m.Rcode = dns.RcodeNameError + case q.Qtype == dns.TypeA: + m.Answer = append(m.Answer, &dns.A{ + Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 0}, + A: net.ParseIP(ip).To4(), + }) + } + _ = w.WriteMsg(m) + }), + } + go func() { _ = stub.ActivateAndServe() }() + t.Cleanup(func() { _ = stub.Shutdown() }) + prev := resolvedStubAddr + resolvedStubAddr = pc.LocalAddr().String() + t.Cleanup(func() { resolvedStubAddr = prev }) + return func() []dns.Question { + mu.Lock() + defer mu.Unlock() + return append([]dns.Question(nil), seen...) + } +} + +// syntheticServer is a filtering, default-deny server — the configuration +// under which these names would otherwise be REFUSED, so every answer below +// also proves precedence over the gate — with resolved "running", a fake +// stub, and a resolver config without a search list. The mock firewall +// carries no expectations: any enforcement side effect fails the test. +func syntheticServer(t *testing.T) (*Server, func() []dns.Question) { t.Helper() + withResolvedRunning(t, true) withResolvConf(t, "nameserver 127.0.0.53\n") + seen := withFakeStub(t) cfg := config.NewConfigManager() require.NoError(t, cfg.LoadConfigFromRules(nil, config.ActionDeny)) s := newTestServer(t, cfg, firewall.NewMockFirewall(t)) s.filterQueries = true - s.synthetic = true - return s + return s, seen } -func askSynthetic(t *testing.T, s *Server, w *MockResponseWriter, name string, qtype uint16) *dns.Msg { +func ask(t *testing.T, s *Server, w *MockResponseWriter, q *dns.Msg) *dns.Msg { t.Helper() - q := new(dns.Msg) - q.SetQuestion(name, qtype) q.Id = 4242 w.On("WriteMsg", mock.AnythingOfType("*dns.Msg")).Return(nil).Once() s.handleDNSQuery(w, q) @@ -128,21 +126,11 @@ func askSynthetic(t *testing.T, s *Server, w *MockResponseWriter, name string, q return w.msg } -func answerIPs(t *testing.T, m *dns.Msg) []string { +func askName(t *testing.T, s *Server, name string, qtype uint16) *dns.Msg { t.Helper() - var ips []string - for _, rr := range m.Answer { - assert.Equal(t, uint32(0), rr.Header().Ttl, "synthetic answers carry TTL 0") - switch a := rr.(type) { - case *dns.A: - ips = append(ips, a.A.String()) - case *dns.AAAA: - ips = append(ips, a.AAAA.String()) - default: - t.Fatalf("unexpected RR type %T", rr) - } - } - return ips + q := new(dns.Msg) + q.SetQuestion(name, qtype) + return ask(t, s, &MockResponseWriter{}, q) } func TestHostSearchDomains(t *testing.T) { @@ -187,185 +175,102 @@ func TestSyntheticQuery(t *testing.T) { assert.False(t, ok, "with no search list the expanded form is an ordinary query") } -func TestParseIPv4Routes(t *testing.T) { - routes, err := parseIPv4Routes(strings.NewReader(fixtureIPv4Routes)) - require.NoError(t, err) - require.Len(t, routes, 2, "only up gateway default routes; docker0 and the link routes are skipped") - assert.Equal(t, defaultRoute{iface: "eth0", gateway: net.IPv4(192, 168, 5, 2), metric: 200}, routes[0]) - assert.Equal(t, defaultRoute{iface: "eth1", gateway: net.IPv4(10, 10, 10, 1), metric: 100}, routes[1]) -} - -func TestParseIPv6Routes(t *testing.T) { - routes, err := parseIPv6Routes(strings.NewReader(fixtureIPv6Routes)) - require.NoError(t, err) - require.Len(t, routes, 1, "the unreachable default on lo has no gateway bit") - assert.Equal(t, "eth0", routes[0].iface) - assert.Equal(t, "fe80::1", routes[0].gateway.String()) - assert.Equal(t, uint32(1024), routes[0].metric) -} - -func TestDefaultRoutes_OrderedByMetricAcrossFamilies(t *testing.T) { - withRouteFixtures(t, fixtureIPv4Routes, fixtureIPv6Routes) - routes, err := defaultRoutes() - require.NoError(t, err) - var got []string - for _, rt := range routes { - got = append(got, rt.gateway.String()) - } - assert.Equal(t, []string{"10.10.10.1", "192.168.5.2", "fe80::1"}, got) -} - -func TestDefaultRoutes_IPv6TableAbsent(t *testing.T) { - withRouteFixtures(t, fixtureIPv4Routes, "") - routes, err := defaultRoutes() - require.NoError(t, err) - assert.Len(t, routes, 2) -} - -func TestDefaultRoutes_TableUnreadable(t *testing.T) { - withRouteFixtures(t, fixtureIPv4Routes, "") - procNetRoute = filepath.Join(t.TempDir(), "absent") - _, err := defaultRoutes() - assert.Error(t, err) -} - -func TestOutboundAddrs(t *testing.T) { - withInterfaceAddrs(t, map[string][]string{ - // The gateway-subnet address wins over an earlier unrelated one. - "eth0": {"10.99.0.7/24", "192.168.5.15/24", "fe80::2/64"}, - "eth1": {"10.10.10.20/24"}, - }) - routes := []defaultRoute{ - {iface: "eth1", gateway: net.IPv4(10, 10, 10, 1), metric: 100}, - {iface: "eth0", gateway: net.IPv4(192, 168, 5, 2), metric: 200}, - {iface: "eth0", gateway: net.ParseIP("fe80::1"), metric: 1024}, - {iface: "wg0", gateway: net.IPv4(10, 8, 0, 1), metric: 2000}, // unreadable interface contributes nothing - } - var got []string - for _, ip := range outboundAddrs(routes) { - got = append(got, ip.String()) - } - assert.Equal(t, []string{"10.10.10.20", "192.168.5.15", "fe80::2"}, got) -} - -func TestOutboundAddrs_FallsBackToGlobalUnicast(t *testing.T) { - // No address shares the gateway's subnet (a /32 point-to-point uplink): - // the interface's global unicast address is the answer, not link-local. - withInterfaceAddrs(t, map[string][]string{"eth0": {"169.254.1.2/16", "203.0.113.9/32"}}) - got := outboundAddrs([]defaultRoute{{iface: "eth0", gateway: net.IPv4(203, 0, 113, 1)}}) - require.Len(t, got, 1) - assert.Equal(t, "203.0.113.9", got[0].String()) -} - -func TestHandleDNSQuery_SyntheticGatewayBeatsFilterGate(t *testing.T) { - withRouteFixtures(t, fixtureIPv4Routes, fixtureIPv6Routes) - s := syntheticServer(t) +// The bare name is relayed to the stub and resolved's answer written back +// as-is, ahead of a gate that would otherwise REFUSE it — and nothing is +// cached, tracked or enforced. +func TestServeSynthetic_RelaysBareNameToStub(t *testing.T) { + s, seen := syntheticServer(t) - m := askSynthetic(t, s, &MockResponseWriter{}, "_gateway.", dns.TypeA) + m := askName(t, s, "_gateway.", dns.TypeA) assert.Equal(t, dns.RcodeSuccess, m.Rcode) assert.True(t, m.Authoritative) - assert.Equal(t, []string{"10.10.10.1", "192.168.5.2"}, answerIPs(t, m), "gateways by metric, IPv4 only for an A query") + require.Len(t, m.Answer, 1) + a, ok := m.Answer[0].(*dns.A) + require.True(t, ok) + assert.Equal(t, "192.168.5.2", a.A.String()) + assert.Equal(t, uint32(0), a.Hdr.Ttl) - // Resolution only: nothing is tracked for the name. + require.Len(t, seen(), 1) + assert.Equal(t, "_gateway.", seen()[0].Name) + + _, cached := s.dnsCache.Get(s.generateCacheKey(&dns.Msg{Question: []dns.Question{{Name: "_gateway.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}})) + assert.False(t, cached, "stub answers are not cached") s.hostnameIPsMutex.RLock() defer s.hostnameIPsMutex.RUnlock() - assert.Empty(t, s.hostnameIPs) -} - -func TestHandleDNSQuery_SyntheticGatewayAAAA(t *testing.T) { - withRouteFixtures(t, fixtureIPv4Routes, fixtureIPv6Routes) - m := askSynthetic(t, syntheticServer(t), &MockResponseWriter{}, "_gateway.", dns.TypeAAAA) - assert.Equal(t, dns.RcodeSuccess, m.Rcode) - assert.Equal(t, []string{"fe80::1"}, answerIPs(t, m)) -} - -func TestHandleDNSQuery_SyntheticNoDataForOtherTypes(t *testing.T) { - withRouteFixtures(t, fixtureIPv4Routes, fixtureIPv6Routes) - m := askSynthetic(t, syntheticServer(t), &MockResponseWriter{}, "_gateway.", dns.TypeTXT) - assert.Equal(t, dns.RcodeSuccess, m.Rcode, "NODATA, as resolved answers it") - assert.True(t, m.Authoritative) - assert.Empty(t, m.Answer) + assert.Empty(t, s.hostnameIPs, "stub answers are not tracked") } -func TestHandleDNSQuery_SyntheticNoDefaultRoute(t *testing.T) { - withRouteFixtures(t, "Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT\n"+ - "docker0\t000011AC\t00000000\t0001\t0\t0\t0\t0000FFFF\t0\t0\t0\n", "") - m := askSynthetic(t, syntheticServer(t), &MockResponseWriter{}, "_gateway.", dns.TypeA) - assert.Equal(t, dns.RcodeSuccess, m.Rcode, "no default route is an honest empty answer, not a failure") - assert.Empty(t, m.Answer) -} - -func TestHandleDNSQuery_SyntheticStubAndProxy(t *testing.T) { - s := syntheticServer(t) - m := askSynthetic(t, s, &MockResponseWriter{}, "_localdnsstub.", dns.TypeA) - assert.Equal(t, []string{"127.0.0.53"}, answerIPs(t, m)) - - m = askSynthetic(t, s, &MockResponseWriter{}, "_localdnsproxy.", dns.TypeA) - assert.Equal(t, []string{"127.0.0.54"}, answerIPs(t, m)) +// Whatever resolved says is what the client gets: NODATA for a family it +// has no address for, NXDOMAIN for a name it does not synthesize. The proxy +// shapes nothing itself. +func TestServeSynthetic_RelaysStubVerdictVerbatim(t *testing.T) { + s, _ := syntheticServer(t) - m = askSynthetic(t, s, &MockResponseWriter{}, "_localdnsstub.", dns.TypeAAAA) + m := askName(t, s, "_gateway.", dns.TypeAAAA) assert.Equal(t, dns.RcodeSuccess, m.Rcode) - assert.Empty(t, m.Answer, "the stub has no IPv6 listener") -} + assert.Empty(t, m.Answer) -func TestHandleDNSQuery_SyntheticOutbound(t *testing.T) { - withRouteFixtures(t, fixtureIPv4Routes, "") - withInterfaceAddrs(t, map[string][]string{ - "eth0": {"192.168.5.15/24"}, - "eth1": {"10.10.10.20/24"}, - }) - m := askSynthetic(t, syntheticServer(t), &MockResponseWriter{}, "_outbound.", dns.TypeA) - assert.Equal(t, []string{"10.10.10.20", "192.168.5.15"}, answerIPs(t, m)) + m = askName(t, s, "_localdnsproxy.", dns.TypeA) + assert.Equal(t, dns.RcodeNameError, m.Rcode) } -func TestHandleDNSQuery_SyntheticRouteTableUnreadable(t *testing.T) { - withRouteFixtures(t, fixtureIPv4Routes, "") - procNetRoute = filepath.Join(t.TempDir(), "absent") - m := askSynthetic(t, syntheticServer(t), &MockResponseWriter{}, "_gateway.", dns.TypeA) - assert.Equal(t, dns.RcodeServerFailure, m.Rcode, "a table we cannot read is 'try again', not 'no such name'") +func TestServeSynthetic_StubUnreachableIsServfail(t *testing.T) { + s, _ := syntheticServer(t) + resolvedStubAddr = "127.0.0.1:1" // nothing listens; UDP gets ECONNREFUSED at once + m := askName(t, s, "_gateway.", dns.TypeA) + assert.Equal(t, dns.RcodeServerFailure, m.Rcode) } // The search-expanded form is the first query a resolver sends for the -// bare name: answered NXDOMAIN, not REFUSED — the rcode every client, -// c-ares included, treats as "try the next form". -func TestHandleDNSQuery_SyntheticExpandedIsNXDomain(t *testing.T) { - withRouteFixtures(t, fixtureIPv4Routes, "") - s := syntheticServer(t) +// bare name: NXDOMAIN, not REFUSED — the rcode every client, c-ares +// included, treats as "try the next form" — and never relayed to the stub. +func TestServeSynthetic_ExpandedIsNXDomain(t *testing.T) { + s, seen := syntheticServer(t) withResolvConf(t, "search lan vm.blacksmith.sh\n") for _, q := range []string{"_gateway.lan.", "_gateway.vm.blacksmith.sh.", "_outbound.lan."} { - m := askSynthetic(t, s, &MockResponseWriter{}, q, dns.TypeA) + m := askName(t, s, q, dns.TypeA) assert.Equal(t, dns.RcodeNameError, m.Rcode, q) assert.True(t, m.Authoritative, q) assert.Empty(t, m.Answer, q) } + assert.Empty(t, seen(), "expanded forms never reach the stub") } // Only the host's own expansions are answered: a suffix that is not on the // search list, or no search list at all, leaves the query on the ordinary // path — REFUSED here — rather than the proxy inventing a negative answer. -func TestHandleDNSQuery_SyntheticExpandedOtherwiseOrdinary(t *testing.T) { - withRouteFixtures(t, fixtureIPv4Routes, "") - s := syntheticServer(t) +func TestServeSynthetic_ExpandedOtherwiseOrdinary(t *testing.T) { + s, _ := syntheticServer(t) - m := askSynthetic(t, s, &MockResponseWriter{}, "_gateway.lan.", dns.TypeA) + m := askName(t, s, "_gateway.lan.", dns.TypeA) assert.Equal(t, dns.RcodeRefused, m.Rcode, "no search list") withResolvConf(t, "search lan\n") - m = askSynthetic(t, s, &MockResponseWriter{}, "_gateway.example.com.", dns.TypeA) + m = askName(t, s, "_gateway.example.com.", dns.TypeA) assert.Equal(t, dns.RcodeRefused, m.Rcode, "suffix not on the search list") } -// Off (resolved not running), the name takes the ordinary path — and under -// filtering with default deny that is REFUSED, which is exactly the enforce -// symptom in #126. Pins that the feature, not something else, is what -// answers the name above. -func TestHandleDNSQuery_SyntheticDisabledIsRefused(t *testing.T) { - withRouteFixtures(t, fixtureIPv4Routes, "") - s := syntheticServer(t) - s.synthetic = false - m := askSynthetic(t, s, &MockResponseWriter{}, "_gateway.", dns.TypeA) +// With resolved not running the name takes the ordinary path — and under +// filtering with default deny that is REFUSED, the enforce symptom in #126. +// Pins that the relay, not something else, answers the name above, and that +// the stub is not consulted on a host that has none. +func TestServeSynthetic_ResolvedNotRunningIsOrdinary(t *testing.T) { + s, seen := syntheticServer(t) + withResolvedRunning(t, false) + m := askName(t, s, "_gateway.", dns.TypeA) + assert.Equal(t, dns.RcodeRefused, m.Rcode) + assert.Empty(t, seen()) +} + +func TestServeSynthetic_NonINClassIsOrdinary(t *testing.T) { + s, seen := syntheticServer(t) + q := new(dns.Msg) + q.SetQuestion("_gateway.", dns.TypeA) + q.Question[0].Qclass = dns.ClassCHAOS + m := ask(t, s, &MockResponseWriter{}, q) assert.Equal(t, dns.RcodeRefused, m.Rcode) + assert.Empty(t, seen()) } // containerListenerWriter answers on the docker-bridge listener. @@ -378,9 +283,8 @@ func (c *containerListenerWriter) LocalAddr() net.Addr { // A container's native path never resolved these names, and the host's // gateway is the wrong answer in a container netns: container listeners // fall through to the ordinary path. -func TestHandleDNSQuery_SyntheticSkippedOnContainerListener(t *testing.T) { - withRouteFixtures(t, fixtureIPv4Routes, "") - s := syntheticServer(t) +func TestServeSynthetic_ContainerListenerIsOrdinary(t *testing.T) { + s, seen := syntheticServer(t) s.listenerModes = map[string]listenerAttribution{"172.17.0.1": attributeContainerIP} q := new(dns.Msg) @@ -391,4 +295,5 @@ func TestHandleDNSQuery_SyntheticSkippedOnContainerListener(t *testing.T) { w.AssertExpectations(t) require.NotNil(t, w.msg) assert.Equal(t, dns.RcodeRefused, w.msg.Rcode) + assert.Empty(t, seen()) } From 09bfe23231ed278bee4dccc099b1fa5c6b0d6522 Mon Sep 17 00:00:00 2001 From: Matthew DeVenny Date: Tue, 15 Sep 2026 10:26:58 -0700 Subject: [PATCH 3/4] #126 trim synthetic.go comments to the two constraints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review pass: the comments still restated design.md around an ~80-line implementation. Kept one-line WHYs where the code would otherwise look wrong — the expanded form is NXDOMAIN because REFUSED on a multi-label attempt aborts c-ares' search before the bare name, and it is not relayed because the stub's unmarked upstream query would DNAT straight back — and dropped the rest. No code change. Signed-off-by: Matthew DeVenny --- pkg/dns/server.go | 4 ++-- pkg/dns/synthetic.go | 48 ++++++++++++++------------------------------ 2 files changed, 17 insertions(+), 35 deletions(-) diff --git a/pkg/dns/server.go b/pkg/dns/server.go index eca3084..3881b2b 100644 --- a/pkg/dns/server.go +++ b/pkg/dns/server.go @@ -529,8 +529,8 @@ func (s *Server) handleDNSQuery(w dns.ResponseWriter, r *dns.Msg) { "type", queryType, "upstream", s.upstream) - // systemd-resolved's synthetic names (#126): relayed to the stub, ahead - // of the filter gate and the cache. See synthetic.go. + // systemd-resolved's synthetic names are relayed to the stub, ahead of + // the filter gate and the cache. if s.serveSynthetic(w, r) { return } diff --git a/pkg/dns/synthetic.go b/pkg/dns/synthetic.go index fc2c1da..38c4974 100644 --- a/pkg/dns/synthetic.go +++ b/pkg/dns/synthetic.go @@ -27,30 +27,21 @@ import ( cargowallNet "github.com/code-cargo/cargowall/pkg/network" ) -// syntheticNames are the names systemd-resolved synthesizes from local state -// and never sends upstream. The redirect DNATs the stub to this proxy and the -// proxy's upstream is the resolver behind resolved, so without special -// handling they NXDOMAIN for the whole run — REFUSED under enforce, silently -// under audit (#126). The proxy relays the bare names to the stub on its -// marked client socket, which the redirect's RETURN rules pass through the -// stub DNAT (as they do the startup cache peek), and writes resolved's own -// answer back: uncached, unenforced, never fed to hostnameIPs or the -// firewall. Reaching the gateway remains an explicit CIDR decision. +// syntheticNames are the names systemd-resolved synthesizes locally (#126). +// The proxy relays them to the stub on its marked client and writes +// resolved's answer back uncached and unenforced: it never feeds +// hostnameIPs or the firewall. var syntheticNames = []string{"_gateway", "_outbound", "_localdnsstub", "_localdnsproxy"} -// Injection points for tests: the stub address, the client resolver config -// whose search list is honoured, and the resolved-running probe. +// Injection points for tests. var ( resolvedStubAddr = "127.0.0.53:53" resolvConfPath = "/etc/resolv.conf" resolvedRunning = cargowallNet.SystemdResolvedRunning ) -// serveSynthetic answers a synthetic-name query, reporting whether it wrote -// a response. Host listeners and IN class only, ahead of the filter gate and -// the cache — resolved's own precedence. The probe runs per hit rather than -// once at Start so a resolved restart mid-run is honoured; a probe error -// reads as not running and the query takes the ordinary path. +// serveSynthetic answers a synthetic-name query — host listeners, IN class, +// resolved running (probed per hit) — reporting whether it wrote a response. func (s *Server) serveSynthetic(w dns.ResponseWriter, r *dns.Msg) bool { if len(r.Question) == 0 || r.Question[0].Qclass != dns.ClassINET || !s.hostListener(w) { return false @@ -69,12 +60,9 @@ func (s *Server) serveSynthetic(w dns.ResponseWriter, r *dns.Msg) bool { m := new(dns.Msg) if expanded { - // The search-expanded form a stub resolver tries before the bare - // name. NXDOMAIN — what the upstream says natively — rather than the - // gate's REFUSED, which c-ares treats as terminal on a multi-label - // attempt and so never went on to ask "_gateway". Never relayed to - // the stub: resolved would send it upstream unmarked, and the DNAT - // would bring it straight back here. + // NXDOMAIN, not REFUSED: REFUSED on a multi-label attempt aborts + // c-ares' search before the bare name. Not relayed: the stub would + // query upstream unmarked and the DNAT would bring it back here. m.SetRcode(r, dns.RcodeNameError) m.Authoritative = true w.WriteMsg(m) @@ -94,19 +82,15 @@ func (s *Server) serveSynthetic(w dns.ResponseWriter, r *dns.Msg) bool { } // hostListener reports whether the query arrived on a listener created for -// host-netns clients rather than via AddContainerListenAddr. A container's -// native resolver path never answered these names, and the host's gateway is -// the wrong answer inside a container netns. +// host-netns clients rather than via AddContainerListenAddr. func (s *Server) hostListener(w dns.ResponseWriter) bool { return s.attributionMode(w) == attributeHostSockdiag } // syntheticQuery classifies a wire-form query name: a bare synthetic name, // its search-expanded form (first label synthetic, remainder a suffix on the -// host's own search list), or neither. Only expansions a resolver on this -// host generates count — "_gateway.example.com" is an ordinary query. -// resolv.conf is read only once the first label has matched, per hit, so a -// DHCP-rewritten search list stays current. +// host's resolv.conf search list), or neither. resolv.conf is read only +// after the first label matches. func syntheticQuery(qname string) (name string, expanded, ok bool) { full := strings.ToLower(strings.TrimSuffix(qname, ".")) first, rest, hasRest := strings.Cut(full, ".") @@ -122,10 +106,8 @@ func syntheticQuery(qname string) (name string, expanded, ok bool) { return "", false, false } -// hostSearchDomains reads the search list clients expand single-label names -// with: the last "search" or "domain" directive wins, as glibc and Go read -// it. Lowercased, trailing dots trimmed. An unreadable file yields nil, so -// no expansion is recognised. +// hostSearchDomains reads resolv.conf's search list: the last "search" or +// "domain" directive wins, as glibc reads it; unreadable yields nil. func hostSearchDomains(path string) []string { f, err := os.Open(path) if err != nil { From 2b6ff225fd923bc81db9aaa259c4f4ea2e8db03d Mon Sep 17 00:00:00 2001 From: Matthew DeVenny Date: Tue, 15 Sep 2026 10:53:48 -0700 Subject: [PATCH 4/4] #126 relay errors take the ordinary path; cover the localhost family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review on #128: - /run/systemd/resolve outlives a stopped resolved (RuntimeDirectoryPreserve=yes, verified on the Lima box), so the "resolved running" probe only ever detected that it had started once. A job that stops resolved would have had every _gateway lookup relayed to a dead stub: SERVFAIL plus a WARN per attempt, surfacing as ::warning:: annotations. The probe is gone — pkg/network is back to main — and a stub exchange error now sends the query down the ordinary path with a Debug line, which is where it went before this PR. Verified live: stop resolved mid-run → _gateway REFUSED at the gate, no WARN; start it → answers again with no cargowall restart. - resolved also synthesizes the RFC 6761 localhost family — localhost.localdomain and anything beneath localhost or localhost.localdomain — and /etc/hosts carries only the exact name localhost, so api.localhost hit the same REFUSED as _gateway. Relayed like the rest; verified live to 127.0.0.1 under enforce. - The fake stub in tests is started before use and its conn closed on cleanup: miekg's Shutdown before ActivateAndServe is a "server not started" no-op that leaves the socket and goroutine behind. - The general search-expansion problem (#127) needs the opposite fix from the synthetic case, not a shared one: for a real host the expanded form is the name that resolves, so the general answer is to strip host search suffixes for matching, not to NXDOMAIN them. #127's proposal is corrected; the NXDOMAIN here stays specific to the synthetic set. Signed-off-by: Matthew DeVenny --- design.md | 2 +- pkg/dns/synthetic.go | 49 ++++++++++---------- pkg/dns/synthetic_test.go | 76 +++++++++++++++++++------------- pkg/network/dns_redirect.go | 34 ++++---------- pkg/network/dns_redirect_test.go | 43 ------------------ 5 files changed, 80 insertions(+), 124 deletions(-) diff --git a/design.md b/design.md index 03226ba..7d9b514 100644 --- a/design.md +++ b/design.md @@ -697,7 +697,7 @@ documented residuals. - Scoped to dport 53: a full table flush would churn unrelated NAT state (Docker MASQUERADE bindings, established flows). Collateral within scope is deliberate: an in-flight UDP transaction costs one retry, while an established DNAT'd DNS-over-TCP stream is reset — its later packets miss the NAT verdict — which is the point at both call sites, since such a stream is either bypassing the proxy or aimed at a dead one. Loopback-destination entries (the DNAT'd `127.0.0.53` stub flows, direct `127.0.0.1` proxy flows) cost at most the same one-retry/reset when deleted: an in-flight reply recreates the entry — possibly reversed, which on `lo` matters not at all, since a reversed 127.x entry has loopback addresses on both sides and can never match a later external-resolver query. They stay in scope to keep the predicate simple, and the live test targets loopback for exactly that harmlessness. The proxy's own marked upstream flows re-match the mark RETURN rules on recreation - Reverse-direction guard: those costs only hold if the client speaks first. If the first packet after a delete comes from upstream (a reply in flight across the flush, a server-side segment on a DNS-over-TCP stream), conntrack re-creates the flow with upstream as ORIGINAL, stamps a null NAT binding (no nat rules face inbound), and every later client packet rides the reply direction — which never traverses nat OUTPUT — with an original tuple (dport = client's ephemeral port) no dport-53 flush can select. For a socket-reusing resolver (c-ares/Node holds one UDP socket per server) that is a persistent, invisible bypass. The redirect therefore installs `INPUT -p udp/tcp ! -i lo --sport 53 -m conntrack --ctstate NEW -j DROP` alongside the DNAT: dropping the packet destroys its unconfirmed entry, so the client's next packet is NEW forward and takes the DNAT — the already-budgeted retry/reset, now guaranteed regardless of which side speaks first. Legitimate replies ride ESTABLISHED entries and never match. Loopback is exempt (`! -i lo`): a reversed 127.x entry has loopback addresses on both sides, so it can only ever match loopback tuples — it can never capture a later external query, even though stub-destined `lo` traffic is now DNAT'd — and without the exemption a stub or proxy lookup in flight across the install flush would lose its reply and sit out the resolver timeout (5s for glibc) - Best-effort with a Warn at both call sites: idle entries age out in 30–120s, but an actively-used flow (an open DNS-over-TCP stream) refreshes its entry indefinitely — exactly the flow the flush exists to kill, which is why a failed flush is warned loudly rather than ignored - - Synthetic names (#126): systemd-resolved answers `_gateway`, `_outbound`, `_localdnsstub` and `_localdnsproxy` from local state and never sends them upstream. With the stub DNAT'd and the proxy's upstream deliberately the resolver *behind* resolved, they would NXDOMAIN for the whole run — REFUSED under enforce, silently under audit, where no connection is ever attempted and nothing is logged (`localhost` is synthetic too but survives via `/etc/hosts`). The proxy relays the bare names to the stub on its marked client socket (`pkg/dns/synthetic.go`; the mark RETURN rules pass it through the stub DNAT, as they do the startup cache peek) and writes resolved's own answer back — uncached, unenforced, never fed to hostname tracking or the firewall, so reaching the gateway stays an explicit CIDR rule. Ahead of the filter gate; host listeners and IN class only (a container's native path never resolved them); and only while resolved is running (`network.SystemdResolvedRunning`, probed per hit so a restart mid-run is honoured). The search-expanded form a stub resolver tries first (`_gateway.`, for a suffix on the host's own `resolv.conf` search list) is answered NXDOMAIN locally — what the upstream says natively — rather than REFUSED, which c-ares treats as terminal on a multi-label attempt and which therefore left `_gateway` unresolvable for c-ares clients under enforce; it is never relayed to the stub, which would send it upstream unmarked and DNAT it back into the proxy + - Synthetic names (#126): systemd-resolved answers `_gateway`, `_outbound`, `_localdnsstub`, `_localdnsproxy` and the RFC 6761 localhost family (`localhost`, `localhost.localdomain`, anything beneath either) from local state and never sends them upstream. With the stub DNAT'd and the proxy's upstream deliberately the resolver *behind* resolved, they would NXDOMAIN for the whole run — REFUSED under enforce, silently under audit, where no connection is ever attempted and nothing is logged (`/etc/hosts` covers only the exact name `localhost`). The proxy relays them to the stub on its marked client socket (`pkg/dns/synthetic.go`; the mark RETURN rules pass it through the stub DNAT, as they do the startup cache peek) and writes resolved's own answer back — uncached, unenforced, never fed to hostname tracking or the firewall, so reaching the gateway stays an explicit CIDR rule. Ahead of the filter gate; host listeners and IN class only (a container's native path never resolved them). A stub that does not answer — resolved stopped, or never installed — sends the query down the ordinary path; `/run/systemd/resolve` outlives a stopped resolved (`RuntimeDirectoryPreserve=yes`), so directory presence is no probe, and the connection-refused round trip on loopback is the cheapest true one. The search-expanded form a stub resolver tries first (`_gateway.`, for a suffix on the host's own `resolv.conf` search list) is answered NXDOMAIN locally — what the upstream says natively — rather than REFUSED, which c-ares treats as terminal on a multi-label attempt and which therefore left `_gateway` unresolvable for c-ares clients under enforce; it is never relayed to the stub, which would send it upstream unmarked and DNAT it back into the proxy. This is specific to the synthetic set, whose expanded form never exists; for a real host the expanded form is the name that resolves (#127) - **Sudo lockdown:** writes `/etc/sudoers.d/zz-cargowall-lockdown` with a NOPASSWD allowlist; removes the runner user from sudo-granting groups (`sudo`, `admin`, `wheel`) and the `docker` group; disables competing sudoers.d files by renaming them to `*.cargowall-disabled` - **Auto-infrastructure:** `EnsureInfraAllowed()` and `EnsureHostnameAllowed()` add rules for platform services (Azure IMDS, GitHub API, etc.) - **Logging:** `slog.Handler` that formats messages as GitHub workflow commands (`::error::`, `::warning::`, `::debug::`) diff --git a/pkg/dns/synthetic.go b/pkg/dns/synthetic.go index 38c4974..c311e2a 100644 --- a/pkg/dns/synthetic.go +++ b/pkg/dns/synthetic.go @@ -23,25 +23,29 @@ import ( "strings" "github.com/miekg/dns" - - cargowallNet "github.com/code-cargo/cargowall/pkg/network" ) -// syntheticNames are the names systemd-resolved synthesizes locally (#126). -// The proxy relays them to the stub on its marked client and writes -// resolved's answer back uncached and unenforced: it never feeds +// Names systemd-resolved synthesizes locally (#126): the underscore set, and +// the RFC 6761 localhost family (localhost, localhost.localdomain, anything +// beneath either). The proxy relays them to the stub on its marked client +// and writes resolved's answer back uncached and unenforced: it never feeds // hostnameIPs or the firewall. -var syntheticNames = []string{"_gateway", "_outbound", "_localdnsstub", "_localdnsproxy"} +var ( + syntheticNames = []string{"_gateway", "_outbound", "_localdnsstub", "_localdnsproxy"} + localhostRoots = []string{"localhost", "localhost.localdomain"} +) // Injection points for tests. var ( resolvedStubAddr = "127.0.0.53:53" resolvConfPath = "/etc/resolv.conf" - resolvedRunning = cargowallNet.SystemdResolvedRunning ) -// serveSynthetic answers a synthetic-name query — host listeners, IN class, -// resolved running (probed per hit) — reporting whether it wrote a response. +// serveSynthetic answers a synthetic-name query — host listeners, IN class — +// reporting whether it wrote a response. A stub that does not answer sends +// the query down the ordinary path: /run/systemd/resolve outlives a stopped +// resolved (RuntimeDirectoryPreserve=yes), so presence proves nothing, and +// the connection-refused round trip on loopback is the cheapest true probe. func (s *Server) serveSynthetic(w dns.ResponseWriter, r *dns.Msg) bool { if len(r.Question) == 0 || r.Question[0].Qclass != dns.ClassINET || !s.hostListener(w) { return false @@ -50,13 +54,6 @@ func (s *Server) serveSynthetic(w dns.ResponseWriter, r *dns.Msg) bool { if !ok { return false } - if running, err := resolvedRunning(); err != nil || !running { - if err != nil { - s.logger.Debug("systemd-resolved probe failed; synthetic name takes the ordinary path", - "name", name, "error", err) - } - return false - } m := new(dns.Msg) if expanded { @@ -71,10 +68,9 @@ func (s *Server) serveSynthetic(w dns.ResponseWriter, r *dns.Msg) bool { resp, _, err := s.client.Exchange(r, resolvedStubAddr) if err != nil { - s.logger.Warn("systemd-resolved stub query failed", "name", name, "error", err) - m.SetRcode(r, dns.RcodeServerFailure) - w.WriteMsg(m) - return true + s.logger.Debug("systemd-resolved stub unreachable; synthetic name takes the ordinary path", + "name", name, "error", err) + return false } resp.Id = r.Id w.WriteMsg(resp) @@ -87,12 +83,17 @@ func (s *Server) hostListener(w dns.ResponseWriter) bool { return s.attributionMode(w) == attributeHostSockdiag } -// syntheticQuery classifies a wire-form query name: a bare synthetic name, -// its search-expanded form (first label synthetic, remainder a suffix on the -// host's resolv.conf search list), or neither. resolv.conf is read only -// after the first label matches. +// syntheticQuery classifies a wire-form query name: a localhost-family name, +// a bare underscore name, its search-expanded form (first label synthetic, +// remainder a suffix on the host's resolv.conf search list), or neither. +// resolv.conf is read only after the first label matches. func syntheticQuery(qname string) (name string, expanded, ok bool) { full := strings.ToLower(strings.TrimSuffix(qname, ".")) + for _, root := range localhostRoots { + if full == root || strings.HasSuffix(full, "."+root) { + return full, false, true + } + } first, rest, hasRest := strings.Cut(full, ".") if !slices.Contains(syntheticNames, first) { return "", false, false diff --git a/pkg/dns/synthetic_test.go b/pkg/dns/synthetic_test.go index 1a62b39..c5f5c8d 100644 --- a/pkg/dns/synthetic_test.go +++ b/pkg/dns/synthetic_test.go @@ -20,6 +20,7 @@ import ( "net" "os" "path/filepath" + "strings" "sync" "testing" @@ -32,13 +33,6 @@ import ( "github.com/code-cargo/cargowall/pkg/firewall" ) -func withResolvedRunning(t *testing.T, running bool) { - t.Helper() - prev := resolvedRunning - resolvedRunning = func() (bool, error) { return running, nil } - t.Cleanup(func() { resolvedRunning = prev }) -} - // withResolvConf points the client resolver config at a fixture; an empty // string leaves it absent. func withResolvConf(t *testing.T, content string) { @@ -52,9 +46,11 @@ func withResolvConf(t *testing.T, content string) { } // withFakeStub runs a stand-in for the resolved stub that answers the way -// resolved does — "_gateway" and "_outbound" A with AA and TTL 0, AAAA as -// NODATA, anything it does not know as NXDOMAIN — and returns a snapshot -// function for the questions it was asked. +// resolved does — "_gateway" and "_outbound" A with AA and TTL 0, the +// localhost family as 127.0.0.1, AAAA as NODATA, anything else as NXDOMAIN — +// and returns a snapshot function for the questions it was asked. Returns +// only once the server is serving: Shutdown before ActivateAndServe is a +// "server not started" no-op that leaves the conn open. func withFakeStub(t *testing.T) func() []dns.Question { t.Helper() pc, err := net.ListenPacket("udp", "127.0.0.1:0") @@ -64,8 +60,10 @@ func withFakeStub(t *testing.T) func() []dns.Question { seen []dns.Question ) answers := map[string]string{"_gateway.": "192.168.5.2", "_outbound.": "192.168.5.15"} + started := make(chan struct{}) stub := &dns.Server{ - PacketConn: pc, + PacketConn: pc, + NotifyStartedFunc: func() { close(started) }, Handler: dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) { q := r.Question[0] mu.Lock() @@ -74,7 +72,11 @@ func withFakeStub(t *testing.T) func() []dns.Question { m := new(dns.Msg) m.SetReply(r) m.Authoritative = true - switch ip, known := answers[q.Name]; { + ip, known := answers[q.Name] + if strings.HasSuffix(q.Name, "localhost.") || strings.HasSuffix(q.Name, "localhost.localdomain.") { + ip, known = "127.0.0.1", true + } + switch { case !known: m.Rcode = dns.RcodeNameError case q.Qtype == dns.TypeA: @@ -87,7 +89,11 @@ func withFakeStub(t *testing.T) func() []dns.Question { }), } go func() { _ = stub.ActivateAndServe() }() - t.Cleanup(func() { _ = stub.Shutdown() }) + <-started + t.Cleanup(func() { + _ = stub.Shutdown() + _ = pc.Close() + }) prev := resolvedStubAddr resolvedStubAddr = pc.LocalAddr().String() t.Cleanup(func() { resolvedStubAddr = prev }) @@ -100,12 +106,11 @@ func withFakeStub(t *testing.T) func() []dns.Question { // syntheticServer is a filtering, default-deny server — the configuration // under which these names would otherwise be REFUSED, so every answer below -// also proves precedence over the gate — with resolved "running", a fake -// stub, and a resolver config without a search list. The mock firewall -// carries no expectations: any enforcement side effect fails the test. +// also proves precedence over the gate — with a fake stub and a resolver +// config without a search list. The mock firewall carries no expectations: +// any enforcement side effect fails the test. func syntheticServer(t *testing.T) (*Server, func() []dns.Question) { t.Helper() - withResolvedRunning(t, true) withResolvConf(t, "nameserver 127.0.0.53\n") seen := withFakeStub(t) cfg := config.NewConfigManager() @@ -163,6 +168,12 @@ func TestSyntheticQuery(t *testing.T) { {"_gateway.blacksmith.sh.", "", false, false}, // partial suffix is not the suffix {"gateway.lan.", "", false, false}, {"example.com.", "", false, false}, + {"localhost.", "localhost", false, true}, + {"API.localhost.", "api.localhost", false, true}, + {"localhost.localdomain.", "localhost.localdomain", false, true}, + {"foo.localhost.localdomain.", "foo.localhost.localdomain", false, true}, + {"localhost.example.com.", "", false, false}, + {"notlocalhost.", "", false, false}, } { got, expanded, ok := syntheticQuery(tc.qname) assert.Equal(t, tc.ok, ok, tc.qname) @@ -214,11 +225,26 @@ func TestServeSynthetic_RelaysStubVerdictVerbatim(t *testing.T) { assert.Equal(t, dns.RcodeNameError, m.Rcode) } -func TestServeSynthetic_StubUnreachableIsServfail(t *testing.T) { +// A stub that does not answer — resolved stopped, or never installed — sends +// the name down the ordinary path, REFUSED here, rather than SERVFAIL: the +// runtime directory outlives a stopped resolved, so this is the only probe. +func TestServeSynthetic_StubUnreachableIsOrdinary(t *testing.T) { s, _ := syntheticServer(t) resolvedStubAddr = "127.0.0.1:1" // nothing listens; UDP gets ECONNREFUSED at once m := askName(t, s, "_gateway.", dns.TypeA) - assert.Equal(t, dns.RcodeServerFailure, m.Rcode) + assert.Equal(t, dns.RcodeRefused, m.Rcode) +} + +// The localhost family resolved synthesizes beyond what /etc/hosts carries. +func TestServeSynthetic_RelaysLocalhostFamily(t *testing.T) { + s, seen := syntheticServer(t) + for _, q := range []string{"api.localhost.", "localhost.localdomain.", "foo.localhost.localdomain."} { + m := askName(t, s, q, dns.TypeA) + assert.Equal(t, dns.RcodeSuccess, m.Rcode, q) + require.Len(t, m.Answer, 1, q) + assert.Equal(t, "127.0.0.1", m.Answer[0].(*dns.A).A.String(), q) + } + assert.Len(t, seen(), 3) } // The search-expanded form is the first query a resolver sends for the @@ -251,18 +277,6 @@ func TestServeSynthetic_ExpandedOtherwiseOrdinary(t *testing.T) { assert.Equal(t, dns.RcodeRefused, m.Rcode, "suffix not on the search list") } -// With resolved not running the name takes the ordinary path — and under -// filtering with default deny that is REFUSED, the enforce symptom in #126. -// Pins that the relay, not something else, answers the name above, and that -// the stub is not consulted on a host that has none. -func TestServeSynthetic_ResolvedNotRunningIsOrdinary(t *testing.T) { - s, seen := syntheticServer(t) - withResolvedRunning(t, false) - m := askName(t, s, "_gateway.", dns.TypeA) - assert.Equal(t, dns.RcodeRefused, m.Rcode) - assert.Empty(t, seen()) -} - func TestServeSynthetic_NonINClassIsOrdinary(t *testing.T) { s, seen := syntheticServer(t) q := new(dns.Msg) diff --git a/pkg/network/dns_redirect.go b/pkg/network/dns_redirect.go index 9d6a126..4f04813 100644 --- a/pkg/network/dns_redirect.go +++ b/pkg/network/dns_redirect.go @@ -121,22 +121,6 @@ func SetupDNSRedirect(logger *slog.Logger) error { // caller). A var so tests can point it at a controllable path. var resolvedRuntimeDir = "/run/systemd/resolve" -// SystemdResolvedRunning reports whether systemd-resolved is running, by the -// presence of its runtime directory. A genuine "not there" is a clean false; -// any other stat failure (EACCES, EIO) is surfaced rather than folded into -// false, so a host where resolved IS running is never misreported as one -// where it is not. Shared by the cache flush and the DNS proxy's synthetic -// answers (#126) so both read the same signal. -func SystemdResolvedRunning() (bool, error) { - if _, err := os.Stat(resolvedRuntimeDir); err != nil { - if errors.Is(err, os.ErrNotExist) { - return false, nil - } - return false, fmt.Errorf("probing %s failed: %w", resolvedRuntimeDir, err) - } - return true, nil -} - // flushResolvedTimeout bounds the resolvectl call so a wedged systemd-resolved // (or its D-Bus endpoint) cannot stall startup before the eBPF program // attaches and the firewall begins enforcing. A var so tests can shorten it. @@ -171,15 +155,15 @@ func FlushResolvedCache(ctx context.Context, logger *slog.Logger) error { // resolvectl can be installed on hosts that don't actually run // systemd-resolved (a different resolver is in use); its runtime dir is // absent there, so skip quietly rather than warn on every startup. Only a - // genuine "not there" is benign — SystemdResolvedRunning surfaces every - // other stat failure, mirroring the LookPath classification above. - running, err := SystemdResolvedRunning() - if err != nil { - return err - } - if !running { - logger.Debug("systemd-resolved not running; skipping cache flush", "probe", resolvedRuntimeDir) - return nil + // genuine "not there" is benign — a stat failure such as EACCES/EIO is real + // and surfaced, mirroring the LookPath classification above (otherwise a + // host where resolved *is* running would be misreported as a correct skip). + if _, err := os.Stat(resolvedRuntimeDir); err != nil { + if errors.Is(err, os.ErrNotExist) { + logger.Debug("systemd-resolved not running; skipping cache flush", "probe", resolvedRuntimeDir) + return nil + } + return fmt.Errorf("probing %s failed: %w", resolvedRuntimeDir, err) } flushCtx, cancel := context.WithTimeout(ctx, flushResolvedTimeout) diff --git a/pkg/network/dns_redirect_test.go b/pkg/network/dns_redirect_test.go index 72d4ada..2b7c9b3 100644 --- a/pkg/network/dns_redirect_test.go +++ b/pkg/network/dns_redirect_test.go @@ -57,49 +57,6 @@ func withResolvedRunning(t *testing.T) { t.Cleanup(func() { resolvedRuntimeDir = prev }) } -// TestSystemdResolvedRunning: the runtime dir present → true; a genuine -// "not there" → false with no error. Both are clean outcomes; only a -// non-ENOENT stat failure is surfaced (see the permission case below). -func TestSystemdResolvedRunning(t *testing.T) { - withResolvedRunning(t) - running, err := SystemdResolvedRunning() - if err != nil || !running { - t.Fatalf("runtime dir present: want (true, nil), got (%v, %v)", running, err) - } - - prev := resolvedRuntimeDir - resolvedRuntimeDir = filepath.Join(t.TempDir(), "absent") - t.Cleanup(func() { resolvedRuntimeDir = prev }) - running, err = SystemdResolvedRunning() - if err != nil || running { - t.Fatalf("runtime dir absent: want (false, nil), got (%v, %v)", running, err) - } -} - -// TestSystemdResolvedRunning_StatFailureSurfaced: a probe that fails for any -// reason other than ENOENT must not read as "not running" — otherwise a host -// where resolved IS running would be misreported. Root bypasses directory -// permissions, so the case is only testable unprivileged. -func TestSystemdResolvedRunning_StatFailureSurfaced(t *testing.T) { - if os.Geteuid() == 0 { - t.Skip("root ignores directory permissions; EACCES cannot be provoked") - } - parent := t.TempDir() - if err := os.Chmod(parent, 0); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = os.Chmod(parent, 0o700) }) - - prev := resolvedRuntimeDir - resolvedRuntimeDir = filepath.Join(parent, "resolve") - t.Cleanup(func() { resolvedRuntimeDir = prev }) - - running, err := SystemdResolvedRunning() - if err == nil || running { - t.Fatalf("EACCES probe: want (false, error), got (%v, %v)", running, err) - } -} - // TestFlushResolvedCache_NotInstalled: resolvectl absent from PATH → quiet skip. func TestFlushResolvedCache_NotInstalled(t *testing.T) { t.Setenv("PATH", t.TempDir()) // empty dir, no resolvectl