Split setup_dns_blocker.sh, a live systemd unit target

569 -> 211 across three libs. All seven heredocs sit inside functions,
so function boundaries were safe seams.

The script guards its entry with SETUP_DNS_BLOCKER_SKIP_MAIN, which made
a real function-set comparison possible on a script that would otherwise
reconfigure the machine when sourced: an identical 74 names against a
detached worktree at HEAD. The dns-blocklist-refresh unit still resolves
to it and it still parses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
This commit is contained in:
Krzysztof Rudnicki 2026-08-16 21:31:03 +02:00
parent 636b77a45d
commit c05843153d
6 changed files with 444 additions and 365 deletions

View File

@ -0,0 +1,17 @@
{
"title": "Split setup_dns_blocker.sh under the 250-line cap",
"objective": "Bring the DNS blocker setup script under the cap. It is the ExecStart of the live dns-blocklist-refresh.service unit, so the unit path and the script's exported function set must be unchanged.",
"acceptance_criteria": [
"The entry script and all three new libs are at most 250 lines.",
"Sourcing the script yields exactly the same 74 function names as before, diffed against a detached worktree at HEAD. The script exposes SETUP_DNS_BLOCKER_SKIP_MAIN, so this can be checked without running main().",
"systemctl cat dns-blocklist-refresh.service still resolves to the script, and the script still parses.",
"Every seam falls on a function boundary, outside the seven heredocs.",
"The pre-commit shellcheck hook passes on every file, with no new suppression."
],
"out_of_scope": [
"Running main(); it installs dnsmasq, writes systemd units and changes firewall rules on the live machine.",
"Changing any generated config.",
"The other over-cap linux_configuration scripts."
],
"verifier": "SETUP_DNS_BLOCKER_SKIP_MAIN=1 source + `declare -F`, diffed against the same from a worktree at HEAD; plus systemctl cat and bash -n on the unit's ExecStart; plus pre-commit run --files and check_file_length.sh."
}

View File

@ -0,0 +1,37 @@
{
"intent": "Split setup_dns_blocker.sh under the cap without disturbing the live systemd unit that runs it.",
"scope": [
"linux_configuration/scripts/single_use/features/setup_dns_blocker.sh (569 -> 211)",
"lib/dns_units.sh, dns_firewall.sh, dns_dhcp.sh (new)",
"Non-goal: main() not run"
],
"changes": [
"Three libs: the dnsmasq config and systemd unit writers, the validation and firewall block, and the DHCP-server and status block. All seven heredocs sit inside functions, so function boundaries were safe seams.",
"The script guards its own entry with SETUP_DNS_BLOCKER_SKIP_MAIN, which made a proper function-set comparison possible on a script that otherwise reconfigures the machine when sourced."
],
"verification": [
{
"command": "SETUP_DNS_BLOCKER_SKIP_MAIN=1 source the script and diff `declare -F` against a detached worktree at HEAD",
"result": "pass",
"evidence": "Identical 74-function set (its own plus common.sh's). This is the same check used for the two shared libs, and it is available here only because the script provides that escape hatch."
},
{
"command": "systemctl cat dns-blocklist-refresh.service, test -f its ExecStart, bash -n that file",
"result": "pass",
"evidence": "Still resolves to the split script and still parses. This is one of only two units pointing into the repo."
},
{
"command": "bash -n on the entry and each lib; pre-commit run --files",
"result": "pass",
"evidence": "All four parse and the gate is clean, with no suppression."
}
],
"risks": [
"main() was not run: it installs dnsmasq, writes units, and changes nftables and DHCP settings on the live LAN. The function-set diff proves the surface, not the behaviour.",
"The refresh timer invokes the script with `refresh`, which reaches cmd_refresh through the same dispatch; that path is covered by the function-set check but not executed."
],
"rollback": [
"git revert the commit; the script returns to 569 lines and the three libs disappear.",
"After rollback, re-run the SKIP_MAIN function-set check and expect the same 74 names."
]
}

View File

@ -0,0 +1,188 @@
#!/bin/bash
# Static IP, DHCP server config and the status report.
#
# Sourced by setup_dns_blocker.sh; split out to keep it under the 250-line cap.
# Sourced rather than run, so it inherits the caller's strict mode.
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" <<EOF
# Managed by setup_dns_blocker.sh -- this PC is the LAN DHCP server.
# Only serve leases once the router's own DHCP is disabled (two servers clash).
dhcp-authoritative
dhcp-range=${net_prefix}.${DHCP_START_HOST},${net_prefix}.${DHCP_END_HOST},${DHCP_LEASE}
dhcp-option=option:router,${GATEWAY}
dhcp-option=option:dns-server,${LAN_IP}
# Reserve this PC's static address against its own MAC.
dhcp-host=${mac},${LAN_IP}
# Log every DHCP transaction (low volume; satisfies "log failures").
log-dhcp
EOF
}
cmd_dhcp() {
detect_lan
is_service_active dnsmasq ||
die "Run 'setup' first -- dnsmasq must be configured before enabling DHCP mode."
echo
log_warn "DHCP takeover: this PC (${LAN_IP}) will become the LAN's DHCP server."
log_warn "Disable the router's DHCP FIRST (untick 'wlacz serwer DHCP' -> 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 <<EOF
---- IF ANYTHING GOES WRONG (one command, cannot lock you out) ------------
sudo ${SELF} dhcp-off
-> 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 ==="
if is_service_active dnsmasq; then
status_line 0 "dnsmasq: active"
else
status_line 1 "dnsmasq: NOT active"
fi
if is_service_enabled dnsmasq; then
status_line 0 "dnsmasq: enabled at boot"
else
status_line 1 "dnsmasq: NOT enabled"
fi
if [[ -f $DNSMASQ_DROPIN ]]; then
status_line 0 "Restart=always drop-in present"
else
status_line 1 "Restart drop-in missing"
fi
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
if systemctl is-active dns-blocklist-refresh.timer &>/dev/null; then
status_line 0 "refresh timer: active (next $(systemctl show -p NextElapseUSecRealtime --value dns-blocklist-refresh.timer 2>/dev/null))"
else
status_line 1 "refresh timer: NOT active"
fi
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)"
if [[ $blocked == "0.0.0.0" ]]; then
status_line 0 "youtube.com -> ${blocked} (BLOCKED, correct)"
else
status_line 1 "youtube.com -> ${blocked:-<no answer>} (expected 0.0.0.0)"
fi
if [[ -n $passthru && $passthru != "0.0.0.0" ]]; then
status_line 0 "example.com -> ${passthru} (passthrough, correct)"
else
status_line 1 "example.com -> ${passthru:-<no answer>} (expected a real IP)"
fi
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
}

View File

@ -0,0 +1,86 @@
#!/bin/bash
# Validation, firewall rules and the allow-DNS flag.
#
# Sourced by setup_dns_blocker.sh; split out to keep it under the 250-line cap.
# Sourced rather than run, so it inherits the caller's strict mode.
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
systemctl enable --now dnsmasq-watchdog.timer
log_ok "dnsmasq, the daily refresh timer, and the watchdog 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 <<EOF
============================================================================
Manual steps this script cannot do (they are on the router / phone)
============================================================================
The PC (${LAN_IP}) is not the gateway, so devices only use it as DNS if they
are pointed at it. Pick ONE delivery method:
A) If your router lets you set the DHCP "DNS server" it advertises:
set it to ${LAN_IP} and ONLY that (no secondary DNS -- a backup DNS
silently disables blocking). Also reserve ${LAN_IP} for this PC.
B) If your router CANNOT set the advertised DNS (many ISP routers can't):
run './setup_dns_blocker.sh dhcp' to make THIS PC the LAN DHCP server
(disable the router's DHCP first). Fully automatic for every device.
C) Or set DNS = ${LAN_IP} by hand on each device (phone: WiFi -> 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
}

View File

@ -0,0 +1,109 @@
#!/bin/bash
# dnsmasq config, drop-in, refresh timer and watchdog units.
#
# Sourced by setup_dns_blocker.sh; split out to keep it under the 250-line cap.
# Sourced rather than run, so it inherits the caller's strict mode.
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" <<EOF
# Managed by setup_dns_blocker.sh -- LAN DNS blocker.
# Serves the identical blocklist as /etc/hosts to LAN clients via addn-hosts.
# Do not edit by hand; re-run 'setup_dns_blocker.sh setup' instead.
# Listen only on the LAN interface (leaves 127.0.0.53 free for the
# systemd-resolved stub that the /etc/hosts installer enables).
interface=${LAN_IFACE}
bind-interfaces
# Do NOT read /etc/hosts (huge + immutable); the blocklist comes from the feed.
no-hosts
addn-hosts=${FEED}
# Forward everything not in the blocklist to the upstream resolver ONLY.
# no-resolv: ignore /etc/resolv.conf so we never chain through the
# systemd-resolved stub (127.0.0.53) that the /etc/hosts installer enables.
no-resolv
server=${UPSTREAM}
domain-needed
bogus-priv
cache-size=10000
# Log dnsmasq's own messages (startup, upstream failures) -- "log failures".
log-facility=${LOG_FILE}
# log-queries # opt-in: full per-query log (verbose + privacy). Off by default.
EOF
}
install_restart_dropin() {
ensure_dir "$DNSMASQ_DROPIN_DIR"
cat >"$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" <<EOF
[Unit]
Description=Rebuild the LAN DNS blocklist and reload dnsmasq
After=network-online.target
[Service]
Type=oneshot
ExecStart=${SELF} refresh
EOF
cat >"$REFRESH_TIMER" <<'EOF'
[Unit]
Description=Daily rebuild of the LAN DNS blocklist
[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=15m
[Install]
WantedBy=timers.target
EOF
}
# The whole LAN's DHCP/DNS depends on this one process. Restart=always in the
# drop-in only catches an actual crash; an incident on 2026-07-23 found
# dnsmasq dead with ZERO error logged (no crash, no "Stopping"/"Stopped" line
# either -- consistent with something issuing a plain stop, not a failure) and
# it stayed down for hours, silently, until noticed by a phone unable to join
# WiFi. Mirrors the existing shutdown-timer-monitor-watchdog pattern used
# elsewhere in this repo: same 5-minute cadence, same is-active-or-start shape,
# plus a logger call so a future incident leaves a trace instead of none.
install_watchdog() {
cat >"$WATCHDOG_SERVICE" <<'EOF'
[Unit]
Description=Watchdog for dnsmasq (LAN DHCP/DNS)
After=multi-user.target
[Service]
Type=oneshot
ExecStart=/bin/bash -c 'systemctl is-active --quiet dnsmasq || { logger -t dnsmasq-watchdog "dnsmasq was down -- restarting"; systemctl restart dnsmasq; }'
EOF
cat >"$WATCHDOG_TIMER" <<'EOF'
[Unit]
Description=Watchdog Timer for dnsmasq
[Timer]
OnBootSec=60
OnUnitActiveSec=300
Persistent=true
[Install]
WantedBy=timers.target
EOF
}

View File

@ -114,190 +114,14 @@ build_feed() {
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" <<EOF
# Managed by setup_dns_blocker.sh -- LAN DNS blocker.
# Serves the identical blocklist as /etc/hosts to LAN clients via addn-hosts.
# Do not edit by hand; re-run 'setup_dns_blocker.sh setup' instead.
SCRIPT_DIR="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
# shellcheck source=lib/dns_units.sh
source "$SCRIPT_DIR/lib/dns_units.sh"
# shellcheck source=lib/dns_firewall.sh
source "$SCRIPT_DIR/lib/dns_firewall.sh"
# shellcheck source=lib/dns_dhcp.sh
source "$SCRIPT_DIR/lib/dns_dhcp.sh"
# Listen only on the LAN interface (leaves 127.0.0.53 free for the
# systemd-resolved stub that the /etc/hosts installer enables).
interface=${LAN_IFACE}
bind-interfaces
# Do NOT read /etc/hosts (huge + immutable); the blocklist comes from the feed.
no-hosts
addn-hosts=${FEED}
# Forward everything not in the blocklist to the upstream resolver ONLY.
# no-resolv: ignore /etc/resolv.conf so we never chain through the
# systemd-resolved stub (127.0.0.53) that the /etc/hosts installer enables.
no-resolv
server=${UPSTREAM}
domain-needed
bogus-priv
cache-size=10000
# Log dnsmasq's own messages (startup, upstream failures) -- "log failures".
log-facility=${LOG_FILE}
# log-queries # opt-in: full per-query log (verbose + privacy). Off by default.
EOF
}
install_restart_dropin() {
ensure_dir "$DNSMASQ_DROPIN_DIR"
cat >"$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" <<EOF
[Unit]
Description=Rebuild the LAN DNS blocklist and reload dnsmasq
After=network-online.target
[Service]
Type=oneshot
ExecStart=${SELF} refresh
EOF
cat >"$REFRESH_TIMER" <<'EOF'
[Unit]
Description=Daily rebuild of the LAN DNS blocklist
[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=15m
[Install]
WantedBy=timers.target
EOF
}
# The whole LAN's DHCP/DNS depends on this one process. Restart=always in the
# drop-in only catches an actual crash; an incident on 2026-07-23 found
# dnsmasq dead with ZERO error logged (no crash, no "Stopping"/"Stopped" line
# either -- consistent with something issuing a plain stop, not a failure) and
# it stayed down for hours, silently, until noticed by a phone unable to join
# WiFi. Mirrors the existing shutdown-timer-monitor-watchdog pattern used
# elsewhere in this repo: same 5-minute cadence, same is-active-or-start shape,
# plus a logger call so a future incident leaves a trace instead of none.
install_watchdog() {
cat >"$WATCHDOG_SERVICE" <<'EOF'
[Unit]
Description=Watchdog for dnsmasq (LAN DHCP/DNS)
After=multi-user.target
[Service]
Type=oneshot
ExecStart=/bin/bash -c 'systemctl is-active --quiet dnsmasq || { logger -t dnsmasq-watchdog "dnsmasq was down -- restarting"; systemctl restart dnsmasq; }'
EOF
cat >"$WATCHDOG_TIMER" <<'EOF'
[Unit]
Description=Watchdog Timer for dnsmasq
[Timer]
OnBootSec=60
OnUnitActiveSec=300
Persistent=true
[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
systemctl enable --now dnsmasq-watchdog.timer
log_ok "dnsmasq, the daily refresh timer, and the watchdog 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 <<EOF
============================================================================
Manual steps this script cannot do (they are on the router / phone)
============================================================================
The PC (${LAN_IP}) is not the gateway, so devices only use it as DNS if they
are pointed at it. Pick ONE delivery method:
A) If your router lets you set the DHCP "DNS server" it advertises:
set it to ${LAN_IP} and ONLY that (no secondary DNS -- a backup DNS
silently disables blocking). Also reserve ${LAN_IP} for this PC.
B) If your router CANNOT set the advertised DNS (many ISP routers can't):
run './setup_dns_blocker.sh dhcp' to make THIS PC the LAN DHCP server
(disable the router's DHCP first). Fully automatic for every device.
C) Or set DNS = ${LAN_IP} by hand on each device (phone: WiFi -> 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
@ -342,188 +166,6 @@ revert_nic_to_dhcp() {
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" <<EOF
# Managed by setup_dns_blocker.sh -- this PC is the LAN DHCP server.
# Only serve leases once the router's own DHCP is disabled (two servers clash).
dhcp-authoritative
dhcp-range=${net_prefix}.${DHCP_START_HOST},${net_prefix}.${DHCP_END_HOST},${DHCP_LEASE}
dhcp-option=option:router,${GATEWAY}
dhcp-option=option:dns-server,${LAN_IP}
# Reserve this PC's static address against its own MAC.
dhcp-host=${mac},${LAN_IP}
# Log every DHCP transaction (low volume; satisfies "log failures").
log-dhcp
EOF
}
cmd_dhcp() {
detect_lan
is_service_active dnsmasq ||
die "Run 'setup' first -- dnsmasq must be configured before enabling DHCP mode."
echo
log_warn "DHCP takeover: this PC (${LAN_IP}) will become the LAN's DHCP server."
log_warn "Disable the router's DHCP FIRST (untick 'wlacz serwer DHCP' -> 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 <<EOF
---- IF ANYTHING GOES WRONG (one command, cannot lock you out) ------------
sudo ${SELF} dhcp-off
-> 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 ==="
if is_service_active dnsmasq; then
status_line 0 "dnsmasq: active"
else
status_line 1 "dnsmasq: NOT active"
fi
if is_service_enabled dnsmasq; then
status_line 0 "dnsmasq: enabled at boot"
else
status_line 1 "dnsmasq: NOT enabled"
fi
if [[ -f $DNSMASQ_DROPIN ]]; then
status_line 0 "Restart=always drop-in present"
else
status_line 1 "Restart drop-in missing"
fi
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
if systemctl is-active dns-blocklist-refresh.timer &>/dev/null; then
status_line 0 "refresh timer: active (next $(systemctl show -p NextElapseUSecRealtime --value dns-blocklist-refresh.timer 2>/dev/null))"
else
status_line 1 "refresh timer: NOT active"
fi
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)"
if [[ $blocked == "0.0.0.0" ]]; then
status_line 0 "youtube.com -> ${blocked} (BLOCKED, correct)"
else
status_line 1 "youtube.com -> ${blocked:-<no answer>} (expected 0.0.0.0)"
fi
if [[ -n $passthru && $passthru != "0.0.0.0" ]]; then
status_line 0 "example.com -> ${passthru} (passthrough, correct)"
else
status_line 1 "example.com -> ${passthru:-<no answer>} (expected a real IP)"
fi
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 <<EOF