testsAndMisc/linux_configuration/scripts/single_use/features/setup_dot_resolver.sh
Krzysztof kuhy Rudnicki e3d5e1dd18 Serve the blocklist over DoT and hand the policy to the app as an asset
Two gaps stopped the unrooted path working end to end. The phone had no way to
read the focus policy, and the blocklist was only reachable as a hosts file or
over plain UDP/53 on the LAN -- while the rooted enforcer actively rejected
every DoT connection, so a self-hosted resolver could not have worked even if
one existed.

focus_owner now loads a generated policy.json instead of hardcoding anything,
so config.sh stays the single source of truth across Python, shell and Kotlin.
The committed asset is rendered with --redact-home and a test fails if home
coordinates ever appear in it. An unknown schema_version is rejected outright:
silently accepting a newer schema means enforcing a policy the app has misread,
and a misread allowlist is one that blocks the dialer.

dns_enforcer.sh rejected ALL port 853 traffic and pinned Private DNS off, every
20 seconds. That is right when DoT is a bypass channel and exactly wrong when
DoT is the enforcement channel, which is what an unrooted phone needs -- it has
no hosts file to fall back to. With DNS_TRUSTED_DOT_HOST set it now ACCEPTs the
trusted resolver before the blanket REJECT, and pins Private DNS to that
hostname so the user cannot pick an unfiltered one. Rule order is load-bearing:
iptables takes the first match, so an ACCEPT appended after the REJECT would
never be reached. expected_rule_count() counts the new rules, because a
mismatch there makes chain_intact() rebuild the chain every 20s -- a silent
fork storm that also drops the connection each time. With the variable empty
the behaviour is byte-identical, so the rooted phone is untouched until this is
deployed.

setup_dot_resolver.sh terminates DoT with stunnel rather than Caddy: DoT is a
raw TLS stream and stock caddy:2.8 has no layer4 module, so using Caddy would
have meant an xcaddy rebuild of a container serving five live sites. It reuses
the certificate Caddy already renews, so nothing competes for port 80. It binds
to the WireGuard address, not 0.0.0.0 -- a public DoT endpoint is an open
resolver, and over the existing tunnel it needs no port-forward, no firewall
rule and no rate limiting. Verified: example.com resolves, pornhub.com returns
0.0.0.0, TLS 1.3 against the LE cert, and both the LAN and public addresses
refuse.

The Device Owner checklist is planning only. Checking the device rather than
the config settled the one open risk: Google Wallet is not installed and no NFC
payment default is set, so the unconfirmable tap-to-pay question does not apply
here. The same check found that Aegis, MS Authenticator, Oracle IDM and PKO IKO
are absent -- they are in config.sh's whitelist, which describes the rooted
phone, not this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL
2026-08-05 09:36:11 +02:00

291 lines
8.9 KiB
Bash
Executable File

#!/bin/bash
# ============================================================================
# Expose the existing dnsmasq blocklist over DNS-over-TLS (port 853).
#
# Why this exists:
# setup_dns_blocker.sh serves the blocklist over plain UDP/53, which only
# works on the LAN -- and Android's default "Automatic" Private DNS uses DoH
# and bypasses it entirely. A phone off the LAN gets no filtering at all.
# Terminating DoT here lets the phone pin Private DNS to a resolver that
# applies the blocklist, anywhere, with no root and no VPN slot consumed.
#
# Why stunnel rather than Caddy:
# DoT is a raw TLS stream, not HTTPS. Terminating it in Caddy needs the
# `layer4` module, which the stock `caddy:2.8` image does not ship -- that
# would mean an xcaddy rebuild of a container currently serving five live
# sites. stunnel does exactly this one job, and it can reuse the Let's
# Encrypt certificate Caddy already renews, so there is no second ACME
# client competing for port 80.
#
# Usage:
# ./setup_dot_resolver.sh [--domain <fqdn>] [--check]
# ============================================================================
set -euo pipefail
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
readonly DEFAULT_DOMAIN="dns.kuhy.duckdns.org"
readonly CADDY_CONTAINER="gitea-caddy"
readonly CERT_DIR="/etc/stunnel/dot"
readonly STUNNEL_CONF="/etc/stunnel/dot-resolver.conf"
readonly SYNC_SCRIPT="/usr/local/bin/sync-dot-cert.sh"
readonly DOT_PORT=853
readonly DEFAULT_BIND="10.8.0.1"
DOMAIN="$DEFAULT_DOMAIN"
# Bind to the WireGuard interface, not 0.0.0.0. An internet-facing DoT
# endpoint is an open resolver: TCP-only so not usable for classic UDP
# amplification, but still someone else's free DNS and a standing attack
# surface. Reaching it over the existing tunnel needs no port-forward and no
# rate limiting, because only configured peers can route to it at all.
BIND_ADDR="$DEFAULT_BIND"
CHECK_ONLY=0
log() { printf '[dot-resolver] %s\n' "$1"; }
die() {
printf '[dot-resolver] ERROR: %s\n' "$1" >&2
exit 1
}
usage() {
cat <<EOF
Usage: $SCRIPT_NAME [OPTIONS]
--domain <fqdn> DoT hostname (default: $DEFAULT_DOMAIN)
--bind <addr> Address to listen on (default: $DEFAULT_BIND, the
WireGuard interface). Use 0.0.0.0 only if you intend
to expose an open resolver to the internet.
--check Verify an existing install, change nothing
-h, --help Show this help
EOF
exit 0
}
require_tools() {
# Per repo policy a script installs what it needs rather than telling the
# user to. pacman is non-interactive here; if that fails we stop rather
# than silently skipping the dependency.
local missing=()
command -v stunnel >/dev/null 2>&1 || missing+=(stunnel)
command -v openssl >/dev/null 2>&1 || missing+=(openssl)
if [[ ${#missing[@]} -gt 0 ]]; then
log "Installing: ${missing[*]}"
sudo pacman -S --needed --noconfirm "${missing[@]}" ||
die "could not install ${missing[*]}; run: sudo pacman -S ${missing[*]}"
fi
}
cert_source_dir() {
# Caddy stores certs per-domain under its data volume.
printf '/data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/%s' \
"$DOMAIN"
}
verify_prerequisites() {
# A DoT endpoint in front of a resolver that is not running would answer
# every query with a connection error, which looks exactly like a network
# fault on the phone. Check first.
ss -lnu 2>/dev/null | grep -qE '127\.0\.0\.1:53' ||
die "dnsmasq is not listening on 127.0.0.1:53 - run setup_dns_blocker.sh first"
docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$CADDY_CONTAINER" ||
die "container '$CADDY_CONTAINER' is not running; it owns the certificates"
local resolved
resolved="$(getent hosts "$DOMAIN" | awk '{print $1; exit}')" ||
die "$DOMAIN does not resolve - DuckDNS wildcard missing?"
log "$DOMAIN resolves to $resolved"
# Binding to an address that does not exist yet makes stunnel fail at
# start with an opaque error, so check before installing anything.
if [[ "$BIND_ADDR" != "0.0.0.0" ]]; then
ip -brief addr show 2>/dev/null | grep -q "$BIND_ADDR" ||
die "$BIND_ADDR is not assigned to any interface - is wg0 up?"
log "Binding to $BIND_ADDR (reachable over WireGuard only)"
else
log "WARNING: binding to 0.0.0.0 exposes an open resolver publicly"
fi
}
install_cert_sync() {
# Caddy renews on its own schedule; stunnel reads files from disk and must
# be told. A timer copies the current cert out of the container and
# reloads stunnel only when the content actually changed.
sudo install -d -m 700 "$CERT_DIR"
sudo tee "$SYNC_SCRIPT" >/dev/null <<EOF
#!/bin/bash
# Copy the Caddy-managed certificate for $DOMAIN out to stunnel.
# Generated by $SCRIPT_NAME - edit that instead.
set -euo pipefail
src="$(cert_source_dir)"
tmp="\$(mktemp -d)"
trap 'rm -rf "\$tmp"' EXIT
docker cp "$CADDY_CONTAINER:\$src/$DOMAIN.crt" "\$tmp/cert.pem"
docker cp "$CADDY_CONTAINER:\$src/$DOMAIN.key" "\$tmp/key.pem"
cat "\$tmp/cert.pem" "\$tmp/key.pem" >"\$tmp/combined.pem"
if ! cmp -s "\$tmp/combined.pem" "$CERT_DIR/combined.pem"; then
install -m 600 "\$tmp/combined.pem" "$CERT_DIR/combined.pem"
systemctl reload-or-restart stunnel-dot.service
echo "certificate updated for $DOMAIN"
fi
EOF
sudo chmod 755 "$SYNC_SCRIPT"
}
install_stunnel_conf() {
sudo tee "$STUNNEL_CONF" >/dev/null <<EOF
; DNS-over-TLS front end for the local dnsmasq blocklist.
; Generated by $SCRIPT_NAME - edit that instead.
foreground = yes
syslog = yes
[dns-over-tls]
accept = $BIND_ADDR:$DOT_PORT
connect = 127.0.0.1:53
cert = $CERT_DIR/combined.pem
; DoT is TLS 1.2+ only in practice; Android requires a publicly trusted cert.
sslVersionMin = TLSv1.2
EOF
}
install_units() {
sudo tee /etc/systemd/system/stunnel-dot.service >/dev/null <<EOF
[Unit]
Description=DNS-over-TLS front end for dnsmasq blocklist
After=network-online.target dnsmasq.service wg-quick@wg0.service
Wants=network-online.target
Requires=dnsmasq.service
; Binding to the WireGuard address fails if the interface is not up yet.
After=sys-devices-virtual-net-wg0.device
[Service]
ExecStart=/usr/bin/stunnel $STUNNEL_CONF
ExecReload=/bin/kill -HUP \$MAINPID
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
sudo tee /etc/systemd/system/dot-cert-sync.service >/dev/null <<EOF
[Unit]
Description=Copy the Caddy certificate for $DOMAIN to stunnel
[Service]
Type=oneshot
ExecStart=$SYNC_SCRIPT
EOF
sudo tee /etc/systemd/system/dot-cert-sync.timer >/dev/null <<EOF
[Unit]
Description=Daily certificate sync for the DoT resolver
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
}
open_firewall() {
# Bound to WireGuard there is nothing to open: the tunnel is already
# permitted and the listener is not reachable from the LAN or the WAN.
if [[ "$BIND_ADDR" != "0.0.0.0" ]]; then
log "No firewall change needed - $BIND_ADDR is WireGuard-only."
log "No router port-forward needed either."
return
fi
if command -v nft >/dev/null 2>&1 && sudo nft list table inet filter >/dev/null 2>&1; then
if ! sudo nft list table inet filter | grep -q "dport $DOT_PORT"; then
sudo nft add rule inet filter input tcp dport "$DOT_PORT" accept ||
log "WARNING: could not add nftables rule for $DOT_PORT"
fi
fi
log "Ensure your router forwards TCP $DOT_PORT to this host."
}
run_check() {
local ok=0
systemctl is-active --quiet stunnel-dot.service ||
{
log "FAIL stunnel-dot.service not active"
ok=1
}
ss -lnt 2>/dev/null | grep -qE ":$DOT_PORT\b" ||
{
log "FAIL nothing listening on $DOT_PORT"
ok=1
}
# $CERT_DIR is mode 700 and owned by root because it holds a private key,
# so an unprivileged [[ -s ]] reports "missing" for a file that is
# present. Test as root, or --check reports a false failure on a healthy
# install.
sudo test -s "$CERT_DIR/combined.pem" ||
{
log "FAIL certificate missing at $CERT_DIR/combined.pem"
ok=1
}
if command -v kdig >/dev/null 2>&1; then
kdig +tls "@$BIND_ADDR" -p "$DOT_PORT" +short example.com >/dev/null 2>&1 ||
{
log "FAIL DoT query did not answer"
ok=1
}
fi
[[ $ok -eq 0 ]] && log "OK - DoT resolver healthy on $DOMAIN:$DOT_PORT"
return "$ok"
}
main() {
if [[ $CHECK_ONLY -eq 1 ]]; then
run_check
exit $?
fi
require_tools
verify_prerequisites
install_cert_sync
install_stunnel_conf
install_units
log "Syncing certificate..."
sudo "$SYNC_SCRIPT" || die "certificate sync failed - has Caddy issued $DOMAIN yet?"
sudo systemctl enable --now stunnel-dot.service
sudo systemctl enable --now dot-cert-sync.timer
open_firewall
log "Done. Point the phone at:"
log " Private DNS hostname: $DOMAIN"
log "And set in phone_focus_mode/config.sh:"
log " DNS_TRUSTED_DOT_HOST=\"$DOMAIN\""
log " DNS_TRUSTED_DOT_IPS=\"\$(getent hosts $DOMAIN | awk '{print \$1}')\""
run_check || true
}
while [[ $# -gt 0 ]]; do
case $1 in
--domain)
DOMAIN="$2"
shift 2
;;
--bind)
BIND_ADDR="$2"
shift 2
;;
--check)
CHECK_ONLY=1
shift
;;
-h | --help) usage ;;
*) die "Unknown option: $1" ;;
esac
done
main "$@"