testsAndMisc/linux_configuration/scripts/single_use/features/setup_dns_blocker.sh
Krzysztof Rudnicki b83ce5d3c7
Some checks failed
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
billsplit CI / quality (push) Has been cancelled
reverse-survivors CI / check (push) Has been cancelled
Fail the build on any shellcheck finding; fix the 222 the old gate hid
The shellcheck hook ran with --severity=warning, so info- and style-level
findings never failed a commit. 222 had accumulated across 94 of 244 shell
files. They were not all style noise -- six were bugs:

  - Five scripts in single_use/misc/testsAndMisc-bash/ sourced
    $SCRIPT_DIR/../../lib/common.sh, one directory too shallow. Nothing exists
    at that path, so all five failed at runtime.
  - setup_personal_website.sh and setup_kcd2_dice_solver.sh called usage()
    (which ends in `exit 0`) and then `exit 1` for an unknown command. The
    exit 1 was unreachable, so a typo'd subcommand reported SUCCESS.
  - Six i3blocks scripts had a `# shellcheck source=` directive missing the
    scripts/periodic_background/ path segment. The stale path had silently
    disabled source-following for all of them.
  - diagnose_arch_performance.sh word-split a newline-joined file list; when
    the find came back empty, awk got no file operands, fell through to
    reading stdin, and the diagnostic hung.
  - lib/common.sh's handle_arg_help_or_unknown ended in an unreachable
    `return 0` while documenting "Returns: 0 if handled" -- a contract no code
    path could satisfy.
  - deploy.sh's SCRIPT_DIR is reassigned by config.sh, which it sources at top
    level, and config.sh can repoint it at the on-device path
    /data/local/tmp/focus_mode. Renamed to DEPLOY_DIR.

The SC1091 bulk (81 findings) was resolved rather than silenced: .shellcheckrc
sets external-sources + source-path=SCRIPTDIR so sourced files are actually
followed and analysed. That is what surfaced the i3blocks and deploy.sh bugs.

17 `# shellcheck disable=` directives remain, each with a written reason, and
only where the finding is provably wrong: injection-test payloads that must not
expand, literal source text being grepped for, POSIX sh scripts on the phone
where arrays do not exist, and source paths that only exist at runtime.
Everything else was fixed in code.

Verified in a CLEAN worktree, not just my own tree. config.sh sources the
gitignored config_secrets.sh, which exists locally but not in a fresh checkout;
a working-tree-only check passed while ci_mirror.sh -- which lints HEAD in a
clean worktree, exactly as CI does -- failed on it.

The gate is verified to bite: a file whose only finding is SC2016 (info) fails
the hook, while the old --severity=warning gate exits 0 on it.

third_party/ is excluded -- vendored upstream, matching the prettier hook.

Not fixed, reported: check_polling_antipatterns.sh carries a pre-existing
SC2034 disable that hides a real bug -- its `patterns` array is never used, so
the checker detects nothing. Confirmed unchanged by this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 19:49:11 +02:00

570 lines
21 KiB
Bash
Executable File

#!/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"
readonly WATCHDOG_SERVICE="/etc/systemd/system/dnsmasq-watchdog.service"
readonly WATCHDOG_TIMER="/etc/systemd/system/dnsmasq-watchdog.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" <<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
}
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
install_dnsmasq
prepare_dirs
build_feed
write_dnsmasq_conf
install_restart_dropin
install_refresh_timer
install_watchdog
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" <<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
Usage: $0 <command>
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