Files
dotfiles/docker/claude-sandbox/dns-proxy.sh
T
branderson 5294e72a32 Keep sandbox firewall ipsets fresh and batch Loki log shipping
init-firewall.sh's ipset population was a one-time snapshot at container
start: an already-allowed domain could still start failing to connect
later if its CDN edge rotated to an IP that was never added
(pypi.org/files.pythonhosted.org/GitHub's release-assets CDN all do this),
and allowing a domain like crates.io didn't help traffic that actually
goes to a different hostname under it (index.crates.io/static.crates.io).

dns-proxy.sh's dnsmasq now also uses --ipset= so the resolved IP gets added
on every fresh answer it serves for the life of the session, not just once
at boot - verified this actually repopulates after a real (non-cached)
re-resolution, and that dnsmasq tolerates the target ipset not existing yet
when its config is generated (it runs before init-firewall.sh creates it).

Also batches log-shipper.sh's Loki pushes (up to 20 lines or 2s, whichever
first) instead of one HTTP request per line - a burst of activity (e.g.
npm install resolving/connecting to many hosts quickly) would otherwise
spam Loki with one request per line. Verified via the sandbox's own
connection logging: 15 rapid lines produced 2 requests instead of 15.
2026-07-19 18:09:16 -07:00

108 lines
4.4 KiB
Bash

#!/bin/bash
# Restricts DNS resolution itself to an allowlist, rather than only
# filtering the IP connections made afterward: without this, Docker's
# embedded resolver at 127.0.0.11 will still resolve *any* domain even once
# the firewall is locked down (verified: `dig` on a non-allowlisted domain
# resolves fine, only the subsequent connect gets blocked), so a compromised
# session could exfiltrate data by encoding it into query names to an
# attacker-controlled domain. Runs a local dnsmasq bound to 127.0.0.1 with
# no fallback resolver, forwarding only allowlisted names to Docker's real
# resolver; everything else gets no upstream configured at all, so it fails
# outright instead of leaking the query. Must run before init-firewall.sh so
# that script's own dig calls (and everything afterward) go through it.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=fixed-domains.sh
source "$SCRIPT_DIR/fixed-domains.sh"
CONF_FILE=/etc/dnsmasq-sandbox.conf
SERVERS_FILE=/etc/dnsmasq-sandbox-servers.conf
LOG_FILE=/var/log/dnsmasq.log
touch "$LOG_FILE"
chown dnsmasq:dnsmasq "$LOG_FILE" 2>/dev/null || true
# server=/domain/upstream lines live in their own file (rather than the main
# conf) since dnsmasq only re-reads a --servers-file on SIGHUP - lets
# bin/claude-sandbox-allow add a domain to a running session by appending a
# line here and signaling the container's dnsmasq, no restart needed.
{
for domain in "${FIXED_DOMAINS[@]}" "${GITHUB_DNS_SUFFIXES[@]}" ${EXTRA_ALLOWED_DOMAINS:-}; do
# Literal IPs (e.g. a monitoring host given as an IP, not a hostname)
# don't need a DNS entry - there's nothing to resolve.
if [[ "$domain" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
continue
fi
echo "server=/$domain/127.0.0.11"
done
} > "$SERVERS_FILE"
{
cat <<EOF
no-resolv
no-hosts
bind-interfaces
# IPv6 is blocked outright at the firewall (see init-firewall.sh's
# ip6tables policies) - strip AAAA answers too so nothing wastes a
# connection attempt on an address that can never actually be reached.
filter-AAAA
listen-address=127.0.0.1
port=53
servers-file=$SERVERS_FILE
log-queries
log-facility=$LOG_FILE
user=dnsmasq
pid-file=/var/run/dnsmasq-sandbox.pid
EOF
# init-firewall.sh's ipset population is a one-time snapshot at
# container start; a domain that's been allowed can still start failing
# to connect later if its CDN edge rotates to an IP that was never added
# (pypi.org/files.pythonhosted.org/GitHub's release-assets CDN all do
# this), and a domain like crates.io being allowed doesn't help
# index.crates.io/static.crates.io, which real traffic actually hits.
# These lines keep the ipsets topped up for the life of the session:
# every time dnsmasq actually answers a query for one of these domains,
# the resolved IP gets added too, on top of the upfront snapshot -
# dnsmasq tolerates the target ipset not existing yet at this point
# (verified: it logs the attempt and still answers the query
# normally), so this is safe even though it's generated before
# init-firewall.sh creates the ipsets moments later.
#
# This only covers domains known at container start (FIXED_DOMAINS,
# GITHUB_DNS_SUFFIXES, EXTRA_ALLOWED_DOMAINS) - a domain added mid-session
# via bin/claude-sandbox-allow only gets the one-time add it already
# does, not ongoing tracking, since --ipset= can't live in the
# SIGHUP-reloadable servers-file (dnsmasq only allows --server/
# --rev-server there).
for domain in "${FIXED_DOMAINS[@]}" ${EXTRA_ALLOWED_DOMAINS:-}; do
if [[ "$domain" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
continue
fi
echo "ipset=/$domain/allowed-domains"
done
for domain in "${GITHUB_DNS_SUFFIXES[@]}"; do
echo "ipset=/$domain/github-domains"
done
} > "$CONF_FILE"
dnsmasq --conf-file="$CONF_FILE"
# dnsmasq daemonizes and returns almost immediately, but give it a moment to
# actually be answering before repointing resolv.conf at it.
ready=0
for _ in $(seq 1 20); do
if dig +time=1 +tries=1 @127.0.0.1 api.anthropic.com >/dev/null 2>&1; then
ready=1
break
fi
sleep 0.2
done
if [ "$ready" -ne 1 ]; then
echo "ERROR: dnsmasq did not come up in time" >&2
exit 1
fi
echo "nameserver 127.0.0.1" > /etc/resolv.conf