diff --git a/docs/superpowers/evidence/setup-dns-blocker-2026-07-10.json b/docs/superpowers/evidence/setup-dns-blocker-2026-07-10.json new file mode 100644 index 00000000..91978f16 --- /dev/null +++ b/docs/superpowers/evidence/setup-dns-blocker-2026-07-10.json @@ -0,0 +1,43 @@ +{ + "intent": "Give every device on the LAN the same domain blocking as the PC's /etc/hosts, with no app installed on the device, by running the PC as a Pi-hole-like DNS (and, for this router, DHCP) server.", + "scope": [ + "linux_configuration/scripts/single_use/features/setup_dns_blocker.sh (new: dnsmasq-based LAN DNS blocker + optional DHCP-server mode)", + "linux_configuration/scripts/single_use/features/setup_wireguard_ssh.sh (new allow-dns subcommand: opens DNS 53 + DHCP 67 for the LAN in the single-owner nftables ruleset)", + "linux_configuration/tests/test_setup_dns_blocker.sh (new regression tests)", + "Non-goals: no Pi-hole/web dashboard, no per-query analytics, no DoH enforcement on clients (a voluntary-DNS design cannot force off-path traffic)." + ], + "changes": [ + "dnsmasq serves the identical blocklist as /etc/hosts via addn-hosts pointed at generate_hosts_file.sh output (same StevenBlack variant + custom entries + unblocks); no-hosts + no-resolv keep it deterministic; bound to the LAN interface only so the systemd-resolved stub stays free.", + "Daily systemd timer rebuilds the feed and SIGHUPs dnsmasq; Restart=always drop-in; logs to /var/log/dns-blocker/.", + "dhcp/dhcp-off subcommands: because the ISP router cannot advertise a custom DNS, the PC becomes the LAN DHCP server (static IP via nmcli with a gateway-ping self-check + auto-revert; hands out itself as DNS). Rollback keeps the PC online on its static IP.", + "Firewall: allow-dns opens udp/tcp 53 (LAN saddr) and udp 67 for DHCP. The DHCP accept rule is placed BEFORE 'ct state invalid drop' because the 0.0.0.0->255.255.255.255 broadcast is classified INVALID and was being dropped.", + "Fixed firewall detection to read the actual loaded nftables ruleset (a ruleset can be loaded while nftables.service reads inactive), avoiding a grep -q/pipefail SIGPIPE false-negative." + ], + "verification": [ + { + "command": "sudo setup_dns_blocker.sh setup && sudo setup_dns_blocker.sh dhcp && setup_dns_blocker.sh status", + "result": "pass", + "evidence": "status all green: dnsmasq active/enabled, listening 192.168.1.43:53, firewall DNS/DHCP open for LAN, DHCP mode ON. Pixel-6a leased 192.168.1.82 with option6 dns-server=192.168.1.43 (DHCPDISCOVER->OFFER->REQUEST->ACK). dig @192.168.1.43 youtube.com -> 0.0.0.0 (blocked); example.com/wikipedia.org -> real IP." + }, + { + "command": "on-device manual test (Pixel 6a)", + "result": "pass", + "evidence": "User confirmed: legit websites load, youtube.com is blocked." + }, + { + "command": "bash linux_configuration/tests/test_setup_dns_blocker.sh", + "result": "pass", + "evidence": "PASS: dispatch (help lists all subcommands, unknown cmd exits non-zero) + firewall_is_loaded regression (drop->LOADED, accept->NOT_LOADED under set -o pipefail with large nft output)." + } + ], + "risks": [ + "Voluntary DNS: a device using DoH / Android Private DNS / a VPN / a manual DNS bypasses blocking; the PC is not the gateway so it cannot force traffic. Phone Private DNS must be Off.", + "PC is now the LAN DHCP server: if it is down, new devices get no lease (existing leases persist ~12h). Fail-closed by design.", + "Static IP pinned on enp6s0; a wrong gateway would break connectivity (mitigated by an immediate ping self-check that auto-reverts)." + ], + "rollback": [ + "DHCP: sudo setup_dns_blocker.sh dhcp-off (stops serving DHCP, KEEPS the PC on its static IP so it stays online), then re-tick the router's DHCP.", + "DNS blocker: systemctl disable --now dnsmasq dns-blocklist-refresh.timer; rm /etc/dnsmasq.d/lan-blocker.conf /etc/dnsmasq.d/lan-dhcp.conf.", + "Validate after rollback: devices get leases from the router again and normal DNS resolves." + ] +} diff --git a/linux_configuration/scripts/single_use/features/setup_dns_blocker.sh b/linux_configuration/scripts/single_use/features/setup_dns_blocker.sh new file mode 100755 index 00000000..12532e38 --- /dev/null +++ b/linux_configuration/scripts/single_use/features/setup_dns_blocker.sh @@ -0,0 +1,517 @@ +#!/bin/bash +# ============================================================================ +# setup_dns_blocker.sh -- Pi-hole-like LAN DNS blocking, hosted on this PC. +# +# Turns this machine into a DNS server (dnsmasq) that serves the EXACT same +# blocklist as the local /etc/hosts to every device on the LAN, with no app +# installed on those devices. The blocklist feed is produced verbatim by the +# repo's generate_hosts_file.sh (same StevenBlack variant + custom entries + +# unblocks that /etc/hosts uses), so blocking is identical by construction. +# +# The PC is NOT the gateway, so it can only be a resolver that clients are +# POINTED at (via the router's DHCP-advertised DNS). See the manual steps +# printed by `setup`. Known bypasses (DoH / Private DNS / manual DNS / VPN) +# are documented there too -- a voluntary-DNS design cannot force traffic. +# +# Usage: +# sudo ./setup_dns_blocker.sh setup # first-time install + enable +# ./setup_dns_blocker.sh status # is it set up & serving? (no root) +# sudo ./setup_dns_blocker.sh refresh # rebuild feed + reload (timer runs this) +# ./setup_dns_blocker.sh help +# +# Idempotent and safe to re-run. +# ============================================================================ + +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" +# shellcheck source=../../lib/common.sh +source "$SCRIPT_DIR/../../lib/common.sh" + +# ---- Configuration --------------------------------------------------------- +readonly FEED_DIR="/var/lib/dns-blocker" +readonly FEED="${FEED_DIR}/blocklist.hosts" +readonly DNSMASQ_MAIN_CONF="/etc/dnsmasq.conf" +readonly DNSMASQ_CONF="/etc/dnsmasq.d/lan-blocker.conf" +readonly CONF_DIR_LINE="conf-dir=/etc/dnsmasq.d/,*.conf" +readonly LOG_DIR="/var/log/dns-blocker" +readonly LOG_FILE="${LOG_DIR}/dnsmasq.log" +readonly DNSMASQ_DROPIN_DIR="/etc/systemd/system/dnsmasq.service.d" +readonly DNSMASQ_DROPIN="${DNSMASQ_DROPIN_DIR}/blocker.conf" +readonly REFRESH_SERVICE="/etc/systemd/system/dns-blocklist-refresh.service" +readonly REFRESH_TIMER="/etc/systemd/system/dns-blocklist-refresh.timer" +# Optional DHCP-server mode (for routers that cannot advertise a custom DNS). +readonly DHCP_CONF="/etc/dnsmasq.d/lan-dhcp.conf" +readonly DHCP_LEASE="12h" +readonly DHCP_START_HOST="10" # .10 .. .150 mirrors a common router default range +readonly DHCP_END_HOST="150" +# Feed generator (chattr +i but still executable) and firewall owner script. +readonly GEN="${SCRIPT_DIR}/../../periodic_background/hosts/generate_hosts_file.sh" +readonly WG_SCRIPT="${SCRIPT_DIR}/setup_wireguard_ssh.sh" +readonly SELF="${SCRIPT_DIR}/setup_dns_blocker.sh" + +die() { + log_error "$1" + exit 1 +} + +# ---- LAN autodetection ----------------------------------------------------- +# Derive the LAN interface / IP / gateway from the default route so the config +# is not hardcoded to one NIC name. +detect_lan() { + local route + route="$(ip -4 route get 1.1.1.1 2>/dev/null || true)" + LAN_IFACE="$(awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}' <<<"$route")" + LAN_IP="$(awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}' <<<"$route")" + GATEWAY="$(ip -4 route show default 2>/dev/null | awk '{print $3; exit}')" + [[ -n ${LAN_IFACE:-} && -n ${LAN_IP:-} ]] || + die "Could not detect the LAN interface/IP from the default route." + # Upstream for non-blocked names: the gateway if known, else a public resolver. + UPSTREAM="${GATEWAY:-1.1.1.1}" +} + +# ---- Setup steps ----------------------------------------------------------- +install_dnsmasq() { + # dnsmasq is the resolver; bind provides 'dig' for the status live test. + local pkgs=() + has_cmd dnsmasq || pkgs+=(dnsmasq) + has_cmd dig || pkgs+=(bind) + if ((${#pkgs[@]})); then + log_info "Installing: ${pkgs[*]}" + install_missing_pacman_packages "${pkgs[@]}" + else + log_ok "dnsmasq and dig already installed." + fi + # dnsmasq only reads /etc/dnsmasq.d/* if the main conf enables conf-dir. + if grep -qE '^\s*conf-dir=' "$DNSMASQ_MAIN_CONF" 2>/dev/null; then + log_ok "conf-dir already enabled in ${DNSMASQ_MAIN_CONF}." + else + log_info "Enabling conf-dir in ${DNSMASQ_MAIN_CONF}." + printf '\n# Added by setup_dns_blocker.sh -- load /etc/dnsmasq.d/*.conf\n%s\n' \ + "$CONF_DIR_LINE" >>"$DNSMASQ_MAIN_CONF" + fi +} + +prepare_dirs() { + ensure_dir "$FEED_DIR" + ensure_dir "$LOG_DIR" + # dnsmasq drops to the 'dnsmasq' user; it must be able to write its log. + touch "$LOG_FILE" + if id dnsmasq &>/dev/null; then + chown dnsmasq:dnsmasq "$LOG_DIR" "$LOG_FILE" + fi +} + +build_feed() { + [[ -x $GEN ]] || die "Feed generator not found or not executable: ${GEN}" + log_info "Building blocklist feed from generate_hosts_file.sh (this is the same list as /etc/hosts)..." + "$GEN" "$FEED" || die "Feed generation failed." + local count + count="$(wc -l <"$FEED")" + ((count > 1000)) || die "Feed looks too small (${count} lines) -- refusing to serve a broken blocklist." + log_ok "Feed built: ${count} lines at ${FEED}." +} + +write_dnsmasq_conf() { + ensure_dir "$(dirname "$DNSMASQ_CONF")" # /etc/dnsmasq.d may not exist yet + log_info "Writing ${DNSMASQ_CONF} (listen on ${LAN_IFACE}/${LAN_IP}, upstream ${UPSTREAM})." + cat >"$DNSMASQ_CONF" <"$DNSMASQ_DROPIN" <<'EOF' +# Managed by setup_dns_blocker.sh -- keep the resolver up and wait for the +# network so bind-interfaces can bind the LAN address at boot. +[Unit] +After=network-online.target +Wants=network-online.target + +[Service] +Restart=always +RestartSec=5 +EOF +} + +install_refresh_timer() { + cat >"$REFRESH_SERVICE" <"$REFRESH_TIMER" <<'EOF' +[Unit] +Description=Daily rebuild of the LAN DNS blocklist + +[Timer] +OnCalendar=daily +Persistent=true +RandomizedDelaySec=15m + +[Install] +WantedBy=timers.target +EOF +} + +validate_and_enable() { + log_info "Validating dnsmasq configuration..." + dnsmasq --test 2>&1 | tail -3 + dnsmasq --test >/dev/null 2>&1 || die "dnsmasq config failed validation -- not enabling." + log_ok "dnsmasq config valid." + systemctl daemon-reload + enable_service dnsmasq + # enable --now does not restart an already-running daemon, so restart to + # apply config changes on idempotent re-runs. + systemctl restart dnsmasq + systemctl enable --now dns-blocklist-refresh.timer + log_ok "dnsmasq and the daily refresh timer are enabled." +} + +# Open port 53 for LAN clients -- but only touch the firewall if it is already +# the active (wireguard-managed) default-drop ruleset. If nftables is inactive, +# port 53 is already reachable and force-loading the firewall here could +# disrupt other services, so we only persist the intent and instruct the user. +# A default-drop nftables ruleset can be loaded in the kernel even while +# nftables.service reads "inactive", so detect the actual ruleset, not the unit. +firewall_is_loaded() { + # Capture then match (no 'grep -q' pipe: it closes early -> SIGPIPE on nft -> + # pipefail makes the whole pipeline fail, a false negative under set -o pipefail). + local rules + rules="$(nft list chain inet filter input 2>/dev/null || true)" + [[ $rules == *"policy drop"* ]] +} + +configure_firewall() { + if firewall_is_loaded; then + log_info "Default-drop firewall detected; opening DNS/DHCP for the LAN via the firewall owner." + bash "$WG_SCRIPT" allow-dns + else + log_warn "No default-drop firewall loaded -- DNS/DHCP already reachable on the LAN." + persist_allow_dns_flag + log_warn "If you later enable the default-drop firewall, run: sudo ${WG_SCRIPT} allow-dns" + fi +} + +# Record ALLOW_DNS=true in the wireguard config so a future firewall apply keeps +# :53 open for the LAN, without activating the firewall now. +persist_allow_dns_flag() { + local cfg="${SCRIPT_DIR}/.wireguard_ssh.conf" + [[ -f $cfg ]] || return 0 + if grep -qE '^ALLOW_DNS=' "$cfg"; then + sed -i 's/^ALLOW_DNS=.*/ALLOW_DNS="true"/' "$cfg" + else + printf 'ALLOW_DNS="true"\n' >>"$cfg" + fi + log_ok "Persisted ALLOW_DNS=true in ${cfg} for the next firewall apply." +} + +print_manual_steps() { + cat < Static -> DNS). + + PHONE (all methods) -- turn Private DNS OFF (Settings -> Network -> Private + DNS -> Off). Android's default "Automatic" uses DoH and bypasses LAN DNS. + This is a toggle, not an app, so it keeps the "no extra app" rule. + + Known limitations (voluntary DNS): a device using DoH, a VPN, or a manually + set DNS bypasses this. It cannot be forced because the PC is not in the + traffic path. Works for the common case (devices that honor network DNS). +============================================================================ +EOF +} + +cmd_setup() { + detect_lan + install_dnsmasq + prepare_dirs + build_feed + write_dnsmasq_conf + install_restart_dropin + install_refresh_timer + validate_and_enable + configure_firewall + print_manual_steps + log_ok "DNS blocker setup complete. Run './setup_dns_blocker.sh status' to verify." +} + +cmd_refresh() { + [[ -x $GEN ]] || die "Feed generator not found: ${GEN}" + log_info "Refreshing blocklist feed..." + "$GEN" "$FEED" || die "Feed generation failed." + if is_service_active dnsmasq; then + systemctl kill -s HUP dnsmasq + log_ok "Feed refreshed and dnsmasq reloaded ($(wc -l <"$FEED") lines)." + else + log_warn "Feed refreshed but dnsmasq is not running -- start it with: systemctl start dnsmasq" + fi +} + +# ---- DHCP-server mode ------------------------------------------------------ +# For routers that cannot advertise a custom DNS server: the PC takes over LAN +# DHCP and hands out itself as the DNS server, so every device is blocked with +# no per-device config. Requires a static IP on the PC (else it cannot get an +# address once the router's DHCP is off). +nm_connection() { + nmcli -t -f NAME,DEVICE con show --active | + awk -F: -v d="$LAN_IFACE" '$2==d{print $1; exit}' +} + +revert_nic_to_dhcp() { + local con="$1" + nmcli con mod "$con" ipv4.method auto ipv4.addresses "" ipv4.gateway "" ipv4.dns "" + nmcli con up "$con" >/dev/null 2>&1 || true +} + +configure_static_ip() { + has_cmd nmcli || die "nmcli (NetworkManager) not found; cannot pin a static IP automatically." + local con + con="$(nm_connection)" + [[ -n $con ]] || die "No active NetworkManager connection on ${LAN_IFACE}." + log_info "Pinning ${LAN_IFACE} to static ${LAN_IP}/24 (gw ${GATEWAY}) on '${con}'..." + nmcli con mod "$con" \ + ipv4.method manual \ + ipv4.addresses "${LAN_IP}/24" \ + ipv4.gateway "$GATEWAY" \ + ipv4.dns "$GATEWAY" + nmcli con up "$con" >/dev/null + # Safety self-check: if the static config broke connectivity, auto-revert to + # DHCP-client mode immediately so the machine never ends up stranded. + if ping -c1 -W3 "$GATEWAY" >/dev/null 2>&1; then + log_ok "Static IP applied; gateway reachable. ${LAN_IFACE} no longer needs external DHCP." + else + log_error "Gateway unreachable after static IP change -- auto-reverting to DHCP." + revert_nic_to_dhcp "$con" + die "Static IP self-check failed; reverted to DHCP. DHCP mode aborted (no changes kept)." + fi +} + +write_dhcp_conf() { + local mac net_prefix + mac="$(cat "/sys/class/net/${LAN_IFACE}/address")" + net_prefix="${LAN_IP%.*}" + log_info "Writing ${DHCP_CONF} (range ${net_prefix}.${DHCP_START_HOST}-${net_prefix}.${DHCP_END_HOST}, DNS ${LAN_IP})." + cat >"$DHCP_CONF" < Zapisz)," + log_warn "otherwise two DHCP servers will fight on the LAN." + if ! ask_yes_no "Have you already disabled the router's DHCP server?"; then + log_warn "Not activating -- two DHCP servers on one LAN conflict." + log_warn "Disable the router's DHCP ('wlacz serwer DHCP' -> Zapisz), then re-run: sudo ${SELF} dhcp" + return 0 + fi + configure_static_ip + write_dhcp_conf + dnsmasq --test >/dev/null 2>&1 || die "dnsmasq config invalid after adding DHCP -- not restarting." + systemctl restart dnsmasq + log_ok "This PC is now the LAN DHCP server; new leases advertise ${LAN_IP} as DNS." + log_info "Reconnect a device's WiFi to pick up the new lease, then browse to a blocked site to confirm." + cat < stops serving DHCP, KEEPS this PC online on ${LAN_IP}. + Then re-tick the router's DHCP ('wlacz serwer DHCP' -> Zapisz) so other + devices get addresses again. This PC stays reachable throughout. + -------------------------------------------------------------------------- +EOF +} + +# Roll back DHCP mode: stop serving leases. KEEPS this PC on its static IP so +# running rollback can never strand the machine (the router reserves ${LAN_IP} +# anyway). Re-enable the router's DHCP afterwards for the other devices. +cmd_dhcp_off() { + detect_lan + if [[ -f $DHCP_CONF ]]; then + rm -f "$DHCP_CONF" + log_ok "Removed ${DHCP_CONF} (PC no longer serves DHCP)." + else + log_info "No DHCP config present; nothing to remove." + fi + is_service_active dnsmasq && systemctl restart dnsmasq + log_ok "DHCP serving stopped. This PC keeps its static IP (${LAN_IP}); still online." + log_warn "Now re-enable the router's DHCP ('wlacz serwer DHCP' -> Zapisz) so other devices get addresses." + log_info "Optional: to also put THIS PC back on router DHCP (after re-enabling it above), run:" + log_info " nmcli con mod '$(nm_connection)' ipv4.method auto ipv4.addresses '' ipv4.gateway '' ipv4.dns '' && nmcli con up '$(nm_connection)'" +} + +# ---- Status ---------------------------------------------------------------- +status_line() { + # $1 ok/bad flag ("0" ok), $2 message + if [[ $1 == "0" ]]; then log_ok "$2"; else log_warn "$2"; fi +} + +cmd_status() { + detect_lan + echo "=== LAN DNS blocker status ===" + + is_service_active dnsmasq && status_line 0 "dnsmasq: active" || status_line 1 "dnsmasq: NOT active" + is_service_enabled dnsmasq && status_line 0 "dnsmasq: enabled at boot" || status_line 1 "dnsmasq: NOT enabled" + [[ -f $DNSMASQ_DROPIN ]] && status_line 0 "Restart=always drop-in present" || status_line 1 "Restart drop-in missing" + + if [[ -f $FEED ]]; then + status_line 0 "blocklist feed: $(wc -l <"$FEED") lines, updated $(date -r "$FEED" '+%Y-%m-%d %H:%M')" + else + status_line 1 "blocklist feed missing: ${FEED}" + fi + + if ss -tulnp 2>/dev/null | grep -qE "${LAN_IP}:53|:53 "; then + status_line 0 "listening on ${LAN_IP}:53" + else + status_line 1 "not listening on ${LAN_IP}:53" + fi + + local fw_input + fw_input="$(nft list chain inet filter input 2>/dev/null || true)" + if [[ $fw_input == *"policy drop"* ]]; then + if [[ $fw_input == *"dport 53"* ]]; then + status_line 0 "firewall: DNS/DHCP open for LAN" + else + status_line 1 "firewall loaded but DNS/DHCP NOT open -- run: sudo ${WG_SCRIPT} allow-dns" + fi + elif [[ $EUID -ne 0 ]]; then + log_info "firewall: run 'sudo $0 status' to inspect nftables (needs root)" + else + status_line 0 "firewall: no default-drop ruleset loaded (DNS/DHCP not filtered)" + fi + + systemctl is-active dns-blocklist-refresh.timer &>/dev/null && + status_line 0 "refresh timer: active (next $(systemctl show -p NextElapseUSecRealtime --value dns-blocklist-refresh.timer 2>/dev/null))" || + status_line 1 "refresh timer: NOT active" + + if [[ -f $DHCP_CONF ]]; then + if ss -ulnp 2>/dev/null | grep -q ':67 '; then + status_line 0 "DHCP mode: ON (serving leases; PC is the LAN DHCP server)" + else + status_line 1 "DHCP mode: config present but not serving -- restart dnsmasq" + fi + else + status_line 0 "DHCP mode: off (using router DHCP / manual per-device DNS)" + fi + + echo + echo "=== Live resolution test (via ${LAN_IP}) ===" + if has_cmd dig; then + local blocked passthru + blocked="$(dig +short +time=2 +tries=1 @"${LAN_IP}" youtube.com 2>/dev/null | head -1)" + passthru="$(dig +short +time=2 +tries=1 @"${LAN_IP}" example.com 2>/dev/null | head -1)" + [[ $blocked == "0.0.0.0" ]] && + status_line 0 "youtube.com -> ${blocked} (BLOCKED, correct)" || + status_line 1 "youtube.com -> ${blocked:-} (expected 0.0.0.0)" + [[ -n $passthru && $passthru != "0.0.0.0" ]] && + status_line 0 "example.com -> ${passthru} (passthrough, correct)" || + status_line 1 "example.com -> ${passthru:-} (expected a real IP)" + else + log_warn "install 'dig' (bind) to run the live resolution test." + fi + + if [[ -s $LOG_FILE ]]; then + echo + echo "=== Recent dnsmasq log (${LOG_FILE}) ===" + tail -5 "$LOG_FILE" + fi +} + +usage() { + cat < + +Commands: + setup First-time install: dnsmasq + blocklist feed + refresh timer + firewall (root). + status Show whether the blocker is set up and serving, with a live dig test. + refresh Rebuild the blocklist feed and reload dnsmasq (root; run by the timer). + dhcp Make this PC the LAN DHCP server so every device uses it as DNS (root). + Use when the router cannot advertise a custom DNS. Disable router DHCP first. + dhcp-off Revert DHCP mode: stop serving leases, return the NIC to DHCP-client (root). + help Show this message. +EOF +} + +main() { + local cmd="${1:-help}" + case "$cmd" in + setup | refresh | dhcp | dhcp-off) + require_root "$@" + ;; + esac + + case "$cmd" in + setup) cmd_setup ;; + refresh) cmd_refresh ;; + dhcp) cmd_dhcp ;; + dhcp-off) cmd_dhcp_off ;; + status) cmd_status ;; + help | -h | --help) usage ;; + *) + log_error "Unknown command: $cmd" + usage + exit 1 + ;; + esac +} + +# Guard lets tests source this file to exercise individual functions. +if [[ "${SETUP_DNS_BLOCKER_SKIP_MAIN:-}" != "1" ]]; then + main "$@" +fi diff --git a/linux_configuration/scripts/single_use/features/setup_wireguard_ssh.sh b/linux_configuration/scripts/single_use/features/setup_wireguard_ssh.sh index 6f4e4c62..42f6ee0e 100755 --- a/linux_configuration/scripts/single_use/features/setup_wireguard_ssh.sh +++ b/linux_configuration/scripts/single_use/features/setup_wireguard_ssh.sh @@ -38,6 +38,7 @@ DUCKDNS_DOMAIN="${DUCKDNS_DOMAIN:-}" DUCKDNS_TOKEN="${DUCKDNS_TOKEN:-}" LAN_SUBNET="${LAN_SUBNET:-}" ALLOW_WEB="${ALLOW_WEB:-false}" +ALLOW_DNS="${ALLOW_DNS:-false}" die() { log_error "$1" @@ -50,6 +51,7 @@ DUCKDNS_DOMAIN="${DUCKDNS_DOMAIN}" DUCKDNS_TOKEN="${DUCKDNS_TOKEN}" LAN_SUBNET="${LAN_SUBNET}" ALLOW_WEB="${ALLOW_WEB}" +ALLOW_DNS="${ALLOW_DNS}" EOF chmod 600 "$CONFIG_FILE" } @@ -119,6 +121,19 @@ write_nftables_ruleset() { if [[ $ALLOW_WEB == "true" ]]; then web_rule=$'\n\t\ttcp dport { 80, 443 } accept' fi + # DNS blocker (setup_dns_blocker.sh): serve DNS (53) -- and DHCP (67) when + # this PC is the LAN DHCP server -- to LAN clients only. Restricted to the + # LAN subnet so nothing is ever exposed to the internet. + local dns_rule="" dhcp_rule="" + if [[ $ALLOW_DNS == "true" ]]; then + dns_rule=$'\n\t\tip saddr '"${LAN_SUBNET}"$' udp dport 53 accept' + dns_rule+=$'\n\t\tip saddr '"${LAN_SUBNET}"$' tcp dport 53 accept' + # DHCP clients have no IP yet (saddr 0.0.0.0 -> 255.255.255.255) and the + # broadcast is classified INVALID by conntrack -- so this rule MUST sit + # BEFORE 'ct state invalid drop' and match the client source port, not a + # saddr. Link-local only: :67 broadcasts never route in from the internet. + dhcp_rule=$'\n\t\tudp sport 68 udp dport 67 accept' + fi cat >"${NFT_CONF}.new" </dev/null || echo "(interface not up)" @@ -377,6 +400,7 @@ Commands: setup Full first-time setup (WireGuard, firewall, sshd, DuckDNS). add-peer Provision a new phone/laptop and print its QR code. allow-web Open tcp/80 and tcp/443 in the firewall (for a web server on this host). + allow-dns Open udp/tcp 53 from the LAN in the firewall (for the DNS blocker on this host). status Show WireGuard/firewall/sshd status. revoke Remove a peer. help Show this message. @@ -390,7 +414,7 @@ main() { # off -- exec sudo "$0" "$@" inside require_root must re-launch with the # subcommand still present, or sudo would silently run with no args. case "$cmd" in - setup | add-peer | revoke | allow-web) + setup | add-peer | revoke | allow-web | allow-dns) require_root "$@" ;; esac @@ -417,6 +441,9 @@ main() { allow-web) allow_web ;; + allow-dns) + allow_dns + ;; status) status_cmd ;; diff --git a/linux_configuration/tests/test_setup_dns_blocker.sh b/linux_configuration/tests/test_setup_dns_blocker.sh new file mode 100755 index 00000000..f687c93c --- /dev/null +++ b/linux_configuration/tests/test_setup_dns_blocker.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# Regression tests for setup_dns_blocker.sh (LAN DNS blocker). +# +# Covers the pure, root-free logic: +# - command dispatch (help lists all subcommands, unknown -> exit 1) +# - firewall_is_loaded: the pipefail/SIGPIPE regression -- 'nft | grep -q' +# used to false-negative under 'set -o pipefail' because grep closed the +# pipe early and nft died on SIGPIPE. Detection must survive large output. + +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +REPO_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd) +TARGET_SCRIPT="$REPO_DIR/scripts/single_use/features/setup_dns_blocker.sh" + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +TMP_DIR=$(mktemp -d) +BIN_DIR="$TMP_DIR/bin" +mkdir -p "$BIN_DIR" +cleanup() { rm -rf "$TMP_DIR"; } +trap cleanup EXIT + +# --- Dispatch tests (real script, no root needed) -------------------------- +help_out=$("$TARGET_SCRIPT" help) +for cmd in setup status refresh dhcp dhcp-off; do + [[ "$help_out" == *"$cmd"* ]] || fail "help output missing '$cmd' subcommand" +done + +# Unknown command must exit non-zero. +if "$TARGET_SCRIPT" bogus-cmd >/dev/null 2>&1; then + fail "unknown command should exit non-zero" +fi + +# --- firewall_is_loaded regression (pipefail/SIGPIPE) ---------------------- +# Mock 'nft' so the function runs without root. Emit a LARGE ruleset so that a +# reintroduced 'grep -q' pipe would SIGPIPE nft and false-negative under pipefail. +make_nft() { + local mode="$1" # drop | accept + cat >"$BIN_DIR/nft" <