diff --git a/design.md b/design.md index 03f5625..7d9b514 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`, `_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/server.go b/pkg/dns/server.go index 496cbe8..3881b2b 100644 --- a/pkg/dns/server.go +++ b/pkg/dns/server.go @@ -529,6 +529,12 @@ func (s *Server) handleDNSQuery(w dns.ResponseWriter, r *dns.Msg) { "type", queryType, "upstream", s.upstream) + // systemd-resolved's synthetic names are relayed to the stub, ahead of + // the filter gate and the cache. + if s.serveSynthetic(w, r) { + 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..c311e2a --- /dev/null +++ b/pkg/dns/synthetic.go @@ -0,0 +1,134 @@ +// 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" + "os" + "slices" + "strings" + + "github.com/miekg/dns" +) + +// 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"} + localhostRoots = []string{"localhost", "localhost.localdomain"} +) + +// Injection points for tests. +var ( + resolvedStubAddr = "127.0.0.53:53" + resolvConfPath = "/etc/resolv.conf" +) + +// 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 + } + name, expanded, ok := syntheticQuery(r.Question[0].Name) + if !ok { + return false + } + + m := new(dns.Msg) + if expanded { + // 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) + return true + } + + resp, _, err := s.client.Exchange(r, resolvedStubAddr) + if err != nil { + 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) + return true +} + +// hostListener reports whether the query arrived on a listener created for +// 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 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 + } + if !hasRest { + return first, false, true + } + if slices.Contains(hostSearchDomains(resolvConfPath), rest) { + return first, true, true + } + return "", false, false +} + +// 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 { + 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 +} diff --git a/pkg/dns/synthetic_test.go b/pkg/dns/synthetic_test.go new file mode 100644 index 0000000..c5f5c8d --- /dev/null +++ b/pkg/dns/synthetic_test.go @@ -0,0 +1,313 @@ +// 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 ( + "net" + "os" + "path/filepath" + "strings" + "sync" + "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" +) + +// 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 }) +} + +// withFakeStub runs a stand-in for the resolved stub that answers the way +// 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") + 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"} + started := make(chan struct{}) + stub := &dns.Server{ + PacketConn: pc, + NotifyStartedFunc: func() { close(started) }, + 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 + 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: + 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() }() + <-started + t.Cleanup(func() { + _ = stub.Shutdown() + _ = pc.Close() + }) + 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 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() + 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 + return s, seen +} + +func ask(t *testing.T, s *Server, w *MockResponseWriter, q *dns.Msg) *dns.Msg { + t.Helper() + 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 askName(t *testing.T, s *Server, name string, qtype uint16) *dns.Msg { + t.Helper() + q := new(dns.Msg) + q.SetQuestion(name, qtype) + return ask(t, s, &MockResponseWriter{}, q) +} + +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}, + {"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) + 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") +} + +// 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 := askName(t, s, "_gateway.", dns.TypeA) + assert.Equal(t, dns.RcodeSuccess, m.Rcode) + assert.True(t, m.Authoritative) + 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) + + 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, "stub answers are not tracked") +} + +// 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 := askName(t, s, "_gateway.", dns.TypeAAAA) + assert.Equal(t, dns.RcodeSuccess, m.Rcode) + assert.Empty(t, m.Answer) + + m = askName(t, s, "_localdnsproxy.", dns.TypeA) + assert.Equal(t, dns.RcodeNameError, m.Rcode) +} + +// 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.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 +// 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 := 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 TestServeSynthetic_ExpandedOtherwiseOrdinary(t *testing.T) { + s, _ := syntheticServer(t) + + m := askName(t, s, "_gateway.lan.", dns.TypeA) + assert.Equal(t, dns.RcodeRefused, m.Rcode, "no search list") + + withResolvConf(t, "search lan\n") + m = askName(t, s, "_gateway.example.com.", dns.TypeA) + assert.Equal(t, dns.RcodeRefused, m.Rcode, "suffix not on the search list") +} + +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. +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 TestServeSynthetic_ContainerListenerIsOrdinary(t *testing.T) { + s, seen := 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) + assert.Empty(t, seen()) +}