Skip to content
Open
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
68 changes: 40 additions & 28 deletions cli/templates/devcontainer/sandcat/scripts/wg-client-init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,31 @@ extract_search_domains() {
"$resolv_conf"
}

# Write a dnsmasq config that splits DNS:
# - queries under any of the given search domains (the Docker compose project
# network) are forwarded to Docker's embedded DNS at 127.0.0.11,
# - everything else is forwarded to the upstream from dns.conf or defaults.
# Write a dnsmasq config that splits DNS (#113):
# - unqualified single-label names (sibling containers) go to Docker's
# embedded DNS at 127.0.0.11 via the empty-domain rule `server=//...`,
# - every dotted name — including hosts under the container's search
# domains — is forwarded to the upstream from dns.conf or defaults,
# which routes through wg0 → mitmproxy for inspection and policy.
#
# Search domains are deliberately NOT routed to 127.0.0.11: Docker copies
# the HOST's search domains into the container, so in corporate setups they
# are intranet domains that only the custom upstream (dns_servers) can
# resolve — routing them to Docker's resolver black-holed them via the
# compose `dns:` exfiltration sink AND broke sibling lookups (libc's search
# expansion never reached the bare name because `domain-needed` dropped it).
# With the empty-domain rule, bare names resolve directly and search
# expansion serves what it is for — short intranet names.
#
# Unknown single-label names still cannot leak: Docker's embedded resolver
# forwards non-container names to the compose-configured `dns:` sink
# (192.0.2.1), which is unroutable, so they fail without leaving the host.
#
# Args:
# $1 - path to dns.conf (sidecar from mitmproxy)
# $2 - path to dnsmasq config to write
# $3..$N - search domains routed to 127.0.0.11 (may be empty)
# $3..$N - search domains (unused here; kept for resolv.conf callers'
# symmetry and future use)
write_dnsmasq_conf() {
local dns_conf="$1"
local out="$2"
Expand All @@ -91,16 +107,9 @@ write_dnsmasq_conf() {
echo "bind-interfaces"
echo "log-facility=-"
echo "cache-size=1000"
# Drop dotless and RFC1918-reverse queries instead of forwarding them.
# Sibling-container resolution relies on libc expanding bare names via
# the search domain into FQDNs; any single-label query that still
# reaches dnsmasq has bypassed that path and shouldn't leak upstream.
echo "domain-needed"
echo "bogus-priv"
local d
for d in "$@"; do
printf 'server=/%s/127.0.0.11\n' "$d"
done
# Empty-domain rule: applies ONLY to unqualified names.
echo "server=//127.0.0.11"
local ns
while IFS= read -r ns; do
printf 'server=%s\n' "$ns"
Expand All @@ -109,13 +118,14 @@ write_dnsmasq_conf() {
}

# Write a resolv.conf that routes all queries through the local dnsmasq.
# Search domains are preserved so that bare hostnames (e.g. another compose
# service) get expanded by the libc resolver and routed to 127.0.0.11 by
# dnsmasq's `server=/<search>/127.0.0.11` rules.
# Search domains are preserved so that short intranet names (e.g. `foo` with
# a corporate search domain `corp.example`) get expanded by the libc resolver
# into FQDNs the upstream DNS can answer. Sibling containers do not need the
# expansion: bare names are matched by dnsmasq's empty-domain rule
# (`server=//127.0.0.11`) and answered by Docker's embedded resolver (#113).
#
# `options ndots:0` from the Docker-supplied resolv.conf is intentionally NOT
# preserved — it would defeat the search-domain expansion path that lets
# dnsmasq distinguish sibling-container queries from external ones.
# preserved — search expansion for intranet short names should run first.
#
# Args:
# $1 - path to resolv.conf to overwrite
Expand Down Expand Up @@ -143,9 +153,10 @@ main() {
test -f "$WG_JSON"

# ── Snapshot search domains BEFORE we overwrite resolv.conf ────────────────
# Docker populates the container's resolv.conf with the compose project's
# default network as a search domain. We preserve it so single-label
# sibling-container lookups still work after dnsmasq takes over.
# Docker copies the host's search domains (and/or the compose network
# domain) into the container's resolv.conf. We preserve them so short
# intranet names expand to FQDNs the upstream DNS can answer; sibling
# containers resolve via the empty-domain dnsmasq rule instead (#113).
local search_domains=()
mapfile -t search_domains < <(extract_search_domains /etc/resolv.conf)

Expand Down Expand Up @@ -249,12 +260,13 @@ main() {
ip6tables -A OUTPUT -o eth0 -j DROP

# ── Local DNS forwarder ────────────────────────────────────────────────────
# Run dnsmasq on 127.0.0.1 so:
# - sibling-container queries (matched via the compose project's search
# domain) go to Docker's embedded resolver at 127.0.0.11 (loopback,
# NAT'd to the daemon — never enters wg0),
# - everything else goes to the upstream from dns.conf (or defaults),
# which routes through wg0 → mitmproxy for inspection and policy.
# Run dnsmasq on 127.0.0.1 so (#113):
# - unqualified single-label queries (sibling containers) go to Docker's
# embedded resolver at 127.0.0.11 (loopback, NAT'd to the daemon —
# never enters wg0),
# - every dotted name — including hosts under the container's search
# domains — goes to the upstream from dns.conf (or defaults), which
# routes through wg0 → mitmproxy for inspection and policy.
#
# Outbound from dnsmasq stays inside the kill-switch installed above:
# - 127.0.0.11 traffic uses the lo interface (ACCEPT-ed),
Expand Down
22 changes: 14 additions & 8 deletions cli/test/wg-client/write_dnsmasq_conf.bats
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ setup() {
DNSMASQ_OUT="$BATS_TEST_TMPDIR/dnsmasq.conf"
}

@test "write_dnsmasq_conf uses defaults and routes the search domain to Docker DNS" {
@test "write_dnsmasq_conf routes unqualified names to Docker DNS and the rest upstream" {
write_dnsmasq_conf "$DNS_CONF_FIXTURE" "$DNSMASQ_OUT" myproj_default

run cat "$DNSMASQ_OUT"
Expand All @@ -15,11 +15,16 @@ setup() {
assert_line "no-hosts"
assert_line "listen-address=127.0.0.1"
assert_line "bind-interfaces"
assert_line "domain-needed"
assert_line "bogus-priv"
assert_line "server=/myproj_default/127.0.0.11"
# Empty-domain rule: single-label (sibling container) names → Docker DNS.
assert_line "server=//127.0.0.11"
assert_line "server=1.1.1.1"
assert_line "server=8.8.8.8"
# #113 regression guards: search domains must NOT be routed to Docker
# (corporate search domains are intranet zones only the upstream knows),
# and dotless queries must NOT be dropped (they carry sibling lookups).
refute_output --partial "server=/myproj_default/"
refute_output --partial "domain-needed"
}

@test "write_dnsmasq_conf uses custom upstream from dns.conf" {
Expand All @@ -35,22 +40,23 @@ setup() {
refute_line "server=8.8.8.8"
}

@test "write_dnsmasq_conf with no search domains emits no Docker DNS routes" {
@test "write_dnsmasq_conf emits the same config with no search domains" {
write_dnsmasq_conf "$DNS_CONF_FIXTURE" "$DNSMASQ_OUT"

run cat "$DNSMASQ_OUT"
assert_success
refute_output --partial "127.0.0.11"
assert_line "server=//127.0.0.11"
assert_line "server=1.1.1.1"
}

@test "write_dnsmasq_conf supports multiple search domains" {
@test "write_dnsmasq_conf ignores search domains for routing (multiple given)" {
write_dnsmasq_conf "$DNS_CONF_FIXTURE" "$DNSMASQ_OUT" myproj_default cluster.local

run cat "$DNSMASQ_OUT"
assert_success
assert_line "server=/myproj_default/127.0.0.11"
assert_line "server=/cluster.local/127.0.0.11"
assert_line "server=//127.0.0.11"
refute_output --partial "server=/myproj_default/"
refute_output --partial "server=/cluster.local/"
}

@test "write_dnsmasq_conf falls back to defaults when dns.conf is empty" {
Expand Down
20 changes: 11 additions & 9 deletions docs/configuration/dns.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,11 @@ what a lower layer set. Run `sandcat restart` after editing.
## Container-to-container DNS

The agent can resolve sibling containers on the same Docker compose network by
name (e.g. a `db:` service in `compose.yml` is reachable as `db`). Queries
under the compose project's network (the `search` domain Docker assigns to
the container) go to Docker's embedded resolver at `127.0.0.11`. No
name (e.g. a `db:` service in `compose.yml` is reachable as `db`).
Unqualified single-label queries go to Docker's embedded resolver at
`127.0.0.11` (dnsmasq's empty-domain rule); every dotted name — including
hosts under the container's search domains, which in corporate setups are
intranet zones — goes to the configured upstream through the tunnel. No
configuration is required.

The agent shares wg-client's network namespace via `network_mode` but Docker
Expand All @@ -40,12 +42,12 @@ volume mounted read-only at `/run/sandcat` in the agent, and `app-init.sh`
copies it into `/etc/resolv.conf` on startup so the agent's lookups also go
through the local dnsmasq.

To prevent the search-domain carve-out from becoming a DNS exfiltration
channel — where an attacker-crafted name like `<payload>.<project>_default`
would otherwise be forwarded by Docker's embedded resolver to the host's
upstream DNS, bypassing mitmproxy — wg-client is launched with a `dns:` sink
(RFC 5737 `192.0.2.1`). Sibling names still resolve locally; anything else
under the search domain fails fast without leaving the host.
To prevent the unqualified-name carve-out from becoming a DNS exfiltration
channel, wg-client is launched with a `dns:` sink (RFC 5737 `192.0.2.1`):
Docker's embedded resolver answers sibling-container names locally and
forwards anything it does not know to the sink, which is unroutable — so
unknown single-label lookups fail fast without ever leaving the host, and
every dotted name is subject to mitmproxy's DNS policy on its way upstream.

## Resolving internal hostnames — `extra_hosts`

Expand Down
Loading