mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-14 19:40:48 +01:00
feat: add Pi-hole-like LAN DNS blocking served from the PC (dnsmasq)
setup_dns_blocker.sh turns this PC into a DNS server that serves the identical /etc/hosts blocklist to the whole LAN with no per-device app, by feeding generate_hosts_file.sh output into dnsmasq via addn-hosts (same StevenBlack variant + custom entries + unblocks). Adds status/ refresh subcommands, a daily refresh timer, Restart=always, and logging to /var/log/dns-blocker/. Because the ISP router cannot advertise a custom DNS server, a dhcp/ dhcp-off mode makes the PC the LAN DHCP server (static IP via nmcli with a gateway-ping self-check and safe, always-online rollback), handing out itself as the DNS server so every device is blocked automatically. setup_wireguard_ssh.sh gains an allow-dns subcommand that opens DNS (53, LAN saddr) and DHCP (67) in the single-owner nftables ruleset. The DHCP accept rule is placed before 'ct state invalid drop' because the 0.0.0.0->255.255.255.255 DHCP broadcast is classified INVALID and was otherwise dropped. Firewall detection now reads the actual loaded ruleset (a ruleset can be loaded while nftables.service reads inactive) and avoids a grep -q/pipefail SIGPIPE false-negative. Verified end-to-end: Pixel-6a leased 192.168.1.82 with DNS 192.168.1.43; youtube.com blocked, legit sites resolve. Regression tests added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pqSnrLt8QoWup7n2Ymk77
This commit is contained in:
parent
66c4698194
commit
343d38eaf8
43
docs/superpowers/evidence/setup-dns-blocker-2026-07-10.json
Normal file
43
docs/superpowers/evidence/setup-dns-blocker-2026-07-10.json
Normal file
@ -0,0 +1,43 @@
|
||||
{
|
||||
"intent": "Give every device on the LAN the same domain blocking as the PC's /etc/hosts, with no app installed on the device, by running the PC as a Pi-hole-like DNS (and, for this router, DHCP) server.",
|
||||
"scope": [
|
||||
"linux_configuration/scripts/single_use/features/setup_dns_blocker.sh (new: dnsmasq-based LAN DNS blocker + optional DHCP-server mode)",
|
||||
"linux_configuration/scripts/single_use/features/setup_wireguard_ssh.sh (new allow-dns subcommand: opens DNS 53 + DHCP 67 for the LAN in the single-owner nftables ruleset)",
|
||||
"linux_configuration/tests/test_setup_dns_blocker.sh (new regression tests)",
|
||||
"Non-goals: no Pi-hole/web dashboard, no per-query analytics, no DoH enforcement on clients (a voluntary-DNS design cannot force off-path traffic)."
|
||||
],
|
||||
"changes": [
|
||||
"dnsmasq serves the identical blocklist as /etc/hosts via addn-hosts pointed at generate_hosts_file.sh output (same StevenBlack variant + custom entries + unblocks); no-hosts + no-resolv keep it deterministic; bound to the LAN interface only so the systemd-resolved stub stays free.",
|
||||
"Daily systemd timer rebuilds the feed and SIGHUPs dnsmasq; Restart=always drop-in; logs to /var/log/dns-blocker/.",
|
||||
"dhcp/dhcp-off subcommands: because the ISP router cannot advertise a custom DNS, the PC becomes the LAN DHCP server (static IP via nmcli with a gateway-ping self-check + auto-revert; hands out itself as DNS). Rollback keeps the PC online on its static IP.",
|
||||
"Firewall: allow-dns opens udp/tcp 53 (LAN saddr) and udp 67 for DHCP. The DHCP accept rule is placed BEFORE 'ct state invalid drop' because the 0.0.0.0->255.255.255.255 broadcast is classified INVALID and was being dropped.",
|
||||
"Fixed firewall detection to read the actual loaded nftables ruleset (a ruleset can be loaded while nftables.service reads inactive), avoiding a grep -q/pipefail SIGPIPE false-negative."
|
||||
],
|
||||
"verification": [
|
||||
{
|
||||
"command": "sudo setup_dns_blocker.sh setup && sudo setup_dns_blocker.sh dhcp && setup_dns_blocker.sh status",
|
||||
"result": "pass",
|
||||
"evidence": "status all green: dnsmasq active/enabled, listening 192.168.1.43:53, firewall DNS/DHCP open for LAN, DHCP mode ON. Pixel-6a leased 192.168.1.82 with option6 dns-server=192.168.1.43 (DHCPDISCOVER->OFFER->REQUEST->ACK). dig @192.168.1.43 youtube.com -> 0.0.0.0 (blocked); example.com/wikipedia.org -> real IP."
|
||||
},
|
||||
{
|
||||
"command": "on-device manual test (Pixel 6a)",
|
||||
"result": "pass",
|
||||
"evidence": "User confirmed: legit websites load, youtube.com is blocked."
|
||||
},
|
||||
{
|
||||
"command": "bash linux_configuration/tests/test_setup_dns_blocker.sh",
|
||||
"result": "pass",
|
||||
"evidence": "PASS: dispatch (help lists all subcommands, unknown cmd exits non-zero) + firewall_is_loaded regression (drop->LOADED, accept->NOT_LOADED under set -o pipefail with large nft output)."
|
||||
}
|
||||
],
|
||||
"risks": [
|
||||
"Voluntary DNS: a device using DoH / Android Private DNS / a VPN / a manual DNS bypasses blocking; the PC is not the gateway so it cannot force traffic. Phone Private DNS must be Off.",
|
||||
"PC is now the LAN DHCP server: if it is down, new devices get no lease (existing leases persist ~12h). Fail-closed by design.",
|
||||
"Static IP pinned on enp6s0; a wrong gateway would break connectivity (mitigated by an immediate ping self-check that auto-reverts)."
|
||||
],
|
||||
"rollback": [
|
||||
"DHCP: sudo setup_dns_blocker.sh dhcp-off (stops serving DHCP, KEEPS the PC on its static IP so it stays online), then re-tick the router's DHCP.",
|
||||
"DNS blocker: systemctl disable --now dnsmasq dns-blocklist-refresh.timer; rm /etc/dnsmasq.d/lan-blocker.conf /etc/dnsmasq.d/lan-dhcp.conf.",
|
||||
"Validate after rollback: devices get leases from the router again and normal DNS resolves."
|
||||
]
|
||||
}
|
||||
517
linux_configuration/scripts/single_use/features/setup_dns_blocker.sh
Executable file
517
linux_configuration/scripts/single_use/features/setup_dns_blocker.sh
Executable file
@ -0,0 +1,517 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# setup_dns_blocker.sh -- Pi-hole-like LAN DNS blocking, hosted on this PC.
|
||||
#
|
||||
# Turns this machine into a DNS server (dnsmasq) that serves the EXACT same
|
||||
# blocklist as the local /etc/hosts to every device on the LAN, with no app
|
||||
# installed on those devices. The blocklist feed is produced verbatim by the
|
||||
# repo's generate_hosts_file.sh (same StevenBlack variant + custom entries +
|
||||
# unblocks that /etc/hosts uses), so blocking is identical by construction.
|
||||
#
|
||||
# The PC is NOT the gateway, so it can only be a resolver that clients are
|
||||
# POINTED at (via the router's DHCP-advertised DNS). See the manual steps
|
||||
# printed by `setup`. Known bypasses (DoH / Private DNS / manual DNS / VPN)
|
||||
# are documented there too -- a voluntary-DNS design cannot force traffic.
|
||||
#
|
||||
# Usage:
|
||||
# sudo ./setup_dns_blocker.sh setup # first-time install + enable
|
||||
# ./setup_dns_blocker.sh status # is it set up & serving? (no root)
|
||||
# sudo ./setup_dns_blocker.sh refresh # rebuild feed + reload (timer runs this)
|
||||
# ./setup_dns_blocker.sh help
|
||||
#
|
||||
# Idempotent and safe to re-run.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
# shellcheck source=../../lib/common.sh
|
||||
source "$SCRIPT_DIR/../../lib/common.sh"
|
||||
|
||||
# ---- Configuration ---------------------------------------------------------
|
||||
readonly FEED_DIR="/var/lib/dns-blocker"
|
||||
readonly FEED="${FEED_DIR}/blocklist.hosts"
|
||||
readonly DNSMASQ_MAIN_CONF="/etc/dnsmasq.conf"
|
||||
readonly DNSMASQ_CONF="/etc/dnsmasq.d/lan-blocker.conf"
|
||||
readonly CONF_DIR_LINE="conf-dir=/etc/dnsmasq.d/,*.conf"
|
||||
readonly LOG_DIR="/var/log/dns-blocker"
|
||||
readonly LOG_FILE="${LOG_DIR}/dnsmasq.log"
|
||||
readonly DNSMASQ_DROPIN_DIR="/etc/systemd/system/dnsmasq.service.d"
|
||||
readonly DNSMASQ_DROPIN="${DNSMASQ_DROPIN_DIR}/blocker.conf"
|
||||
readonly REFRESH_SERVICE="/etc/systemd/system/dns-blocklist-refresh.service"
|
||||
readonly REFRESH_TIMER="/etc/systemd/system/dns-blocklist-refresh.timer"
|
||||
# Optional DHCP-server mode (for routers that cannot advertise a custom DNS).
|
||||
readonly DHCP_CONF="/etc/dnsmasq.d/lan-dhcp.conf"
|
||||
readonly DHCP_LEASE="12h"
|
||||
readonly DHCP_START_HOST="10" # .10 .. .150 mirrors a common router default range
|
||||
readonly DHCP_END_HOST="150"
|
||||
# Feed generator (chattr +i but still executable) and firewall owner script.
|
||||
readonly GEN="${SCRIPT_DIR}/../../periodic_background/hosts/generate_hosts_file.sh"
|
||||
readonly WG_SCRIPT="${SCRIPT_DIR}/setup_wireguard_ssh.sh"
|
||||
readonly SELF="${SCRIPT_DIR}/setup_dns_blocker.sh"
|
||||
|
||||
die() {
|
||||
log_error "$1"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ---- LAN autodetection -----------------------------------------------------
|
||||
# Derive the LAN interface / IP / gateway from the default route so the config
|
||||
# is not hardcoded to one NIC name.
|
||||
detect_lan() {
|
||||
local route
|
||||
route="$(ip -4 route get 1.1.1.1 2>/dev/null || true)"
|
||||
LAN_IFACE="$(awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}' <<<"$route")"
|
||||
LAN_IP="$(awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}' <<<"$route")"
|
||||
GATEWAY="$(ip -4 route show default 2>/dev/null | awk '{print $3; exit}')"
|
||||
[[ -n ${LAN_IFACE:-} && -n ${LAN_IP:-} ]] ||
|
||||
die "Could not detect the LAN interface/IP from the default route."
|
||||
# Upstream for non-blocked names: the gateway if known, else a public resolver.
|
||||
UPSTREAM="${GATEWAY:-1.1.1.1}"
|
||||
}
|
||||
|
||||
# ---- Setup steps -----------------------------------------------------------
|
||||
install_dnsmasq() {
|
||||
# dnsmasq is the resolver; bind provides 'dig' for the status live test.
|
||||
local pkgs=()
|
||||
has_cmd dnsmasq || pkgs+=(dnsmasq)
|
||||
has_cmd dig || pkgs+=(bind)
|
||||
if ((${#pkgs[@]})); then
|
||||
log_info "Installing: ${pkgs[*]}"
|
||||
install_missing_pacman_packages "${pkgs[@]}"
|
||||
else
|
||||
log_ok "dnsmasq and dig already installed."
|
||||
fi
|
||||
# dnsmasq only reads /etc/dnsmasq.d/* if the main conf enables conf-dir.
|
||||
if grep -qE '^\s*conf-dir=' "$DNSMASQ_MAIN_CONF" 2>/dev/null; then
|
||||
log_ok "conf-dir already enabled in ${DNSMASQ_MAIN_CONF}."
|
||||
else
|
||||
log_info "Enabling conf-dir in ${DNSMASQ_MAIN_CONF}."
|
||||
printf '\n# Added by setup_dns_blocker.sh -- load /etc/dnsmasq.d/*.conf\n%s\n' \
|
||||
"$CONF_DIR_LINE" >>"$DNSMASQ_MAIN_CONF"
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_dirs() {
|
||||
ensure_dir "$FEED_DIR"
|
||||
ensure_dir "$LOG_DIR"
|
||||
# dnsmasq drops to the 'dnsmasq' user; it must be able to write its log.
|
||||
touch "$LOG_FILE"
|
||||
if id dnsmasq &>/dev/null; then
|
||||
chown dnsmasq:dnsmasq "$LOG_DIR" "$LOG_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
build_feed() {
|
||||
[[ -x $GEN ]] || die "Feed generator not found or not executable: ${GEN}"
|
||||
log_info "Building blocklist feed from generate_hosts_file.sh (this is the same list as /etc/hosts)..."
|
||||
"$GEN" "$FEED" || die "Feed generation failed."
|
||||
local count
|
||||
count="$(wc -l <"$FEED")"
|
||||
((count > 1000)) || die "Feed looks too small (${count} lines) -- refusing to serve a broken blocklist."
|
||||
log_ok "Feed built: ${count} lines at ${FEED}."
|
||||
}
|
||||
|
||||
write_dnsmasq_conf() {
|
||||
ensure_dir "$(dirname "$DNSMASQ_CONF")" # /etc/dnsmasq.d may not exist yet
|
||||
log_info "Writing ${DNSMASQ_CONF} (listen on ${LAN_IFACE}/${LAN_IP}, upstream ${UPSTREAM})."
|
||||
cat >"$DNSMASQ_CONF" <<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
|
||||
}
|
||||
|
||||
validate_and_enable() {
|
||||
log_info "Validating dnsmasq configuration..."
|
||||
dnsmasq --test 2>&1 | tail -3
|
||||
dnsmasq --test >/dev/null 2>&1 || die "dnsmasq config failed validation -- not enabling."
|
||||
log_ok "dnsmasq config valid."
|
||||
systemctl daemon-reload
|
||||
enable_service dnsmasq
|
||||
# enable --now does not restart an already-running daemon, so restart to
|
||||
# apply config changes on idempotent re-runs.
|
||||
systemctl restart dnsmasq
|
||||
systemctl enable --now dns-blocklist-refresh.timer
|
||||
log_ok "dnsmasq and the daily refresh timer are enabled."
|
||||
}
|
||||
|
||||
# Open port 53 for LAN clients -- but only touch the firewall if it is already
|
||||
# the active (wireguard-managed) default-drop ruleset. If nftables is inactive,
|
||||
# port 53 is already reachable and force-loading the firewall here could
|
||||
# disrupt other services, so we only persist the intent and instruct the user.
|
||||
# A default-drop nftables ruleset can be loaded in the kernel even while
|
||||
# nftables.service reads "inactive", so detect the actual ruleset, not the unit.
|
||||
firewall_is_loaded() {
|
||||
# Capture then match (no 'grep -q' pipe: it closes early -> SIGPIPE on nft ->
|
||||
# pipefail makes the whole pipeline fail, a false negative under set -o pipefail).
|
||||
local rules
|
||||
rules="$(nft list chain inet filter input 2>/dev/null || true)"
|
||||
[[ $rules == *"policy drop"* ]]
|
||||
}
|
||||
|
||||
configure_firewall() {
|
||||
if firewall_is_loaded; then
|
||||
log_info "Default-drop firewall detected; opening DNS/DHCP for the LAN via the firewall owner."
|
||||
bash "$WG_SCRIPT" allow-dns
|
||||
else
|
||||
log_warn "No default-drop firewall loaded -- DNS/DHCP already reachable on the LAN."
|
||||
persist_allow_dns_flag
|
||||
log_warn "If you later enable the default-drop firewall, run: sudo ${WG_SCRIPT} allow-dns"
|
||||
fi
|
||||
}
|
||||
|
||||
# Record ALLOW_DNS=true in the wireguard config so a future firewall apply keeps
|
||||
# :53 open for the LAN, without activating the firewall now.
|
||||
persist_allow_dns_flag() {
|
||||
local cfg="${SCRIPT_DIR}/.wireguard_ssh.conf"
|
||||
[[ -f $cfg ]] || return 0
|
||||
if grep -qE '^ALLOW_DNS=' "$cfg"; then
|
||||
sed -i 's/^ALLOW_DNS=.*/ALLOW_DNS="true"/' "$cfg"
|
||||
else
|
||||
printf 'ALLOW_DNS="true"\n' >>"$cfg"
|
||||
fi
|
||||
log_ok "Persisted ALLOW_DNS=true in ${cfg} for the next firewall apply."
|
||||
}
|
||||
|
||||
print_manual_steps() {
|
||||
cat <<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
|
||||
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 ==="
|
||||
|
||||
is_service_active dnsmasq && status_line 0 "dnsmasq: active" || status_line 1 "dnsmasq: NOT active"
|
||||
is_service_enabled dnsmasq && status_line 0 "dnsmasq: enabled at boot" || status_line 1 "dnsmasq: NOT enabled"
|
||||
[[ -f $DNSMASQ_DROPIN ]] && status_line 0 "Restart=always drop-in present" || status_line 1 "Restart drop-in missing"
|
||||
|
||||
if [[ -f $FEED ]]; then
|
||||
status_line 0 "blocklist feed: $(wc -l <"$FEED") lines, updated $(date -r "$FEED" '+%Y-%m-%d %H:%M')"
|
||||
else
|
||||
status_line 1 "blocklist feed missing: ${FEED}"
|
||||
fi
|
||||
|
||||
if ss -tulnp 2>/dev/null | grep -qE "${LAN_IP}:53|:53 "; then
|
||||
status_line 0 "listening on ${LAN_IP}:53"
|
||||
else
|
||||
status_line 1 "not listening on ${LAN_IP}:53"
|
||||
fi
|
||||
|
||||
local fw_input
|
||||
fw_input="$(nft list chain inet filter input 2>/dev/null || true)"
|
||||
if [[ $fw_input == *"policy drop"* ]]; then
|
||||
if [[ $fw_input == *"dport 53"* ]]; then
|
||||
status_line 0 "firewall: DNS/DHCP open for LAN"
|
||||
else
|
||||
status_line 1 "firewall loaded but DNS/DHCP NOT open -- run: sudo ${WG_SCRIPT} allow-dns"
|
||||
fi
|
||||
elif [[ $EUID -ne 0 ]]; then
|
||||
log_info "firewall: run 'sudo $0 status' to inspect nftables (needs root)"
|
||||
else
|
||||
status_line 0 "firewall: no default-drop ruleset loaded (DNS/DHCP not filtered)"
|
||||
fi
|
||||
|
||||
systemctl is-active dns-blocklist-refresh.timer &>/dev/null &&
|
||||
status_line 0 "refresh timer: active (next $(systemctl show -p NextElapseUSecRealtime --value dns-blocklist-refresh.timer 2>/dev/null))" ||
|
||||
status_line 1 "refresh timer: NOT active"
|
||||
|
||||
if [[ -f $DHCP_CONF ]]; then
|
||||
if ss -ulnp 2>/dev/null | grep -q ':67 '; then
|
||||
status_line 0 "DHCP mode: ON (serving leases; PC is the LAN DHCP server)"
|
||||
else
|
||||
status_line 1 "DHCP mode: config present but not serving -- restart dnsmasq"
|
||||
fi
|
||||
else
|
||||
status_line 0 "DHCP mode: off (using router DHCP / manual per-device DNS)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== Live resolution test (via ${LAN_IP}) ==="
|
||||
if has_cmd dig; then
|
||||
local blocked passthru
|
||||
blocked="$(dig +short +time=2 +tries=1 @"${LAN_IP}" youtube.com 2>/dev/null | head -1)"
|
||||
passthru="$(dig +short +time=2 +tries=1 @"${LAN_IP}" example.com 2>/dev/null | head -1)"
|
||||
[[ $blocked == "0.0.0.0" ]] &&
|
||||
status_line 0 "youtube.com -> ${blocked} (BLOCKED, correct)" ||
|
||||
status_line 1 "youtube.com -> ${blocked:-<no answer>} (expected 0.0.0.0)"
|
||||
[[ -n $passthru && $passthru != "0.0.0.0" ]] &&
|
||||
status_line 0 "example.com -> ${passthru} (passthrough, correct)" ||
|
||||
status_line 1 "example.com -> ${passthru:-<no answer>} (expected a real IP)"
|
||||
else
|
||||
log_warn "install 'dig' (bind) to run the live resolution test."
|
||||
fi
|
||||
|
||||
if [[ -s $LOG_FILE ]]; then
|
||||
echo
|
||||
echo "=== Recent dnsmasq log (${LOG_FILE}) ==="
|
||||
tail -5 "$LOG_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<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
|
||||
@ -38,6 +38,7 @@ DUCKDNS_DOMAIN="${DUCKDNS_DOMAIN:-}"
|
||||
DUCKDNS_TOKEN="${DUCKDNS_TOKEN:-}"
|
||||
LAN_SUBNET="${LAN_SUBNET:-}"
|
||||
ALLOW_WEB="${ALLOW_WEB:-false}"
|
||||
ALLOW_DNS="${ALLOW_DNS:-false}"
|
||||
|
||||
die() {
|
||||
log_error "$1"
|
||||
@ -50,6 +51,7 @@ DUCKDNS_DOMAIN="${DUCKDNS_DOMAIN}"
|
||||
DUCKDNS_TOKEN="${DUCKDNS_TOKEN}"
|
||||
LAN_SUBNET="${LAN_SUBNET}"
|
||||
ALLOW_WEB="${ALLOW_WEB}"
|
||||
ALLOW_DNS="${ALLOW_DNS}"
|
||||
EOF
|
||||
chmod 600 "$CONFIG_FILE"
|
||||
}
|
||||
@ -119,6 +121,19 @@ write_nftables_ruleset() {
|
||||
if [[ $ALLOW_WEB == "true" ]]; then
|
||||
web_rule=$'\n\t\ttcp dport { 80, 443 } accept'
|
||||
fi
|
||||
# DNS blocker (setup_dns_blocker.sh): serve DNS (53) -- and DHCP (67) when
|
||||
# this PC is the LAN DHCP server -- to LAN clients only. Restricted to the
|
||||
# LAN subnet so nothing is ever exposed to the internet.
|
||||
local dns_rule="" dhcp_rule=""
|
||||
if [[ $ALLOW_DNS == "true" ]]; then
|
||||
dns_rule=$'\n\t\tip saddr '"${LAN_SUBNET}"$' udp dport 53 accept'
|
||||
dns_rule+=$'\n\t\tip saddr '"${LAN_SUBNET}"$' tcp dport 53 accept'
|
||||
# DHCP clients have no IP yet (saddr 0.0.0.0 -> 255.255.255.255) and the
|
||||
# broadcast is classified INVALID by conntrack -- so this rule MUST sit
|
||||
# BEFORE 'ct state invalid drop' and match the client source port, not a
|
||||
# saddr. Link-local only: :67 broadcasts never route in from the internet.
|
||||
dhcp_rule=$'\n\t\tudp sport 68 udp dport 67 accept'
|
||||
fi
|
||||
cat >"${NFT_CONF}.new" <<EOF
|
||||
#!/usr/sbin/nft -f
|
||||
flush ruleset
|
||||
@ -128,7 +143,7 @@ table inet filter {
|
||||
type filter hook input priority 0; policy drop;
|
||||
|
||||
iif "lo" accept
|
||||
ct state established,related accept
|
||||
ct state established,related accept${dhcp_rule}
|
||||
ct state invalid drop
|
||||
|
||||
icmp type { destination-unreachable, time-exceeded, parameter-problem, echo-request } accept
|
||||
@ -137,7 +152,7 @@ table inet filter {
|
||||
udp dport ${WG_PORT} accept
|
||||
|
||||
iifname "${WG_IFACE}" tcp dport 22 accept
|
||||
ip saddr ${LAN_SUBNET} tcp dport 22 accept${web_rule}
|
||||
ip saddr ${LAN_SUBNET} tcp dport 22 accept${web_rule}${dns_rule}
|
||||
}
|
||||
chain forward {
|
||||
type filter hook forward priority 0; policy drop;
|
||||
@ -355,6 +370,14 @@ allow_web() {
|
||||
log_ok "Opened tcp/80 and tcp/443 in the input chain (persisted -- future 'setup' re-runs keep this rule)."
|
||||
}
|
||||
|
||||
allow_dns() {
|
||||
ALLOW_DNS=true
|
||||
save_config
|
||||
write_nftables_ruleset
|
||||
verify_nftables_then_apply
|
||||
log_ok "Opened udp/tcp 53 from ${LAN_SUBNET:-the LAN} in the input chain (persisted -- future 'setup' re-runs keep this rule)."
|
||||
}
|
||||
|
||||
status_cmd() {
|
||||
echo "=== WireGuard ==="
|
||||
wg show 2>/dev/null || echo "(interface not up)"
|
||||
@ -377,6 +400,7 @@ Commands:
|
||||
setup Full first-time setup (WireGuard, firewall, sshd, DuckDNS).
|
||||
add-peer <name> Provision a new phone/laptop and print its QR code.
|
||||
allow-web Open tcp/80 and tcp/443 in the firewall (for a web server on this host).
|
||||
allow-dns Open udp/tcp 53 from the LAN in the firewall (for the DNS blocker on this host).
|
||||
status Show WireGuard/firewall/sshd status.
|
||||
revoke <name> Remove a peer.
|
||||
help Show this message.
|
||||
@ -390,7 +414,7 @@ main() {
|
||||
# off -- exec sudo "$0" "$@" inside require_root must re-launch with the
|
||||
# subcommand still present, or sudo would silently run with no args.
|
||||
case "$cmd" in
|
||||
setup | add-peer | revoke | allow-web)
|
||||
setup | add-peer | revoke | allow-web | allow-dns)
|
||||
require_root "$@"
|
||||
;;
|
||||
esac
|
||||
@ -417,6 +441,9 @@ main() {
|
||||
allow-web)
|
||||
allow_web
|
||||
;;
|
||||
allow-dns)
|
||||
allow_dns
|
||||
;;
|
||||
status)
|
||||
status_cmd
|
||||
;;
|
||||
|
||||
73
linux_configuration/tests/test_setup_dns_blocker.sh
Executable file
73
linux_configuration/tests/test_setup_dns_blocker.sh
Executable file
@ -0,0 +1,73 @@
|
||||
#!/bin/bash
|
||||
# Regression tests for setup_dns_blocker.sh (LAN DNS blocker).
|
||||
#
|
||||
# Covers the pure, root-free logic:
|
||||
# - command dispatch (help lists all subcommands, unknown -> exit 1)
|
||||
# - firewall_is_loaded: the pipefail/SIGPIPE regression -- 'nft | grep -q'
|
||||
# used to false-negative under 'set -o pipefail' because grep closed the
|
||||
# pipe early and nft died on SIGPIPE. Detection must survive large output.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
REPO_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd)
|
||||
TARGET_SCRIPT="$REPO_DIR/scripts/single_use/features/setup_dns_blocker.sh"
|
||||
|
||||
fail() {
|
||||
printf 'FAIL: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
TMP_DIR=$(mktemp -d)
|
||||
BIN_DIR="$TMP_DIR/bin"
|
||||
mkdir -p "$BIN_DIR"
|
||||
cleanup() { rm -rf "$TMP_DIR"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# --- Dispatch tests (real script, no root needed) --------------------------
|
||||
help_out=$("$TARGET_SCRIPT" help)
|
||||
for cmd in setup status refresh dhcp dhcp-off; do
|
||||
[[ "$help_out" == *"$cmd"* ]] || fail "help output missing '$cmd' subcommand"
|
||||
done
|
||||
|
||||
# Unknown command must exit non-zero.
|
||||
if "$TARGET_SCRIPT" bogus-cmd >/dev/null 2>&1; then
|
||||
fail "unknown command should exit non-zero"
|
||||
fi
|
||||
|
||||
# --- firewall_is_loaded regression (pipefail/SIGPIPE) ----------------------
|
||||
# Mock 'nft' so the function runs without root. Emit a LARGE ruleset so that a
|
||||
# reintroduced 'grep -q' pipe would SIGPIPE nft and false-negative under pipefail.
|
||||
make_nft() {
|
||||
local mode="$1" # drop | accept
|
||||
cat >"$BIN_DIR/nft" <<EOF
|
||||
#!/bin/bash
|
||||
# Large output to stress the early-close case.
|
||||
echo "table inet filter {"
|
||||
echo " chain input {"
|
||||
echo " type filter hook input priority filter; policy ${mode};"
|
||||
for i in \$(seq 1 500); do echo " ip saddr 10.0.0.\$i tcp dport 22 accept"; done
|
||||
echo " }"
|
||||
echo "}"
|
||||
EOF
|
||||
chmod +x "$BIN_DIR/nft"
|
||||
}
|
||||
|
||||
# Source the target with main() suppressed so we can call its functions.
|
||||
run_fw_check() {
|
||||
local mode="$1"
|
||||
make_nft "$mode"
|
||||
PATH="$BIN_DIR:$PATH" SETUP_DNS_BLOCKER_SKIP_MAIN=1 bash -c '
|
||||
set -euo pipefail
|
||||
source "$1"
|
||||
if firewall_is_loaded; then echo LOADED; else echo NOT_LOADED; fi
|
||||
' _ "$TARGET_SCRIPT"
|
||||
}
|
||||
|
||||
got=$(run_fw_check drop)
|
||||
[[ "$got" == "LOADED" ]] || fail "firewall_is_loaded should be true for a policy-drop chain (got: $got)"
|
||||
|
||||
got=$(run_fw_check accept)
|
||||
[[ "$got" == "NOT_LOADED" ]] || fail "firewall_is_loaded should be false for a policy-accept chain (got: $got)"
|
||||
|
||||
printf 'PASS: %s\n' "$(basename "$0")"
|
||||
Loading…
Reference in New Issue
Block a user