Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<search>`, 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::`)
6 changes: 6 additions & 0 deletions pkg/dns/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
134 changes: 134 additions & 0 deletions pkg/dns/synthetic.go
Original file line number Diff line number Diff line change
@@ -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 {
Comment thread
jordanjennings marked this conversation as resolved.
// 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
}
Loading
Loading