mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 21:47:02 +01:00
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:
parent
636b77a45d
commit
c05843153d
@ -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."
|
||||
}
|
||||
@ -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."
|
||||
]
|
||||
}
|
||||
188
linux_configuration/scripts/single_use/features/lib/dns_dhcp.sh
Executable file
188
linux_configuration/scripts/single_use/features/lib/dns_dhcp.sh
Executable 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
|
||||
}
|
||||
86
linux_configuration/scripts/single_use/features/lib/dns_firewall.sh
Executable file
86
linux_configuration/scripts/single_use/features/lib/dns_firewall.sh
Executable 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
|
||||
}
|
||||
109
linux_configuration/scripts/single_use/features/lib/dns_units.sh
Executable file
109
linux_configuration/scripts/single_use/features/lib/dns_units.sh
Executable 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
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user