mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-13 00:30:51 +01:00
The network layer was the wrong place for this. Tested remotely today: opening RethinkDNS and switching off its device blocklists un-blocked YouTube in a few taps, and no device owner API can stop that -- an on-device filter app always owns its own rules. App hiding is different. 'pm unhide' from adb shell throws a SecurityException and the package stays hidden; only the device owner can reverse it, and reaching that costs a factory reset. So the block moves there, scoped to an explicit allowlist and the home geofence. Both whitelists are rewritten from the apps that actually exist on this phone. The old ones described the rooted Blackview. Every browser is excluded now, so at home a link does not open at all -- confirmed as intended. LAUNCHER_PACKAGE was still the Blackview's launcher, which is not installed here. The exporter refused to render the policy twice over it -- precisely what that check is for. The network layer stays a soft deterrent by choice: pinned and uninstall-blocked, but without lockdown, and its blocklists remain switchable. Private DNS is implemented but ships disabled. stunnel logged zero connections from the handset across two pin attempts, so the router does not forward 853. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014DqEd1uUhyT52KjaNeLanC
310 lines
10 KiB
Bash
Executable File
310 lines
10 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
|
|
# Rate limited, because a publicly bound 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.
|
|
# A phone issues a handful of lookups a second at worst; 25/second
|
|
# with a burst of 50 is far above real use and far below abuse.
|
|
sudo nft add rule inet filter input tcp dport "$DOT_PORT" \
|
|
limit rate 25/second burst 50 packets accept ||
|
|
log "WARNING: could not add rate-limited nftables rule for $DOT_PORT"
|
|
log "Permitted TCP $DOT_PORT (rate limited 25/s, burst 50)"
|
|
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 "$@"
|