mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 21:47:02 +01:00
Measured on the Pixel 6a, twice: with private_dns_mode=hostname and the resolver unreachable, `ping example.com` returns "unknown host". Android's Private DNS does not fall back to the carrier's resolver -- hostname mode is RFC 8310's strict profile, and failing to establish TLS on 853 is a hard error. So a resolver living on this PC means "PC off" equals "phone has no internet", not merely "no filtering". That is disqualifying for a daily driver, and it disqualifies the whole home-hosted delivery path regardless of how well it works while the machine is up -- which it does; it was verified end to end. DNS_TRUSTED_DOT_HOST/IPS go back to empty, which restores the previous enforcer behaviour byte for byte. No code revert is needed, which is exactly why the flag was built to default empty. Two things found while testing are worth keeping. The nftables input chain is policy drop and admitted only ssh over wg0, so binding stunnel to the tunnel address was not sufficient on its own: ICMP passed while TCP 853 was dropped, which presents confusingly as "ping works but the port does not". That rule is now added explicitly and scoped to wg0. And setup_phone_wireguard.sh automates the client setup the tunnel needed, verifying handshake, reachability and DNS rather than assuming success, and deleting the pushed private key afterwards. The deeper reason the home endpoint could not work even with the tunnel up: dns.kuhy.duckdns.org resolves to the public IP while stunnel listens only on 10.8.0.1, and with AllowedIPs=10.8.0.0/24 that routes outside the tunnel to a closed port. Android never opened a connection at all. Split-horizon DNS does not fix it either, since on mobile data the phone uses the carrier's resolver. Research also ruled out the hosted-hybrid option that looked most attractive: no provider can ingest a 185k-domain list (NextDNS none, AdGuard 1-5K, ControlD 10K), and the free tiers fail OPEN -- they stop filtering past 300k queries a month while still resolving, which for a self-control tool is the worst failure mode available. Details and the remaining options are in the parked doc. The stunnel endpoint is left running deliberately: WireGuard-only, verified unreachable from LAN and WAN, and it makes the next attempt testable immediately. Removal commands are documented. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL
303 lines
9.6 KiB
Bash
Executable File
303 lines
9.6 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() {
|
|
# Binding to the WireGuard address is NOT sufficient on its own. The
|
|
# input chain has `policy drop` and admits only specific ports over wg0
|
|
# (ssh), so a DoT connection from the phone is dropped even though the
|
|
# tunnel itself is up and ICMP replies fine. That combination is
|
|
# confusing to debug -- ping works, the port does not -- so the rule is
|
|
# added explicitly, scoped to the tunnel interface.
|
|
if [[ "$BIND_ADDR" != "0.0.0.0" ]]; then
|
|
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 "iifname \"wg0\" tcp dport $DOT_PORT"; then
|
|
log "nftables already permits $DOT_PORT on wg0"
|
|
else
|
|
sudo nft add rule inet filter input iifname wg0 tcp dport "$DOT_PORT" accept ||
|
|
log "WARNING: could not add nftables rule for $DOT_PORT on wg0"
|
|
log "Permitted TCP $DOT_PORT on wg0 only"
|
|
fi
|
|
fi
|
|
log "No router port-forward needed - $BIND_ADDR is WireGuard-only."
|
|
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 "$@"
|