lan blocker: gateway mode, WARP drop with a Mon/Fri 09:00-15:30 window

Gateway mode routes LAN devices through the PC: forced DNS, DoH/DoT
dropped. The work laptop still bypassed it through Cloudflare WARP, so
the WARP ingress is dropped too. warp_window.sh opens it for the LAN
only Mon/Fri 09:00-15:30 (nft set with a timeout to 15:30), asked for
over a forced-command key by warp_home.sh, which keeps WARP off at home
otherwise because WARP fails closed. Also: dnsmasq upstreams, AAAA
filtering, DoH canary, and extracted_repo_dir resolving ~/src first
(the nightly blocklist refresh had failed since 2026-09-11).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019C8pdLR4MePsLocE1pWz7F
This commit is contained in:
Krzysztof Rudnicki 2026-10-25 09:52:40 +01:00
parent ef037ed098
commit 1ea94e142c
23 changed files with 1565 additions and 18 deletions

View File

@ -0,0 +1,142 @@
# LAN blocker: what to do when the internet breaks
This PC (`192.168.1.43`, wired `enp6s0`) does three jobs for the home network
(Orange Światłowód Funbox at `192.168.1.1`, both Ethernet and WiFi):
| Job | What it means | Script |
| --- | --- | --- |
| DHCP server | hands every device its IP address | `setup_dns_blocker.sh dhcp` |
| DNS blocker | answers DNS, blocked names get `0.0.0.0` | `setup_dns_blocker.sh setup` |
| Gateway | other devices' internet traffic goes through this PC, DNS is forced to the blocker, DoH/DoT are dropped | `setup_dns_blocker.sh gateway` |
**The PC itself never depends on any of this.** Its own address is static
(`192.168.1.43/24`, gateway `192.168.1.1`, set in NetworkManager), its own
traffic goes straight to the Funbox, and the firewall `output` chain accepts
everything. Only the OTHER devices go through the PC.
All commands below are run on the PC and use full paths, so they can be copied as-is.
---
## 1. Other devices have no internet, the PC is fine
Most likely the gateway layer. Turn it off (one command, safe, keeps DHCP and
DNS blocking):
```bash
sudo ~/src/testsAndMisc/linux_configuration/features/setup_dns_blocker.sh gateway-off
```
Then on each device: WiFi off and on again (it picks up the new lease with the
Funbox as gateway). Phone: pull down the quick settings, WiFi off, WiFi on.
Still broken? Turn off DHCP serving too, and give DHCP back to the Funbox
(section 3, steps 2 to 4):
```bash
sudo ~/src/testsAndMisc/linux_configuration/features/setup_dns_blocker.sh dhcp-off
```
## 2. The PC itself has no internet
Check in this order. Stop at the first one that fixes it.
1. Cable / Funbox: is the Funbox on, is the cable in `enp6s0`? `ping -c3 192.168.1.1`
2. Route: `ip route get 1.1.1.1` must say `via 192.168.1.1 dev enp6s0`.
If not: `sudo nmcli con up 'Wired connection 1'`
3. Raw internet: `ping -c3 1.1.1.1`. Works but names fail? It is DNS, go to 4.
4. DNS: `getent hosts example.com`. If that fails while step 3 works:
`sudo tailscale down` (the PC resolves through Tailscale's 100.100.100.100),
test again, then `sudo tailscale up` once fixed.
5. Firewall (last resort, removes ALL filtering until next apply):
`sudo nft delete table ip lan_gateway; sudo nft delete table inet filter`
Restore later with `sudo /home/kuhy/src/testsAndMisc/linux_configuration/features/setup_wireguard_ssh.sh allow-dns`.
## 3. The PC is off, dead or being reinstalled
Every device except nextcloud-pi and the P110 plug loses internet AT ONCE
(the PC is their gateway), and new devices get no address. Hand DHCP back
to the Funbox:
1. On a phone or laptop, set a **static** IP on the WiFi so you can reach the
Funbox without DHCP: IP `192.168.1.200`, mask `255.255.255.0`
(prefix 24), gateway `192.168.1.1`, DNS `1.1.1.1`.
2. Open `http://192.168.1.1`, log in (password on the sticker under the Funbox).
3. Find the LAN / DHCP settings and tick **"włącz serwer DHCP"**, then **Zapisz**.
4. Switch the phone/laptop back to DHCP (automatic). Every device now gets
the Funbox as DHCP, DNS and gateway: internet works, nothing is blocked.
5. Toggle WiFi on EVERY other device (or unplug/replug its cable). Until they
do, they keep the dead PC as gateway for up to ~2 hours.
When the PC is back:
```bash
sudo ~/src/testsAndMisc/linux_configuration/features/setup_dns_blocker.sh status
```
Then untick "włącz serwer DHCP" again and run
`sudo /home/kuhy/src/testsAndMisc/linux_configuration/features/setup_dns_blocker.sh dhcp`, then
`sudo /home/kuhy/src/testsAndMisc/linux_configuration/features/setup_dns_blocker.sh gateway`.
## 4. The Funbox itself
No Funbox setting was changed on 2026-10-25 (its LAN IPv6 router
advertisements are already off: it does not answer router solicitations).
Never factory-reset it: that wipes the WireGuard 51820 and 80/443 port
forwards and re-enables its DHCP next to the PC's.
## 5. Health check (all on the PC)
```bash
sudo ~/src/testsAndMisc/linux_configuration/features/setup_dns_blocker.sh status
sudo ~/src/testsAndMisc/linux_configuration/features/setup_wireguard_ssh.sh verify
dig +short @192.168.1.43 www.ubereats.com # must print 0.0.0.0
```
## 6. Check that a device really goes through the blocker (Linux laptop)
1. Turn WiFi off and on (or replug the cable).
2. `ip route | grep default` must say `via 192.168.1.43`.
3. `resolvectl status`: no VPN/tun link, no IPv6 DNS server listed.
4. `resolvectl flush-caches`; in Chrome open `chrome://net-internals/#dns`
and click "Clear host cache".
5. `nslookup www.ubereats.com 8.8.8.8` must print `0.0.0.0` (proves even a
hand-picked DNS server is redirected to the blocker).
6. Open ubereats.com in Chrome and Firefox: it must fail.
If a browser loads NOTHING at all, it is set to strict "secure DNS" (Chrome:
Settings, Privacy, Use secure DNS; Firefox: Settings, Privacy, DNS over
HTTPS). Turn that off; the DoH block is doing its job. If Uber Eats still
loads, see which hostname served it (DevTools, Network) and check it with
`dig @192.168.1.43 <that host>`: the blocklist matches exact names, so a
missing one goes into hosts-blocker's custom list.
### Work laptop with Cloudflare WARP
Gateway mode drops the WARP tunnel (WireGuard and MASQUE ingress, UDP to the
consumer-WARP ranges), because WARP carries all traffic past the DNS block.
WARP fails CLOSED: blocked, even stuck at "Connecting", it leaves the laptop
with no internet. So the laptop runs `warp-home` (user timer, every 30 s): at
home with no window open it turns WARP off, and plain internet with the
blocklist comes back. On any other network (office) it never touches WARP.
- Mon/Fri 09:00-15:30 at home: `warp-home on` asks the PC, then connects.
The PC opens WARP for the LAN until 15:30 sharp, then closes by itself.
Outside that window the PC refuses. `warp-home off` / `warp-home status`.
- Window closed early (the PC firewall was re-applied): `warp-home on` again.
- PC side by hand: `sudo warp-window open|close|status`.
- Emergency, WARP needed outside the window:
`sudo ~/src/testsAndMisc/linux_configuration/features/setup_dns_blocker.sh gateway-off`
lifts the whole gateway (all gateway blocking goes with it); `gateway` restores it.
- The block is IPv4-only: it holds while the Funbox hands out no IPv6 route
(`ip -6 route` on the laptop shows no default).
## 7. Change log
- 2026-10-25: refresh timer fixed (repos moved to `~/src`, it had failed
nightly since 2026-09-11); dnsmasq upstream moved from the Funbox to
1.1.1.1 + 9.9.9.9; AAAA answers stripped (blocked names used to leak their
real IPv6 address); Firefox DoH canary + DoH hostnames blocked; DHCP lease
12h -> 2h; gateway mode added (nextcloud-pi and the P110 plug exempt).
- 2026-10-25: WARP tunnel dropped at the gateway (work laptop bypassed the
DNS block through it); Mon/Fri 09:00-15:30 window via `warp-home on`.

View File

@ -0,0 +1,16 @@
{
"title": "LAN gateway mode drops DoH/DoT and Cloudflare WARP, with a Mon/Fri 09:00-15:30 WARP window",
"objective": "Devices on the LAN route through the PC so the DNS blocklist cannot be bypassed by DoH, DoT or the WARP tunnel; kuhy's work laptop stays online at home with the blocklist and may use WARP at home only Mon/Fri 09:00-15:30 on request, and always on other networks.",
"acceptance_criteria": [
"With gateway mode on, forwarded LAN packets to the WARP WireGuard/MASQUE ingress and UDP to the consumer ranges are dropped, while TCP to 188.114.96.0/22 and IPsec ports elsewhere pass (e2e_warp_forward_drop.sh)",
"warp_window.sh open succeeds only Mon/Fri 09:00:00-15:29:59 and fills warp_open with a timeout ending at 15:30; the forced command accepts only open|status (test_warp_window.sh)",
"warp_home.sh tick disconnects WARP at home when no window is open or the PC reports closed, and never touches WARP off the home network (test_warp_home.sh)",
"The nightly dns-blocklist-refresh resolves hosts-blocker under ~/src (test_extracted_repos.sh)"
],
"out_of_scope": [
"A custom blocklist inside WARP / Cloudflare Zero Trust (employer org)",
"IPv6 filtering (no IPv6 route is handed out on the LAN)",
"python_pkg/app_icons changes in the working tree"
],
"verifier": "meta/scripts/shell_coverage_jail.sh --subject linux_configuration/features/lib/tests/run_all.sh $(jail_args) --min 0 --fail-on-case-error -- ''; sudo linux_configuration/features/lib/tests/e2e_warp_forward_drop.sh; sudo linux_configuration/features/setup_wireguard_ssh.sh verify; on the laptop: warp-home status"
}

View File

@ -0,0 +1,51 @@
{
"intent": "LAN devices cannot bypass the PC's DNS blocklist: gateway mode routes them through the PC (forced DNS, DoH/DoT dropped), the Cloudflare WARP tunnel is dropped, and kuhy can open WARP for the LAN only Mon/Fri 09:00-15:30 via `warp-home on` on the work laptop, which otherwise keeps WARP off at home so the laptop stays online with the blocklist.",
"scope": [
"linux_configuration/features: setup_dns_blocker.sh (gateway/gateway-off), setup_wireguard_ssh.sh, lib/{dns_gateway,dns_units,wg_gateway,wg_firewall,wg_sshd}.sh, warp_window.sh (PC), warp_home.sh (laptop, no root)",
"linux_configuration/lib/extracted_repos.sh: resolve ~/src/<repo> first (nightly dns-blocklist-refresh had failed since 2026-09-11)",
"Non-goal: a blocklist inside WARP itself (laptop is enrolled in the employer's Zero Trust org 'aiclearing'); IPv6 (no native v6 route on the LAN)"
],
"changes": [
"Gateway mode: DHCP router=PC, nft lan_gateway table DNATs LAN :53 to dnsmasq and masquerades; forward drops DoH resolvers and :853",
"WARP drop in the gateway forward chain: 162.159.192.0/24, 162.159.193.0/24 (WireGuard), 162.159.197.0/24 (MASQUE incl. TCP 443 fallback) fully; UDP only to shared consumer ranges 162.159.192.0/22, 162.159.204.0/22, 188.114.96.0/22; no LAN-wide IPsec port drops",
"nft set warp_open (interval,timeout): warp_window.sh open fills it with 0.0.0.0/0 timing out at 15:30, only Mon/Fri 09:00-15:30 by the PC clock; reachable from the laptop via a restrict,from=LAN forced-command key (open|status only; sudoers env_keep SSH_ORIGINAL_COMMAND)",
"warp_home.sh: user systemd timer every 30 s disconnects WARP at home outside a window, or when the PC reports the window closed early; leaves WARP alone on other networks; 15:25 warning",
"dnsmasq: upstreams 1.1.1.1 + 9.9.9.9, filter-AAAA, Firefox DoH canary, DoH hostnames to 0.0.0.0; DHCP lease 2h"
],
"verification": [
{
"command": "meta/scripts/shell_coverage_jail.sh --subject linux_configuration/features/lib/tests/run_all.sh <jail_args> --min 0 --fail-on-case-error",
"result": "pass",
"evidence": "rc=0; test_warp_window 33/0, test_warp_home 35/0, test_wg_firewall 37/0, test_dns_gateway 15/0, test_dns_units 12/0 (unit runs). test_extracted_repos.sh and test_setup_dns_blocker.sh PASS"
},
{
"command": "vmbox: sudo e2e_warp_forward_drop.sh (netns, real nft) red without the WARP drops, then green",
"result": "pass",
"evidence": "passed in vmbox: red 4 FAIL (WARP probes reached the far side); green 9 OK incl. tcp 188.114.96.3:443 and udp 4500 elsewhere passing, open window lets WARP through, 3 s timeout closes it"
},
{
"command": "vmbox (clock Mon 2026-10-26 10:00): warp_window.sh install + ssh forced command",
"result": "pass",
"evidence": "passed in vmbox: open -> seconds_left=19800, status open (expires 5h29m59s); close/id/'open; id' refused over ssh; Tuesday refused; Mon 15:29:58 -> 2 s then closed; 15:30:01 refused"
},
{
"command": "host: setup_wireguard_ssh.sh allow-lan-gateway with auto-revert armed, cancelled after checks; setup_wireguard_ssh.sh verify",
"result": "pass",
"evidence": "host: PC example.com 200, docker egress OK, revert timer cancelled, verify reports nftables.conf matches; warp_open set live"
},
{
"command": "laptop 192.168.1.21 over ssh: warp-home on / status / warp-cli connect then wait",
"result": "pass",
"evidence": "host+laptop: Sunday on refused by the PC; with WARP blocked the tick disconnected a reconnected WARP after 27 s; example.com 200, www.ubereats.com resolves 0.0.0.0 even via 8.8.8.8; kuhy confirmed. Real Mon/Fri open on the host not yet exercised"
}
],
"risks": [
"Any re-apply of the PC nftables ruleset empties warp_open mid-window; the laptop tick detects 'closed' and turns WARP off, kuhy reruns warp-home on",
"WARP fails closed: if the laptop agent stops running, the laptop is offline at home whenever WARP is on",
"Shared consumer-WARP ranges are UDP-only drops; QUIC to sites in 188.114.96.0/22 falls back to TCP"
],
"rollback": [
"sudo linux_configuration/features/setup_dns_blocker.sh gateway-off (removes gateway mode incl. WARP drops); git revert this commit",
"Laptop: systemctl --user disable --now warp-home.timer; PC: remove /usr/local/sbin/warp-window, /etc/sudoers.d/warp-window and the warp-home authorized_keys line; confirm LAN devices browse via the Funbox"
]
}

View File

@ -0,0 +1,150 @@
#!/bin/bash
# Gateway mode: other LAN devices route through this PC, so their DNS can be
# forced to the blocker and DoH/DoT dropped, instead of merely suggested.
#
# 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.
#
# The PC's OWN connectivity never changes: static IP, default route via the
# Funbox, firewall output policy accept. Only DHCP clients route through it.
# Known limit: one NIC on the same L2 as the Funbox, so a device set by hand
# to gateway or DNS 192.168.1.1 still bypasses this. Recovery steps live in
# docs/DOCS-lan-blocker-contingency.md.
GATEWAY_CONF="/etc/dnsmasq.d/lan-gateway.conf"
GATEWAY_SYSCTL="/etc/sysctl.d/90-lan-gateway.conf"
GATEWAY_DOCKER_UNIT="/etc/systemd/system/lan-gateway-docker-forward.service"
# Devices that keep the Funbox as gateway, so a dead PC takes down less:
# nextcloud-pi and the P110 smart plug.
readonly GATEWAY_EXEMPT_MACS=(2c:cf:67:1e:35:3a 58:04:4f:db:c9:e8)
# Tagged options beat the untagged router in lan-dhcp.conf, and every client
# matches exactly one of the two tags, so lan-dhcp.conf needs no edit.
render_gateway_dnsmasq_conf() {
local target="$1" mac
{
printf '# Managed by setup_dns_blocker.sh gateway -- remove with gateway-off.\n'
for mac in "${GATEWAY_EXEMPT_MACS[@]}"; do
printf 'dhcp-host=%s,set:direct\n' "$mac"
done
printf 'dhcp-option=tag:!direct,option:router,%s\n' "$LAN_IP"
printf 'dhcp-option=tag:direct,option:router,%s\n' "$GATEWAY"
} >"$target"
}
render_gateway_sysctl() {
cat >"$1" <<EOF
# Managed by setup_dns_blocker.sh gateway.
net.ipv4.ip_forward = 1
# One NIC on the Funbox's L2: with redirects on, the kernel tells each client
# "go straight to ${GATEWAY}" and gateway mode silently does nothing.
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.${LAN_IFACE}.send_redirects = 0
EOF
}
# Docker sets the legacy iptables FORWARD policy to DROP, and that hook vetoes
# whatever nftables accepts. DOCKER-USER is the chain Docker leaves to us, but
# it does not survive a reboot, so a unit re-inserts it whenever docker starts.
render_docker_user_unit() {
local rule="DOCKER-USER -i ${LAN_IFACE} -o ${LAN_IFACE} -j ACCEPT"
cat >"$1" <<EOF
[Unit]
Description=Let LAN gateway traffic past Docker's FORWARD DROP
After=docker.service
PartOf=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/sh -c 'iptables -C ${rule} 2>/dev/null || iptables -I ${rule}'
ExecStop=/bin/sh -c 'iptables -D ${rule} 2>/dev/null || true'
[Install]
WantedBy=docker.service
EOF
}
# Probes, separate so the self-check can be tested without a network.
pc_route() { ip -4 route get 1.1.1.1 2>/dev/null || true; }
pc_ping() { ping -c1 -W3 1.1.1.1 >/dev/null 2>&1; }
pc_resolve() { timeout 10 getent hosts example.com >/dev/null 2>&1; }
blocker_answers() {
local ans
ans="$(dig +short +time=3 +tries=2 @"${LAN_IP}" example.com 2>/dev/null | head -1)"
[[ -n $ans && $ans != "0.0.0.0" ]]
}
# The PC itself must stay online; nothing else gateway mode does matters more.
gateway_self_check() {
local route
route="$(pc_route)"
[[ $route == *"via ${GATEWAY} dev ${LAN_IFACE}"* ]] || {
log_error "self-check: PC default route is not via ${GATEWAY}: ${route}"
return 1
}
pc_ping || {
log_error "self-check: PC cannot reach 1.1.1.1"
return 1
}
pc_resolve || {
log_error "self-check: PC cannot resolve example.com"
return 1
}
blocker_answers || {
log_error "self-check: the blocker at ${LAN_IP} does not answer"
return 1
}
}
apply_or_rollback() {
if gateway_self_check; then
return 0
fi
log_error "Self-check failed -- rolling gateway mode back."
cmd_gateway_off
die "Gateway mode rolled back; the PC keeps its own connection."
}
cmd_gateway() {
detect_lan
[[ -f $DHCP_CONF ]] ||
die "Gateway mode needs DHCP mode (the PC hands out the gateway): run 'dhcp' first."
firewall_is_loaded ||
die "Gateway mode needs the default-drop firewall from setup_wireguard_ssh.sh."
gateway_self_check || die "The PC is not healthy BEFORE the change -- fix that first."
render_gateway_sysctl "$GATEWAY_SYSCTL"
sysctl -q -p "$GATEWAY_SYSCTL"
render_docker_user_unit "$GATEWAY_DOCKER_UNIT"
systemctl daemon-reload
systemctl enable lan-gateway-docker-forward.service
if is_service_active docker; then
systemctl restart lan-gateway-docker-forward.service
fi
bash "$WG_SCRIPT" allow-lan-gateway
write_dhcp_conf # re-rendered for the shorter DHCP_LEASE
render_gateway_dnsmasq_conf "$GATEWAY_CONF"
if ! dnsmasq --test >/dev/null 2>&1; then
rm -f "$GATEWAY_CONF"
die "dnsmasq rejected the gateway config -- removed it."
fi
systemctl restart dnsmasq
apply_or_rollback
log_ok "Gateway mode ON: new leases route through ${LAN_IP}; DNS forced, DoH/DoT dropped."
log_info "Devices switch at their next lease renewal; toggle WiFi to switch now."
log_info "Undo: sudo ${SELF} gateway-off (see docs/DOCS-lan-blocker-contingency.md)"
}
# Hand the Funbox back as everyone's gateway. DNS blocking and DHCP stay on.
cmd_gateway_off() {
detect_lan
rm -f "$GATEWAY_CONF"
is_service_active dnsmasq && systemctl restart dnsmasq
bash "$WG_SCRIPT" deny-lan-gateway
systemctl disable --now lan-gateway-docker-forward.service 2>/dev/null || true
rm -f "$GATEWAY_DOCKER_UNIT" "$GATEWAY_SYSCTL"
systemctl daemon-reload
log_ok "Gateway mode OFF: new leases name ${GATEWAY} as the gateway again."
log_warn "Devices still on an old lease: toggle their WiFi to get internet back."
}

View File

@ -4,9 +4,22 @@
# Sourced by setup_dns_blocker.sh; split out to keep it under the 250-line cap. # 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. # Sourced rather than run, so it inherits the caller's strict mode.
# Upstream resolvers for every name not in the blocklist.
readonly UPSTREAMS=(1.1.1.1 9.9.9.9)
# Hostnames browsers use to reach DoH providers; answered 0.0.0.0 so a browser
# set to "secure DNS" falls back to this resolver instead of bypassing it.
readonly DOH_HOSTNAMES=(
dns.google cloudflare-dns.com one.one.one.one dns.quad9.net
dns.nextdns.io doh.opendns.com dns.adguard-dns.com doh.cleanbrowsing.org
dns.mullvad.net dns.controld.com freedns.controld.com doh.libredns.gr
)
write_dnsmasq_conf() { write_dnsmasq_conf() {
ensure_dir "$(dirname "$DNSMASQ_CONF")" # /etc/dnsmasq.d may not exist yet 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})." local server_lines doh_lines
server_lines="$(printf 'server=%s\n' "${UPSTREAMS[@]}")"
doh_lines="$(printf 'address=/%s/0.0.0.0\n' "${DOH_HOSTNAMES[@]}")"
log_info "Writing ${DNSMASQ_CONF} (listen on ${LAN_IFACE}/${LAN_IP}, upstream ${UPSTREAMS[*]})."
cat >"$DNSMASQ_CONF" <<EOF cat >"$DNSMASQ_CONF" <<EOF
# Managed by setup_dns_blocker.sh -- LAN DNS blocker. # Managed by setup_dns_blocker.sh -- LAN DNS blocker.
# Serves the identical blocklist as /etc/hosts to LAN clients via addn-hosts. # Serves the identical blocklist as /etc/hosts to LAN clients via addn-hosts.
@ -26,16 +39,29 @@ bind-dynamic
no-hosts no-hosts
addn-hosts=${FEED} addn-hosts=${FEED}
# Forward everything not in the blocklist to the upstream resolver ONLY. # Forward everything not in the blocklist to the upstream resolvers ONLY.
# no-resolv: ignore /etc/resolv.conf so we never chain through the # 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. # systemd-resolved stub (127.0.0.53) that the /etc/hosts installer enables.
# Not the Funbox: it drops ~20% of queries (measured 2026-09-18), which is
# every device's problem once gateway mode forces all LAN DNS through here.
no-resolv no-resolv
server=${UPSTREAM} ${server_lines}
domain-needed domain-needed
bogus-priv bogus-priv
cache-size=10000 cache-size=10000
# The feed only carries 0.0.0.0, so every AAAA query used to be forwarded and a
# "blocked" name resolved to its real IPv6 address. Strip AAAA everywhere: the
# LAN has no IPv6 from the Funbox anyway, and IPv6 would bypass gateway mode.
filter-AAAA
# DoH bypasses this resolver. Firefox turns its default DoH off only when the
# canary domain is NXDOMAIN; the bootstrap names stop browsers resolving the
# common DoH providers (gateway mode also drops their IPs).
local=/use-application-dns.net/
${doh_lines}
# Log dnsmasq's own messages (startup, upstream failures) -- "log failures". # Log dnsmasq's own messages (startup, upstream failures) -- "log failures".
log-facility=${LOG_FILE} log-facility=${LOG_FILE}
# log-queries # opt-in: full per-query log (verbose + privacy). Off by default. # log-queries # opt-in: full per-query log (verbose + privacy). Off by default.

View File

@ -0,0 +1,70 @@
#!/usr/bin/env bash
# End-to-end check that gateway mode's DHCP config hands out the right router.
#
# sudo linux_configuration/features/lib/tests/e2e_dhcp_gateway_tags.sh
#
# Deliberately NOT named test_*.sh: it needs root and real network namespaces.
# A real dnsmasq serves leases inside one namespace, a real dhcpcd asks for one
# from another, over a veth pair -- the host LAN and its dnsmasq are never
# touched. It proves what a unit test cannot: that the TAGGED router options in
# lan-gateway.conf beat the untagged one in lan-dhcp.conf, for a normal client
# AND for an exempt MAC.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly NS_S="gwtag-srv" NS_C="gwtag-cli"
readonly PC_IP="10.99.0.43" FUNBOX="10.99.0.1"
TMP="$(mktemp -d)"
drop_namespaces() {
ip netns pids "$NS_S" 2>/dev/null | xargs -r kill 2>/dev/null || true
ip netns pids "$NS_C" 2>/dev/null | xargs -r kill 2>/dev/null || true
ip netns del "$NS_S" 2>/dev/null || true
ip netns del "$NS_C" 2>/dev/null || true
}
trap 'drop_namespaces; rm -rf "$TMP"' EXIT
# shellcheck source=../dns_gateway.sh
. "$SCRIPT_DIR/../dns_gateway.sh"
drop_namespaces
ip netns add "$NS_S"
ip netns add "$NS_C"
ip link add gwt-s netns "$NS_S" type veth peer name gwt-c netns "$NS_C"
ip -n "$NS_S" addr add "${PC_IP}/24" dev gwt-s
ip -n "$NS_S" link set gwt-s up
ip -n "$NS_C" link set gwt-c up
# The untagged router mirrors lan-dhcp.conf; the gateway conf is the real render.
cat >"$TMP/dhcp.conf" <<EOF
port=0
interface=gwt-s
dhcp-range=10.99.0.10,10.99.0.20,2h
dhcp-option=option:router,${FUNBOX}
dhcp-leasefile=${TMP}/leases
EOF
LAN_IP="$PC_IP" GATEWAY="$FUNBOX" render_gateway_dnsmasq_conf "$TMP/gateway.conf"
ip netns exec "$NS_S" dnsmasq --conf-file="$TMP/dhcp.conf" \
--conf-file="$TMP/gateway.conf" --pid-file="$TMP/dnsmasq.pid"
# Router a client with <mac> is offered, as dhcpcd's test mode reports it.
router_for() {
ip -n "$NS_C" link set gwt-c down
ip -n "$NS_C" link set gwt-c address "$1"
ip -n "$NS_C" link set gwt-c up
ip netns exec "$NS_C" timeout 20 dhcpcd -4 -T -t 15 gwt-c 2>/dev/null |
awk -F= '/^new_routers=/{gsub(/\x27/,"",$2); print $2; exit}'
}
status=0
check() {
if [[ $2 == "$3" ]]; then
printf ' OK: %s (%s)\n' "$1" "$2"
else
printf ' FAIL: %s (want %s, got %s)\n' "$1" "$3" "${2:-<none>}"
status=1
fi
}
check "a normal client is routed through the PC" "$(router_for 02:00:00:00:00:01)" "$PC_IP"
check "an exempt client keeps the Funbox" "$(router_for "${GATEWAY_EXEMPT_MACS[0]}")" "$FUNBOX"
exit "$status"

View File

@ -0,0 +1,129 @@
#!/usr/bin/env bash
# End-to-end check that gateway mode's forward rules stop the WARP tunnel and
# nothing else.
#
# sudo linux_configuration/features/lib/tests/e2e_warp_forward_drop.sh
#
# Deliberately NOT named test_*.sh: it needs root and real network namespaces.
# The real lan_gateway_set/lan_gateway_forward render is loaded into a router
# namespace that hairpins on one NIC, like the PC on the LAN. A client sends
# real packets through it, and nft counters in an "internet" namespace record
# what got past. The host's own ruleset is never touched.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly NS_B="warpe-br" NS_C="warpe-cli" NS_R="warpe-rtr" NS_I="warpe-inet"
readonly CLI_IP="10.98.0.21" RTR_IP="10.98.0.43" INET_IP="10.98.0.1"
# WireGuard and MASQUE ingress, a proxied site in consumer-WARP anycast space,
# and an ordinary host.
readonly WG="162.159.193.9" MASQUE="162.159.197.4" SHARED="188.114.96.3" PLAIN="93.184.215.14"
drop_namespaces() {
local ns
for ns in "$NS_B" "$NS_C" "$NS_R" "$NS_I"; do
ip netns del "$ns" 2>/dev/null || true
done
}
trap drop_namespaces EXIT
die() {
printf '%s\n' "$1" >&2
exit 1
}
# shellcheck source=../wg_gateway.sh
. "$SCRIPT_DIR/../wg_gateway.sh"
drop_namespaces
for ns in "$NS_B" "$NS_C" "$NS_R" "$NS_I"; do ip netns add "$ns"; done
ip -n "$NS_B" link add br0 type bridge
ip -n "$NS_B" link set br0 up
# One veth per host, all on the bridge: a single LAN segment. <port> names the
# bridge end; every host end is lan0.
attach() {
ip link add lan0 netns "$1" type veth peer name "$2" netns "$NS_B"
ip -n "$NS_B" link set "$2" master br0 up
ip -n "$1" addr add "$3/24" dev lan0
ip -n "$1" link set lan0 up
}
attach "$NS_C" p-cli "$CLI_IP"
attach "$NS_R" p-rtr "$RTR_IP"
attach "$NS_I" p-inet "$INET_IP"
ip -n "$NS_C" route add default via "$RTR_IP"
ip -n "$NS_R" route add default via "$INET_IP"
ip netns exec "$NS_R" sysctl -qw net.ipv4.ip_forward=1 net.ipv4.conf.all.send_redirects=0 \
net.ipv4.conf.lan0.send_redirects=0
for ip in "$WG" "$MASQUE" "$SHARED" "$PLAIN"; do ip -n "$NS_I" addr add "$ip/32" dev lan0; done
LAN_IFACE="lan0" LAN_IP="$RTR_IP" LAN_SUBNET="10.98.0.0/24" ALLOW_LAN_GATEWAY="true"
ip netns exec "$NS_R" nft -f - <<EOF
table inet filter {
$(lan_gateway_set)
chain forward {
type filter hook forward priority filter; policy drop;
$(lan_gateway_forward)
}
}
EOF
# One counter per probe, so each verdict is read on its own.
readonly PROBES="udp:$WG:2408 tcp:$MASQUE:443 udp:$MASQUE:443 udp:$SHARED:443 \
tcp:$SHARED:443 udp:$PLAIN:4500 tcp:$PLAIN:443"
{
printf 'table inet seen {\n\tchain input {\n\t\ttype filter hook input priority filter;\n'
for p in $PROBES; do
IFS=: read -r proto ip port <<<"$p"
printf '\t\tip daddr %s %s dport %s counter comment "%s"\n' "$ip" "$proto" "$port" "$p"
done
printf '\t}\n}\n'
} | ip netns exec "$NS_I" nft -f -
for p in $PROBES; do
IFS=: read -r proto ip port <<<"$p"
ip netns exec "$NS_C" timeout 2 bash -c "echo x >/dev/$proto/$ip/$port" 2>/dev/null || true
done
seen() {
ip netns exec "$NS_I" nft list chain inet seen input |
awk -v c="\"$1\"" '$0 ~ c {for(i=1;i<=NF;i++) if($i=="packets") n=$(i+1)} END {print n}'
}
status=0
check() {
local got
got="$(seen "$2")"
if [[ $1 == blocked && $got == 0 ]] || [[ $1 == passes && ${got:-0} -gt 0 ]]; then
printf ' OK: %s %s (%s packets)\n' "$2" "$1" "$got"
else
printf ' FAIL: %s should be %s (%s packets)\n' "$2" "$1" "${got:-<none>}"
status=1
fi
}
check blocked "udp:$WG:2408"
check blocked "tcp:$MASQUE:443"
check blocked "udp:$MASQUE:443"
check blocked "udp:$SHARED:443"
check passes "tcp:$SHARED:443"
check passes "udp:$PLAIN:4500"
check passes "tcp:$PLAIN:443"
# The window (warp_window.sh open): one catch-all element with a timeout lets
# WARP through, and the kernel closes it again when the timeout runs out.
probe() {
local before
before="$(seen "udp:$WG:2408")"
ip netns exec "$NS_C" bash -c "echo x >/dev/udp/$WG/2408" 2>/dev/null || true
printf '%s\n' "$(($(seen "udp:$WG:2408") - before))"
}
window() {
if [[ $2 == "$3" ]]; then
printf ' OK: %s\n' "$1"
else
printf ' FAIL: %s (want %s packets, got %s)\n' "$1" "$3" "$2"
status=1
fi
}
ip netns exec "$NS_R" nft add element inet filter warp_open '{ 0.0.0.0/0 timeout 3s }'
window "an open window lets WARP through" "$(probe)" 1
sleep 4
window "the window closes itself when its timeout runs out" "$(probe)" 0
exit "$status"

View File

@ -50,6 +50,19 @@ _t_file_has() {
fi fi
} }
# Pass when the command succeeds (_t_ok) or fails (_t_not).
_t_ok() {
local desc="$1"
shift
if "$@"; then _t_pass "$desc"; else _t_fail "$desc"; fi
}
_t_not() {
local desc="$1"
shift
if "$@"; then _t_fail "$desc"; else _t_pass "$desc"; fi
}
# A function that calls `exit` would kill the test script if invoked directly # A function that calls `exit` would kill the test script if invoked directly
# in an `if` condition, so failures are asserted inside a subshell. # in an `if` condition, so failures are asserted inside a subshell.
_t_exits_nonzero() { _t_exits_nonzero() {

View File

@ -0,0 +1,89 @@
#!/usr/bin/env bash
# Tests for dns_gateway.sh -- gateway mode for the LAN DNS blocker.
#
# Voluntary DNS let an Ubuntu laptop reach a blocked site on 2026-10-24: it
# took a lease naming this PC as DNS and resolved elsewhere anyway. Gateway
# mode routes other devices through the PC so DNS can be forced. The one
# thing it may never do is take the PC itself offline, so the self-check and
# its rollback are asserted on the commands actually called.
#
# Every write target is a scratch path and every command is stubbed.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./features_harness.sh
. "$SCRIPT_DIR/features_harness.sh"
_t_setup_env
trap _t_teardown EXIT
log_info() { :; }
log_ok() { log "$1"; }
log_warn() { log "$1"; }
log_error() { log "$1"; }
die() {
log "$1"
exit 1
}
# shellcheck source=../dns_gateway.sh
. "$FEATURES_LIB_DIR/dns_gateway.sh"
LAN_IFACE="enp6s0"
LAN_IP="192.168.1.43"
GATEWAY="192.168.1.1"
GATEWAY_CONF="$TEST_TMPDIR/lan-gateway.conf"
GATEWAY_SYSCTL="$TEST_TMPDIR/90-lan-gateway.conf"
GATEWAY_DOCKER_UNIT="$TEST_TMPDIR/lan-gateway-docker-forward.service"
printf '\n-- DHCP hands out the PC as gateway, exempt devices keep the Funbox --\n'
render_gateway_dnsmasq_conf "$GATEWAY_CONF"
_t_file_has "$GATEWAY_CONF" '^dhcp-option=tag:!direct,option:router,192.168.1.43$' \
"every other device gets the PC as router"
_t_file_has "$GATEWAY_CONF" '^dhcp-option=tag:direct,option:router,192.168.1.1$' \
"exempt devices keep the Funbox as router"
_t_file_has "$GATEWAY_CONF" '^dhcp-host=2c:cf:67:1e:35:3a,set:direct$' "nextcloud-pi is exempt"
_t_file_has "$GATEWAY_CONF" '^dhcp-host=58:04:4f:db:c9:e8,set:direct$' "the P110 plug is exempt"
printf '\n-- the kernel forwards and never redirects clients past the PC --\n'
render_gateway_sysctl "$GATEWAY_SYSCTL"
_t_file_has "$GATEWAY_SYSCTL" '^net.ipv4.ip_forward = 1$' "forwarding on"
_t_file_has "$GATEWAY_SYSCTL" '^net.ipv4.conf.all.send_redirects = 0$' "no redirects (all)"
_t_file_has "$GATEWAY_SYSCTL" '^net.ipv4.conf.enp6s0.send_redirects = 0$' "no redirects (LAN NIC)"
printf '\n-- Docker'"'"'s legacy FORWARD DROP lets LAN traffic through --\n'
render_docker_user_unit "$GATEWAY_DOCKER_UNIT"
_t_file_has "$GATEWAY_DOCKER_UNIT" 'iptables -I DOCKER-USER -i enp6s0 -o enp6s0 -j ACCEPT' \
"inserts the accept into DOCKER-USER"
_t_file_has "$GATEWAY_DOCKER_UNIT" '^PartOf=docker.service$' "re-applied whenever docker restarts"
_t_file_has "$GATEWAY_DOCKER_UNIT" '^WantedBy=docker.service$' "started with docker"
printf '\n-- the PC staying online is checked, and a failure rolls back --\n'
_t_stub systemctl
cmd_gateway_off() { printf 'ROLLBACK\n' >>"$TEST_TMPDIR/calls.log"; }
pc_route() { printf '1.1.1.1 via 192.168.1.1 dev enp6s0 src 192.168.1.43\n'; }
pc_ping() { return 0; }
pc_resolve() { return 0; }
blocker_answers() { return 0; }
if gateway_self_check; then
_t_pass "a healthy PC passes the self-check"
else
_t_fail "a healthy PC passes the self-check"
fi
pc_route() { printf '1.1.1.1 via 192.168.1.43 dev enp6s0\n'; }
if gateway_self_check; then
_t_fail "a default route away from the Funbox fails the self-check"
else
_t_pass "a default route away from the Funbox fails the self-check"
fi
pc_route() { printf '1.1.1.1 via 192.168.1.1 dev enp6s0 src 192.168.1.43\n'; }
pc_ping() { return 1; }
if gateway_self_check; then
_t_fail "no internet on the PC fails the self-check"
else
_t_pass "no internet on the PC fails the self-check"
fi
_t_exits_nonzero apply_or_rollback "a failed self-check aborts the apply"
_t_file_has "$TEST_TMPDIR/calls.log" '^ROLLBACK$' "a failed self-check rolls gateway mode back"
_t_report "test_dns_gateway.sh"

View File

@ -28,7 +28,6 @@ DNSMASQ_DROPIN_DIR="$TEST_TMPDIR/dnsmasq.service.d"
DNSMASQ_DROPIN="$DNSMASQ_DROPIN_DIR/blocker.conf" DNSMASQ_DROPIN="$DNSMASQ_DROPIN_DIR/blocker.conf"
LAN_IFACE="enp6s0" LAN_IFACE="enp6s0"
LAN_IP="192.168.1.43" LAN_IP="192.168.1.43"
UPSTREAM="1.1.1.1"
FEED="$TEST_TMPDIR/blocklist.hosts" FEED="$TEST_TMPDIR/blocklist.hosts"
LOG_FILE="$TEST_TMPDIR/dnsmasq.log" LOG_FILE="$TEST_TMPDIR/dnsmasq.log"
@ -52,5 +51,23 @@ else
_t_pass "no network-online ordering" _t_pass "no network-online ordering"
fi fi
printf '\n-- blocked names do not leak, DoH is switched off --\n'
# addn-hosts carries only 0.0.0.0, so dnsmasq forwarded every AAAA query:
# youtube.com resolved to a real IPv6 address while "blocked".
_t_file_has "$DNSMASQ_CONF" '^filter-AAAA$' "AAAA answers are stripped (no IPv6 bypass)"
# Firefox disables its default DoH only on NXDOMAIN, never on 0.0.0.0.
_t_file_has "$DNSMASQ_CONF" '^local=/use-application-dns.net/$' "the Firefox DoH canary gets NXDOMAIN"
_t_file_has "$DNSMASQ_CONF" '^address=/dns.google/0.0.0.0$' "DoH bootstrap names are blocked"
_t_file_has "$DNSMASQ_CONF" '^address=/cloudflare-dns.com/0.0.0.0$' "Cloudflare DoH is blocked"
printf '\n-- upstream is a reliable public resolver, not the Funbox --\n'
_t_file_has "$DNSMASQ_CONF" '^server=1.1.1.1$' "1.1.1.1 upstream"
_t_file_has "$DNSMASQ_CONF" '^server=9.9.9.9$' "9.9.9.9 upstream"
if grep -q '^server=192.168.1.1$' "$DNSMASQ_CONF"; then
_t_fail "the Funbox drops ~20% of queries; it must not be the upstream"
else
_t_pass "the Funbox is not the upstream"
fi
printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" printf '\n%d passed, %d failed\n' "$PASS" "$FAIL"
[[ $FAIL -eq 0 ]] [[ $FAIL -eq 0 ]]

View File

@ -0,0 +1,168 @@
#!/usr/bin/env bash
# Tests for warp_home.sh -- the laptop side of the work-from-home WARP window.
#
# WARP fails closed: blocked at the home gateway it leaves the laptop with no
# internet at all, and Always On keeps reconnecting it. The tick therefore
# turns WARP off whenever the laptop is home and no window is open, and must
# never touch it anywhere else (the office network always gets WARP).
# ip, warp-cli, ssh, notify-send and the clock are stubbed.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./features_harness.sh
. "$SCRIPT_DIR/features_harness.sh"
_t_setup_env
trap _t_teardown EXIT
HOME="$TEST_TMPDIR/home"
XDG_STATE_HOME="$HOME/.local/state"
mkdir -p "$HOME"
# shellcheck source=../../warp_home.sh
. "$FEATURES_LIB_DIR/../warp_home.sh"
FAKE_EPOCH=1000000
date() { printf '%s\n' "$FAKE_EPOCH"; }
ROUTE="default via 192.168.1.43 dev wlp4s0 proto dhcp src 192.168.1.21"
NEIGH="192.168.1.43 dev wlp4s0 lladdr 9c:6b:00:5c:c5:6d REACHABLE"
ip() {
case "$*" in
*route*) printf '%s\n' "$ROUTE" ;;
*neigh*) printf '%s\n' "$NEIGH" ;;
esac
}
WARP_STATE="Status update: Connected"
warp-cli() {
printf 'warp-cli %s\n' "$*" >>"$TEST_TMPDIR/calls.log"
[[ $* == *status* ]] && printf '%s\n' "$WARP_STATE"
return 0
}
SSH_OUT="seconds_left=600"
SSH_RC=0
ssh() {
printf 'ssh %s\n' "$*" >>"$TEST_TMPDIR/calls.log"
printf '%s\n' "$SSH_OUT"
return "$SSH_RC"
}
_t_stub notify-send
reset_calls() { : >"$TEST_TMPDIR/calls.log"; }
not_called() {
if grep -q "$1" "$TEST_TMPDIR/calls.log" 2>/dev/null; then _t_fail "$2"; else _t_pass "$2"; fi
}
printf '\n-- home is the PC as gateway, by IP AND MAC --\n'
_t_ok "the PC as default gateway is home" at_home
ROUTE="default via 10.20.0.1 dev wlp4s0"
_t_not "an office gateway is not home" at_home
ROUTE="default via 192.168.1.43 dev wlp4s0 proto dhcp"
NEIGH="192.168.1.43 dev wlp4s0 lladdr 02:11:22:33:44:55 REACHABLE"
_t_not "the same IP with another MAC is not home" at_home
NEIGH="192.168.1.43 dev wlp4s0 lladdr 9c:6b:00:5c:c5:6d REACHABLE"
printf '\n-- tick: away from home WARP is never touched --\n'
ROUTE="default via 10.20.0.1 dev wlp4s0"
reset_calls
cmd_tick
not_called 'warp-cli' "the tick ignores WARP on the office network"
ROUTE="default via 192.168.1.43 dev wlp4s0 proto dhcp"
printf '\n-- tick at home: WARP goes off unless a window is open --\n'
reset_calls
cmd_tick
_t_called 'warp-cli --accept-tos disconnect' "a connected WARP is disconnected at home"
_t_called 'notify-send' "the disconnect is announced"
WARP_STATE="Status update: Disconnected"
reset_calls
cmd_tick
not_called 'disconnect' "an already disconnected WARP is left alone"
WARP_STATE="Status update: Connecting"
reset_calls
cmd_tick
_t_called 'disconnect' "a WARP stuck connecting (fail-closed) is disconnected"
printf '\n-- on: the PC decides, the laptop only remembers --\n'
WARP_STATE="Status update: Disconnected"
reset_calls
cmd_on >/dev/null
_t_called "ssh .*kuhy@192.168.1.43 open" "on asks the PC to open the window"
_t_called 'warp-cli --accept-tos connect' "on connects WARP after the PC agreed"
_t_eq "1000600" "$(cat "$OPEN_UNTIL")" "the window end is stored from the PC's answer"
reset_calls
WARP_STATE="Status update: Connected"
FAKE_EPOCH=1000300
cmd_tick
not_called 'disconnect' "an open window keeps WARP on"
_t_called 'notify-send' "five minutes before the cut-off kuhy is warned"
reset_calls
cmd_tick
not_called 'notify-send' "the warning is sent once"
FAKE_EPOCH=1000600
reset_calls
cmd_tick
_t_called 'disconnect' "at the window end WARP is disconnected"
_t_not "the expired window is forgotten" test -e "$OPEN_UNTIL"
printf '\n-- tick: a window the PC already closed does not strand the laptop --\n'
FAKE_EPOCH=1000000
printf '1000600\n' >"$OPEN_UNTIL"
SSH_RC=255
SSH_OUT=""
reset_calls
cmd_tick
not_called 'disconnect' "an unreachable PC leaves WARP alone"
SSH_RC=0
SSH_OUT="closed"
reset_calls
cmd_tick
_t_called 'ssh .* status' "an open window is checked against the PC"
_t_called 'disconnect' "a window the PC closed early disconnects WARP"
_t_not "the early-closed window is forgotten" test -e "$OPEN_UNTIL"
SSH_OUT="seconds_left=600"
printf '\n-- on: a refusal connects nothing --\n'
SSH_OUT="WARP window is Mon/Fri 09:00-15:30 only"
SSH_RC=1
reset_calls
on_refused() { cmd_on; }
_t_exits_nonzero on_refused "a refused on exits non-zero"
not_called 'connect' "a refused on does not connect WARP"
SSH_RC=0
SSH_OUT="seconds_left=600"
printf '\n-- on away from home just connects --\n'
ROUTE="default via 10.20.0.1 dev wlp4s0"
reset_calls
cmd_on >/dev/null
not_called '^ssh' "on at the office asks nobody"
_t_called 'warp-cli --accept-tos connect' "on at the office connects WARP"
ROUTE="default via 192.168.1.43 dev wlp4s0 proto dhcp"
printf '\n-- off forgets the window and disconnects --\n'
printf '2000000\n' >"$OPEN_UNTIL"
reset_calls
cmd_off >/dev/null
_t_called 'disconnect' "off disconnects WARP"
_t_not "off forgets the window" test -e "$OPEN_UNTIL"
printf '\n-- install: a user timer every 30 s, a dedicated key --\n'
_t_stub systemctl
_t_stub ssh-keygen
cmd_install >/dev/null
_t_file_has "$HOME/.config/systemd/user/warp-home.timer" '^OnUnitActiveSec=30s$' "the tick runs every 30 s"
_t_file_has "$HOME/.config/systemd/user/warp-home.service" "^ExecStart=$HOME/.local/bin/warp-home tick$" \
"the service runs the installed copy"
_t_called 'systemctl --user enable --now warp-home.timer' "the timer is enabled for the user, no root"
_t_called "ssh-keygen .*$HOME/.ssh/warp_window_ed25519" "a dedicated key is generated"
_t_ok "the script is installed executable" test -x "$HOME/.local/bin/warp-home"
printf '\n-- status and usage --\n'
SSH_OUT="closed"
out="$(cmd_status)"
_t_has "$out" "network: home" "status names the home network"
_t_has "$out" "pc: closed" "status shows the PC's view"
ROUTE="default via 10.20.0.1 dev wlp4s0"
_t_has "$(cmd_status)" "not home" "status away from home says WARP is not managed"
usage() { main bogus; }
_t_exits_nonzero usage "an unknown command exits non-zero"
_t_report "test_warp_home.sh"

View File

@ -0,0 +1,118 @@
#!/usr/bin/env bash
# Tests for warp_window.sh -- the PC side of the work-from-home WARP window.
#
# Gateway mode drops the Cloudflare WARP tunnel because it carries everything
# past the DNS block. kuhy may open it only Mon/Fri 09:00-15:30; the laptop
# asks over an SSH key whose forced command can run nothing but open/status.
# The window edges and the forced-command filter are the security boundary,
# so both are asserted exactly. The clock and nft are stubbed.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./features_harness.sh
. "$SCRIPT_DIR/features_harness.sh"
_t_setup_env
trap _t_teardown EXIT
# shellcheck source=../../warp_window.sh
. "$FEATURES_LIB_DIR/../warp_window.sh"
# `date +%u %H %M %S` as warp_window.sh asks for it.
FAKE_DATE="1 10 00 00"
date() { printf '%s\n' "$FAKE_DATE"; }
_t_stub nft
left_at() {
FAKE_DATE="$1"
window_seconds_left || printf 'closed'
}
printf '\n-- the window is Mon/Fri 09:00-15:30, nothing else --\n'
_t_eq "closed" "$(left_at '1 08 59 59')" "Monday 08:59:59 is closed"
_t_eq "23400" "$(left_at '1 09 00 00')" "Monday 09:00 opens until 15:30"
_t_eq "1" "$(left_at '1 15 29 59')" "Monday 15:29:59 has one second left"
_t_eq "closed" "$(left_at '1 15 30 00')" "Monday 15:30 is closed"
_t_eq "12600" "$(left_at '5 12 00 00')" "Friday noon is open"
for day in 2 3 4 6 7; do
_t_eq "closed" "$(left_at "$day 12 00 00")" "weekday $day noon is closed"
done
_t_eq "closed" "$(left_at '5 08 09 00')" "a leading-zero hour is decimal, not octal"
printf '\n-- open fills the set with a timeout ending at 15:30 --\n'
FAKE_DATE="1 10 00 00"
out="$(cmd_open)"
_t_called 'nft add element inet filter warp_open { 0.0.0.0/0 timeout 19800s }' \
"open adds the catch-all element with the time left"
_t_has "$out" "seconds_left=19800" "open reports the seconds left for the laptop"
printf '\n-- outside the window open refuses and touches nothing --\n'
: >"$TEST_TMPDIR/calls.log"
FAKE_DATE="3 12 00 00"
open_wed() { cmd_open; }
_t_exits_nonzero open_wed "open on a Wednesday exits non-zero"
if grep -q 'add element' "$TEST_TMPDIR/calls.log"; then
_t_fail "a refused open adds no element"
else
_t_pass "a refused open adds no element"
fi
printf '\n-- the forced command allows only open and status --\n'
FAKE_DATE="5 10 00 00"
: >"$TEST_TMPDIR/calls.log"
ssh_as() { SSH_ORIGINAL_COMMAND="$1" cmd_ssh; }
ssh_close() { ssh_as close; }
ssh_inject() { ssh_as 'open; rm -rf /'; }
ssh_empty() { ssh_as ''; }
_t_exits_nonzero ssh_close "close is refused over ssh"
_t_exits_nonzero ssh_inject "a chained command is refused over ssh"
_t_exits_nonzero ssh_empty "an interactive login is refused"
ssh_as open >/dev/null
_t_called 'add element inet filter warp_open' "open is allowed over ssh"
printf '\n-- close empties the set --\n'
cmd_close >/dev/null
_t_called 'nft flush set inet filter warp_open' "close flushes the set"
printf '\n-- install: sudoers checked by visudo, one forced-command key line --\n'
_t_stub visudo
_t_stub chown
INSTALL_PATH="$TEST_TMPDIR/warp-window"
SUDOERS_FILE="$TEST_TMPDIR/sudoers-warp-window"
KEY_HOME="$TEST_TMPDIR/home"
KEY_USER="kuhy"
mkdir -p "$KEY_HOME/.ssh"
printf 'ssh-ed25519 AAAAexisting other\n' >"$KEY_HOME/.ssh/authorized_keys"
printf 'ssh-ed25519 AAAAlaptop warp-home\n' >"$TEST_TMPDIR/laptop.pub"
cmd_install "$TEST_TMPDIR/laptop.pub" >/dev/null
cmd_install "$TEST_TMPDIR/laptop.pub" >/dev/null
_t_called 'visudo -cf' "the sudoers drop-in is syntax-checked"
_t_file_has "$SUDOERS_FILE" "^kuhy ALL=(root) NOPASSWD: $INSTALL_PATH ssh$" \
"sudoers allows only the ssh entry point"
_t_file_has "$SUDOERS_FILE" "^Defaults!$INSTALL_PATH env_keep += \"SSH_ORIGINAL_COMMAND\"$" \
"sudo keeps the client's command for the filter"
_t_file_has "$KEY_HOME/.ssh/authorized_keys" \
"^restrict,from=\"192.168.1.0/24\",command=\"sudo -n $INSTALL_PATH ssh\" ssh-ed25519 AAAAlaptop warp-home\$" \
"the laptop key is restricted to the forced command from the LAN"
_t_eq "1" "$(grep -c AAAAlaptop "$KEY_HOME/.ssh/authorized_keys")" "reinstalling keeps one key line"
_t_file_has "$KEY_HOME/.ssh/authorized_keys" "AAAAexisting other" "other keys are kept"
_t_ok "the script is installed executable" test -x "$INSTALL_PATH"
printf '\n-- status, usage and install failures --\n'
NFT_LIST="elements = { 0.0.0.0/0 timeout 5h expires 4h59m58s }"
nft() { printf '%s\n' "$NFT_LIST"; }
_t_eq "open (expires 4h59m58s)" "$(cmd_status)" "status shows when an open window expires"
NFT_LIST="flags interval,timeout"
_t_eq "closed" "$(cmd_status)" "status shows an empty set as closed"
unset -f nft
usage() { main bogus; }
_t_exits_nonzero usage "an unknown command exits non-zero"
no_pub() { cmd_install "$TEST_TMPDIR/missing.pub"; }
_t_exits_nonzero no_pub "install without a key file refuses"
rm -f "$SUDOERS_FILE"
printf '#!/usr/bin/env bash\nexit 1\n' >"$TEST_TMPDIR/bin/visudo"
bad_sudoers() { cmd_install "$TEST_TMPDIR/laptop.pub"; }
_t_exits_nonzero bad_sudoers "install refuses a drop-in visudo rejects"
_t_not "a rejected drop-in is never installed" test -e "$SUDOERS_FILE"
_t_report "test_warp_window.sh"

View File

@ -37,6 +37,8 @@ die() {
} }
is_service_active() { [[ ${DOCKER_ACTIVE:-yes} == yes ]]; } is_service_active() { [[ ${DOCKER_ACTIVE:-yes} == yes ]]; }
# shellcheck source=../wg_gateway.sh
. "$FEATURES_LIB_DIR/wg_gateway.sh"
# shellcheck source=../wg_firewall.sh # shellcheck source=../wg_firewall.sh
. "$FEATURES_LIB_DIR/wg_firewall.sh" . "$FEATURES_LIB_DIR/wg_firewall.sh"
@ -138,4 +140,64 @@ _t_file_has "${NFT_CONF}.new" 'ip saddr 192.168.1.0/24 tcp dport 8774 accept' \
"the applied ruleset carries the LAN-only 8774 rule" "the applied ruleset carries the LAN-only 8774 rule"
_t_has "$out" 'not wg0' "the confirmation says the port stays off WireGuard" _t_has "$out" 'not wg0' "the confirmation says the port stays off WireGuard"
printf '\n-- LAN gateway mode --\n'
LAN_IFACE="enp6s0"
LAN_IP="192.168.1.43"
ALLOW_LAN_GATEWAY="false"
render_nftables_ruleset "$RULES"
_t_eq "table ip lan_gateway|delete table ip lan_gateway" \
"$(grep -E '^(delete )?table ip lan_gateway$' "$RULES" | paste -sd'|')" \
"gateway off still deletes a stale nat table on apply"
if grep -qE 'masquerade|dnat|oifname "enp6s0"' "$RULES"; then
_t_fail "gateway off renders no forwarding or nat"
else
_t_pass "gateway off renders no forwarding or nat"
fi
ALLOW_LAN_GATEWAY="true"
render_nftables_ruleset "$RULES"
_t_file_has "$RULES" 'iifname "enp6s0" oifname "enp6s0" ip saddr 192.168.1.0/24 accept' \
"LAN traffic is forwarded back out of the same NIC"
_t_file_has "$RULES" 'iifname "enp6s0" ip saddr 192.168.1.0/24 ip daddr @doh_v4 drop' \
"traffic to DoH resolvers is dropped"
_t_file_has "$RULES" 'iifname "enp6s0" ip saddr 192.168.1.0/24 tcp dport 853 drop' "DoT tcp/853 dropped"
_t_file_has "$RULES" 'iifname "enp6s0" ip saddr 192.168.1.0/24 udp dport 853 drop' "DoQ udp/853 dropped"
_t_file_has "$RULES" 'ip saddr != 192.168.1.43 ip daddr != 192.168.1.43 udp dport 53 dnat to 192.168.1.43' \
"plain DNS to any server is redirected to the blocker"
_t_file_has "$RULES" 'oifname "enp6s0" ip saddr 192.168.1.0/24 ip saddr != 192.168.1.43 ip daddr != 192.168.1.0/24 masquerade' \
"forwarded traffic is masqueraded, the PC's own is not"
doh_line=$(grep -n '@doh_v4 drop' "$RULES" | cut -d: -f1)
accept_line=$(grep -n 'oifname "enp6s0" ip saddr 192.168.1.0/24 accept' "$RULES" | cut -d: -f1)
if [[ -n $doh_line && -n $accept_line ]] && ((doh_line < accept_line)); then
_t_pass "the DoH drop precedes the forward accept"
else
_t_fail "the DoH drop precedes the forward accept"
fi
_t_file_has "$RULES" 'iifname "enp6s0" ip saddr 192.168.1.0/24 ip saddr != @warp_open ip daddr @warp_v4 drop' \
"traffic to the WARP tunnel ingress is dropped unless the window is open"
_t_file_has "$RULES" 'iifname "enp6s0" ip saddr 192.168.1.0/24 ip saddr != @warp_open ip daddr @warp_udp_v4 meta l4proto udp drop' \
"UDP to the shared consumer-WARP ranges is dropped unless the window is open"
_t_file_has "$RULES" 'set warp_open' "the WARP window set is declared"
_t_file_has "$RULES" 'flags interval,timeout' "the window set expires its elements by itself"
_t_file_has "$RULES" '162.159.193.0/24' "the WireGuard ingress is in the WARP set"
_t_file_has "$RULES" '162.159.197.0/24' "the MASQUE ingress (incl. its TCP 443 fallback) is in the WARP set"
_t_file_has "$RULES" '188.114.96.0/22' "consumer WARP endpoints are in the UDP-only set"
if grep -qE 'udp dport (500|1701|4500) drop' "$RULES"; then
_t_fail "IPsec ports stay open for the rest of the LAN"
else
_t_pass "IPsec ports stay open for the rest of the LAN"
fi
warp_line=$(grep -n '@warp_v4 drop' "$RULES" | cut -d: -f1)
if [[ -n $warp_line && -n $accept_line ]] && ((warp_line < accept_line)); then
_t_pass "the WARP drop precedes the forward accept"
else
_t_fail "the WARP drop precedes the forward accept"
fi
_t_file_has "$RULES" 'iifname "docker0" accept' "docker forwarding is untouched by gateway mode"
if command -v nft >/dev/null 2>&1 && nft -c -f "$RULES" >/dev/null 2>&1; then
_t_pass "nft -c accepts the gateway ruleset"
else
printf ' (skipped: nft -c not permitted here)\n'
fi
ALLOW_LAN_GATEWAY="false"
_t_report "test_wg_firewall.sh" _t_report "test_wg_firewall.sh"

View File

@ -49,6 +49,11 @@ render_nftables_ruleset() {
if [[ ${ALLOW_DOT:-false} == "true" ]]; then if [[ ${ALLOW_DOT:-false} == "true" ]]; then
dot_rule=$'\n\t\tiifname "'"${WG_IFACE}"$'" tcp dport 853 accept' dot_rule=$'\n\t\tiifname "'"${WG_IFACE}"$'" tcp dport 853 accept'
fi fi
# LAN gateway (setup_dns_blocker.sh gateway), rendered by wg_gateway.sh.
local gw_set gw_forward gw_table
gw_set="$(lan_gateway_set)"
gw_forward="$(lan_gateway_forward)"
gw_table="$(lan_gateway_table)"
cat >"$target" <<EOF cat >"$target" <<EOF
#!/usr/sbin/nft -f #!/usr/sbin/nft -f
# Replaces ONLY this table, atomically. 'flush ruleset' also deleted every # Replaces ONLY this table, atomically. 'flush ruleset' also deleted every
@ -58,7 +63,7 @@ render_nftables_ruleset() {
table inet filter table inet filter
delete table inet filter delete table inet filter
table inet filter { table inet filter {${gw_set}
chain input { chain input {
type filter hook input priority 0; policy drop; type filter hook input priority 0; policy drop;
@ -75,7 +80,7 @@ table inet filter {
ip saddr ${LAN_SUBNET} tcp dport 22 accept${web_rule}${dns_rule}${ha_rule}${poke_rule}${dot_rule} ip saddr ${LAN_SUBNET} tcp dport 22 accept${web_rule}${dns_rule}${ha_rule}${poke_rule}${dot_rule}
} }
chain forward { chain forward {
type filter hook forward priority 0; policy drop; type filter hook forward priority 0; policy drop;${gw_forward}
# Docker keeps its own FORWARD rules (DOCKER-USER, DOCKER-FORWARD, # Docker keeps its own FORWARD rules (DOCKER-USER, DOCKER-FORWARD,
# the isolation chains) in the ip/filter table. This chain runs at # the isolation chains) in the ip/filter table. This chain runs at
@ -94,6 +99,8 @@ table inet filter {
type filter hook output priority 0; policy accept; type filter hook output priority 0; policy accept;
} }
} }
${gw_table}
EOF EOF
} }

View File

@ -0,0 +1,111 @@
#!/bin/bash
# LAN gateway rules (setup_dns_blocker.sh gateway): forward other devices'
# traffic, force their DNS to the blocker, drop DoH, DoT and the WARP tunnel.
#
# Sourced by wg_firewall.sh, which renders the result into /etc/nftables.conf,
# so a reboot or any other allow-* apply keeps it. Only FORWARDED traffic and
# the nat hooks are touched: the PC's own traffic never passes through these.
# DoH/DoT provider addresses. Everything else to these from the LAN is dropped,
# so a browser set to "secure DNS" fails over to plain DNS, which is DNATed to
# the blocker. The PC's own upstream queries are OUTPUT, never FORWARD.
readonly DOH_RESOLVERS_V4="1.1.1.1, 1.0.0.1, 8.8.8.8, 8.8.4.4, 9.9.9.9, 149.112.112.112, \
208.67.222.222, 208.67.220.220, 94.140.14.14, 94.140.15.15, 45.90.28.0/24, \
45.90.30.0/24, 76.76.2.0/24, 76.76.10.0/24, 194.242.2.0/24, 185.228.168.9, \
185.228.169.9, 104.16.248.249, 104.16.249.249, 162.159.36.1, 162.159.46.1"
# Cloudflare WARP tunnel ingress. A WARP client tunnels everything past the
# DNS redirect, so the tunnel itself is the bypass. Dedicated ranges are dropped
# outright (MASQUE falls back to TCP 443, so a port match is not enough):
# 162.159.193.0/24 WireGuard, 162.159.197.0/24 MASQUE, 162.159.192.0/24 the
# consumer endpoint. Source: developers.cloudflare.com/cloudflare-one/
# team-and-resources/devices/warp/deployment/firewall/ (2026-10-25).
readonly WARP_V4="162.159.192.0/24, 162.159.193.0/24, 162.159.197.0/24"
# Consumer WARP endpoints that share anycast space with proxied websites: only
# UDP is dropped there, so those sites still load (HTTP/3 falls back to TCP).
# No LAN-wide drop of UDP 500/1701/4500: that would break IPsec for everyone.
readonly WARP_UDP_V4="162.159.192.0/22, 162.159.204.0/22, 188.114.96.0/22"
# Sources in warp_open skip both WARP drops. Empty by default; warp_window.sh
# fills it Mon/Fri 09:00-15:30 with an element that times out by itself. Any
# re-apply of this ruleset empties it again.
# Fill in LAN_IFACE/LAN_IP from the default route unless the caller set them.
lan_gateway_detect() {
local route
[[ -n ${LAN_IFACE:-} && -n ${LAN_IP:-} ]] && return 0
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")"
[[ -n $LAN_IFACE && -n $LAN_IP ]] || die "Could not detect the LAN interface/IP."
}
# Fragments for render_nftables_ruleset. Each prints nothing while gateway
# mode is off -- except the nat table, whose declare+delete is always rendered
# so that turning gateway mode off removes a stale table too.
lan_gateway_on() { [[ ${ALLOW_LAN_GATEWAY:-false} == "true" ]]; }
# One interval set of IPv4 ranges, inside table inet filter.
_lan_gateway_v4_set() {
printf '\n\tset %s {\n\t\ttype ipv4_addr\n\t\tflags interval\n' "$1"
printf '\t\telements = { %s }\n\t}\n' "$2"
}
# The DoH and WARP address sets.
lan_gateway_set() {
lan_gateway_on || return 0
_lan_gateway_v4_set doh_v4 "$DOH_RESOLVERS_V4"
_lan_gateway_v4_set warp_v4 "$WARP_V4"
_lan_gateway_v4_set warp_udp_v4 "$WARP_UDP_V4"
printf '\n\tset warp_open {\n\t\ttype ipv4_addr\n\t\tflags interval,timeout\n\t}\n'
}
# Forward-chain rules, placed first in the chain: the drops before the accept.
lan_gateway_forward() {
lan_gateway_on || return 0
lan_gateway_detect
local from="iifname \"${LAN_IFACE}\" ip saddr ${LAN_SUBNET}"
printf '\n\t\t%s ip daddr @doh_v4 drop' "$from"
printf '\n\t\t%s tcp dport 853 drop' "$from"
printf '\n\t\t%s udp dport 853 drop' "$from"
printf '\n\t\t%s ip saddr != @warp_open ip daddr @warp_v4 drop' "$from"
printf '\n\t\t%s ip saddr != @warp_open ip daddr @warp_udp_v4 meta l4proto udp drop' "$from"
printf '\n\t\tiifname "%s" oifname "%s" ip saddr %s accept' \
"$LAN_IFACE" "$LAN_IFACE" "$LAN_SUBNET"
}
lan_gateway_table() {
printf 'table ip lan_gateway\ndelete table ip lan_gateway\n'
lan_gateway_on || return 0
lan_gateway_detect
local dns="iifname \"${LAN_IFACE}\" ip saddr ${LAN_SUBNET} ip saddr != ${LAN_IP} ip daddr != ${LAN_IP}"
cat <<EOF
table ip lan_gateway {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
${dns} udp dport 53 dnat to ${LAN_IP}
${dns} tcp dport 53 dnat to ${LAN_IP}
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
oifname "${LAN_IFACE}" ip saddr ${LAN_SUBNET} ip saddr != ${LAN_IP} ip daddr != ${LAN_SUBNET} masquerade
}
}
EOF
}
allow_lan_gateway() {
export ALLOW_LAN_GATEWAY=true
save_config
write_nftables_ruleset
verify_nftables_then_apply
log_ok "LAN gateway rules applied: forwarding, DNS redirect, DoH/DoT and WARP drop (persisted)."
}
deny_lan_gateway() {
export ALLOW_LAN_GATEWAY=false
save_config
write_nftables_ruleset
verify_nftables_then_apply
log_ok "LAN gateway rules removed (persisted)."
}

View File

@ -237,6 +237,7 @@ Commands:
allow-dns Open udp/tcp 53 from the LAN in the firewall (for the DNS blocker on this host). allow-dns Open udp/tcp 53 from the LAN in the firewall (for the DNS blocker on this host).
allow-dot Open tcp/853 on wg0 in the firewall (for the DoT resolver on this host). allow-dot Open tcp/853 on wg0 in the firewall (for the DoT resolver on this host).
allow-workout-poke Open tcp/8774 from the LAN only (screen-locker's workout poke). allow-workout-poke Open tcp/8774 from the LAN only (screen-locker's workout poke).
allow-lan-gateway|deny-lan-gateway LAN gateway rules (run by 'setup_dns_blocker.sh gateway').
verify Check /etc/nftables.conf matches this script and that verify Check /etc/nftables.conf matches this script and that
the live forward chain still lets Docker through. the live forward chain still lets Docker through.
install-verify Install the units that run 'verify' at every boot, so install-verify Install the units that run 'verify' at every boot, so

View File

@ -8,15 +8,17 @@
# repo's generate_hosts_file.sh (same StevenBlack variant + custom entries + # repo's generate_hosts_file.sh (same StevenBlack variant + custom entries +
# unblocks that /etc/hosts uses), so blocking is identical by construction. # 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 # By default clients are only POINTED at it (DHCP-advertised DNS), which DoH,
# POINTED at (via the router's DHCP-advertised DNS). See the manual steps # a manual DNS or a VPN bypasses. `gateway` closes most of that: DHCP clients
# printed by `setup`. Known bypasses (DoH / Private DNS / manual DNS / VPN) # then route through this PC, DNS is forced here and DoH/DoT are dropped.
# are documented there too -- a voluntary-DNS design cannot force traffic. # Recovery: docs/DOCS-lan-blocker-contingency.md.
# #
# Usage: # Usage:
# sudo ./setup_dns_blocker.sh setup # first-time install + enable # sudo ./setup_dns_blocker.sh setup # first-time install + enable
# ./setup_dns_blocker.sh status # is it set up & serving? (no root) # ./setup_dns_blocker.sh status # is it set up & serving? (no root)
# sudo ./setup_dns_blocker.sh refresh # rebuild feed + reload (timer runs this) # sudo ./setup_dns_blocker.sh refresh # rebuild feed + reload (timer runs this)
# sudo ./setup_dns_blocker.sh gateway # route LAN clients through this PC
# sudo ./setup_dns_blocker.sh gateway-off
# ./setup_dns_blocker.sh help # ./setup_dns_blocker.sh help
# #
# Idempotent and safe to re-run. # Idempotent and safe to re-run.
@ -44,7 +46,8 @@ readonly WATCHDOG_SERVICE="/etc/systemd/system/dnsmasq-watchdog.service"
readonly WATCHDOG_TIMER="/etc/systemd/system/dnsmasq-watchdog.timer" readonly WATCHDOG_TIMER="/etc/systemd/system/dnsmasq-watchdog.timer"
# Optional DHCP-server mode (for routers that cannot advertise a custom DNS). # Optional DHCP-server mode (for routers that cannot advertise a custom DNS).
readonly DHCP_CONF="/etc/dnsmasq.d/lan-dhcp.conf" readonly DHCP_CONF="/etc/dnsmasq.d/lan-dhcp.conf"
readonly DHCP_LEASE="12h" # Short, so devices pick up a gateway-off or a revived Funbox DHCP quickly.
readonly DHCP_LEASE="2h"
readonly DHCP_START_HOST="10" # .10 .. .150 mirrors a common router default range readonly DHCP_START_HOST="10" # .10 .. .150 mirrors a common router default range
readonly DHCP_END_HOST="150" readonly DHCP_END_HOST="150"
# Feed generator (chattr +i but still executable) and firewall owner script. # Feed generator (chattr +i but still executable) and firewall owner script.
@ -73,8 +76,6 @@ detect_lan() {
GATEWAY="$(ip -4 route show default 2>/dev/null | awk '{print $3; exit}')" GATEWAY="$(ip -4 route show default 2>/dev/null | awk '{print $3; exit}')"
[[ -n ${LAN_IFACE:-} && -n ${LAN_IP:-} ]] || [[ -n ${LAN_IFACE:-} && -n ${LAN_IP:-} ]] ||
die "Could not detect the LAN interface/IP from the default route." 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 ----------------------------------------------------------- # ---- Setup steps -----------------------------------------------------------
@ -126,6 +127,8 @@ source "$SCRIPT_DIR/lib/dns_units.sh"
source "$SCRIPT_DIR/lib/dns_firewall.sh" source "$SCRIPT_DIR/lib/dns_firewall.sh"
# shellcheck source=lib/dns_dhcp.sh # shellcheck source=lib/dns_dhcp.sh
source "$SCRIPT_DIR/lib/dns_dhcp.sh" source "$SCRIPT_DIR/lib/dns_dhcp.sh"
# shellcheck source=lib/dns_gateway.sh
source "$SCRIPT_DIR/lib/dns_gateway.sh"
cmd_setup() { cmd_setup() {
detect_lan detect_lan
@ -181,6 +184,8 @@ Commands:
dhcp Make this PC the LAN DHCP server so every device uses it as DNS (root). 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. 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). dhcp-off Revert DHCP mode: stop serving leases, return the NIC to DHCP-client (root).
gateway Route DHCP clients through this PC: forced DNS, DoH/DoT dropped (root).
gateway-off Give clients the router back as gateway; blocking stays on (root).
help Show this message. help Show this message.
EOF EOF
} }
@ -188,7 +193,7 @@ EOF
main() { main() {
local cmd="${1:-help}" local cmd="${1:-help}"
case "$cmd" in case "$cmd" in
setup | refresh | dhcp | dhcp-off) setup | refresh | dhcp | dhcp-off | gateway | gateway-off)
require_root "$@" require_root "$@"
;; ;;
esac esac
@ -198,6 +203,8 @@ main() {
refresh) cmd_refresh ;; refresh) cmd_refresh ;;
dhcp) cmd_dhcp ;; dhcp) cmd_dhcp ;;
dhcp-off) cmd_dhcp_off ;; dhcp-off) cmd_dhcp_off ;;
gateway) cmd_gateway ;;
gateway-off) cmd_gateway_off ;;
status) cmd_status ;; status) cmd_status ;;
help | -h | --help) usage ;; help | -h | --help) usage ;;
*) *)

View File

@ -42,6 +42,7 @@ export ALLOW_DNS="${ALLOW_DNS:-false}"
export ALLOW_HOMEASSISTANT="${ALLOW_HOMEASSISTANT:-false}" export ALLOW_HOMEASSISTANT="${ALLOW_HOMEASSISTANT:-false}"
export ALLOW_DOT="${ALLOW_DOT:-false}" export ALLOW_DOT="${ALLOW_DOT:-false}"
export ALLOW_WORKOUT_POKE="${ALLOW_WORKOUT_POKE:-false}" export ALLOW_WORKOUT_POKE="${ALLOW_WORKOUT_POKE:-false}"
export ALLOW_LAN_GATEWAY="${ALLOW_LAN_GATEWAY:-false}"
die() { die() {
log_error "$1" log_error "$1"
@ -58,6 +59,7 @@ ALLOW_DNS="${ALLOW_DNS}"
ALLOW_HOMEASSISTANT="${ALLOW_HOMEASSISTANT}" ALLOW_HOMEASSISTANT="${ALLOW_HOMEASSISTANT}"
ALLOW_DOT="${ALLOW_DOT}" ALLOW_DOT="${ALLOW_DOT}"
ALLOW_WORKOUT_POKE="${ALLOW_WORKOUT_POKE}" ALLOW_WORKOUT_POKE="${ALLOW_WORKOUT_POKE}"
ALLOW_LAN_GATEWAY="${ALLOW_LAN_GATEWAY}"
EOF EOF
chmod 600 "$CONFIG_FILE" chmod 600 "$CONFIG_FILE"
} }
@ -83,6 +85,8 @@ install_dependencies() {
# shellcheck source=lib/wg_keys.sh # shellcheck source=lib/wg_keys.sh
source "$SCRIPT_DIR/lib/wg_keys.sh" source "$SCRIPT_DIR/lib/wg_keys.sh"
# shellcheck source=lib/wg_gateway.sh
source "$SCRIPT_DIR/lib/wg_gateway.sh"
# shellcheck source=lib/wg_firewall.sh # shellcheck source=lib/wg_firewall.sh
source "$SCRIPT_DIR/lib/wg_firewall.sh" source "$SCRIPT_DIR/lib/wg_firewall.sh"
# shellcheck source=lib/wg_sshd.sh # shellcheck source=lib/wg_sshd.sh
@ -97,7 +101,7 @@ main() {
# off -- exec sudo "$0" "$@" inside require_root must re-launch with the # off -- exec sudo "$0" "$@" inside require_root must re-launch with the
# subcommand still present, or sudo would silently run with no args. # subcommand still present, or sudo would silently run with no args.
case "$cmd" in case "$cmd" in
setup | add-peer | revoke | allow-web | allow-dns | allow-homeassistant | allow-dot | allow-workout-poke | verify | install-verify) setup | add-peer | revoke | allow-web | allow-dns | allow-homeassistant | allow-dot | allow-workout-poke | allow-lan-gateway | deny-lan-gateway | verify | install-verify)
require_root "$@" require_root "$@"
;; ;;
esac esac
@ -146,6 +150,14 @@ main() {
allow_workout_poke allow_workout_poke
install_verify_units install_verify_units
;; ;;
allow-lan-gateway)
allow_lan_gateway
install_verify_units
;;
deny-lan-gateway)
deny_lan_gateway
install_verify_units
;;
verify) verify)
verify_nft verify_nft
;; ;;

View File

@ -0,0 +1,160 @@
#!/bin/bash
# ============================================================================
# warp_home.sh -- the work laptop's side of the home WARP window. No root.
#
# At home the PC drops the Cloudflare WARP tunnel (gateway mode), and WARP
# fails CLOSED: blocked, even stuck at "Connecting", it leaves the laptop with
# no internet at all, and Always On keeps reconnecting it. So a user timer
# runs `tick` every 30 s: at home, with no window open, it turns WARP off and
# plain internet (with the DNS blocklist) comes back. On any other network,
# e.g. the office, it never touches WARP.
#
# warp-home on Mon/Fri 09:00-15:30 at home: ask the PC, then connect.
# Anywhere else: just connect.
# warp-home off disconnect and forget the window
# warp-home status network, window, WARP and the PC's view
# warp-home install copy to ~/.local/bin, create the key, enable the timer
#
# The PC enforces the window (warp_window.sh); this only remembers the end
# it was told. Re-applying the PC firewall closes the window early: run
# `warp-home on` again.
# ============================================================================
set -euo pipefail
readonly PC_IP="192.168.1.43" PC_MAC="9c:6b:00:5c:c5:6d" PC_USER="kuhy"
readonly WARN_SEC=300 # warn five minutes before the 15:30 cut-off
KEY="$HOME/.ssh/warp_window_ed25519"
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/warp-home"
OPEN_UNTIL="$STATE_DIR/open-until"
WARNED="$STATE_DIR/warned"
die() {
printf 'warp-home: %s\n' "$1" >&2
exit 1
}
warp() { warp-cli --accept-tos "$@"; }
notify() { notify-send -a warp-home "WARP" "$1" 2>/dev/null || true; }
# Home means the default gateway is the PC, by IP and by MAC, so another
# network that happens to use 192.168.1.43 does not count.
at_home() {
local gw dev
read -r _ _ gw _ dev _ <<<"$(ip -4 route show default | head -n1)"
[[ $gw == "$PC_IP" ]] || return 1
[[ $(ip neigh show "$gw" dev "$dev") == *"lladdr $PC_MAC"* ]]
}
window_open() {
[[ -f $OPEN_UNTIL ]] && (($(date +%s) < $(<"$OPEN_UNTIL")))
}
forget_window() { rm -f "$OPEN_UNTIL" "$WARNED"; }
pc() {
# accept-new: the first call learns the PC's host key (BatchMode would
# refuse it outright), later calls refuse a changed one.
ssh -i "$KEY" -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=5 \
-o StrictHostKeyChecking=accept-new "$PC_USER@$PC_IP" "$1"
}
# Turn WARP off (it is blocked, so on it means offline) and say why.
warp_off_because() {
forget_window
[[ $(warp status) == *Disconnected* ]] && return 0
warp disconnect >/dev/null
notify "$1"
}
cmd_tick() {
at_home || return 0
window_open || {
warp_off_because "WARP turned off at home. 'warp-home on' works Mon/Fri 09:00-15:30."
return 0
}
# Any re-apply of the PC firewall empties its set before 15:30. Only an
# explicit "closed" counts: an unreachable PC proves nothing.
if [[ $(pc status 2>/dev/null) == closed ]]; then
warp_off_because "The PC closed the WARP window early; run 'warp-home on' again."
return 0
fi
if (($(<"$OPEN_UNTIL") - $(date +%s) <= WARN_SEC)) && [[ ! -e $WARNED ]]; then
notify "WARP closes at 15:30, in five minutes."
touch "$WARNED"
fi
}
cmd_on() {
local answer left
if at_home; then
answer="$(pc open)" || die "${answer:-the PC refused}"
left="$(sed -n 's/^seconds_left=//p' <<<"$answer")"
[[ $left =~ ^[0-9]+$ ]] || die "unexpected answer from the PC: $answer"
mkdir -p "$STATE_DIR"
rm -f "$WARNED"
printf '%s\n' "$(($(date +%s) + left))" >"$OPEN_UNTIL"
fi
warp connect >/dev/null
printf 'WARP connecting.%s\n' "$(at_home && printf ' Home window ends at 15:30.')"
}
cmd_off() {
forget_window
warp disconnect >/dev/null
printf 'WARP off.\n'
}
cmd_status() {
if at_home; then
printf 'network: home\nwindow: %s\npc: %s\n' \
"$(window_open && printf open || printf closed)" "$(pc status 2>&1 || true)"
else
printf 'network: not home (WARP is not managed here)\n'
fi
printf 'warp: %s\n' "$(warp status | head -n1)"
}
cmd_install() {
local bin="$HOME/.local/bin/warp-home" units="$HOME/.config/systemd/user"
mkdir -p "${bin%/*}" "$units" "$HOME/.ssh"
install -m 0755 "${BASH_SOURCE[0]}" "$bin"
[[ -f $KEY ]] || ssh-keygen -q -t ed25519 -N '' -C warp-home -f "$KEY"
cat >"$units/warp-home.service" <<EOF
[Unit]
Description=Turn WARP off at home outside the work-from-home window
[Service]
Type=oneshot
ExecStart=$bin tick
EOF
cat >"$units/warp-home.timer" <<EOF
[Unit]
Description=Check WARP against the home window every 30 s
[Timer]
OnBootSec=30s
OnUnitActiveSec=30s
AccuracySec=5s
[Install]
WantedBy=timers.target
EOF
systemctl --user daemon-reload
systemctl --user enable --now warp-home.timer
printf 'Installed. On the PC: sudo warp_window.sh install %s.pub\n' "$KEY"
}
main() {
case "${1:-}" in
on | off | status | tick | install) "cmd_$1" ;;
*)
printf 'usage: warp-home on|off|status|install\n' >&2
exit 2
;;
esac
}
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
main "$@"
fi

View File

@ -0,0 +1,132 @@
#!/bin/bash
# ============================================================================
# warp_window.sh -- let the LAN use Cloudflare WARP on work-from-home days.
#
# Gateway mode (setup_dns_blocker.sh gateway) drops the WARP tunnel, because
# WARP carries all traffic past the DNS block. This opens it for every LAN
# device, but ONLY Mon/Fri 09:00-15:30 by this PC's clock: the open adds one
# element to the nft set warp_open whose timeout ends at 15:30, so the kernel
# closes it again with nothing left to fail. The office days (Tue-Thu) need
# nothing here: the home gateway never touches the work network.
#
# The laptop (warp_home.sh) asks over an SSH key whose forced command is
# `sudo -n /usr/local/sbin/warp-window ssh`, which accepts only open|status.
# The window check runs here, so the laptop's clock cannot widen it.
#
# Usage:
# sudo ./warp_window.sh open | close | status
# sudo ./warp_window.sh install <laptop-key.pub>
#
# Standalone on purpose: install copies this one file to /usr/local/sbin.
# ============================================================================
set -euo pipefail
readonly WINDOW_DAYS=" 1 5 " # date +%u: Monday, Friday
readonly OPEN_SEC=$((9 * 3600)) CLOSE_SEC=$((15 * 3600 + 30 * 60))
readonly NFT_SET="inet filter warp_open"
readonly KEY_FROM="192.168.1.0/24" # the key works only from the LAN
INSTALL_PATH="/usr/local/sbin/warp-window"
SUDOERS_FILE="/etc/sudoers.d/warp-window"
KEY_USER="${SUDO_USER:-kuhy}"
KEY_HOME="$(getent passwd "$KEY_USER" | cut -d: -f6 || true)"
die() {
printf 'warp-window: %s\n' "$1" >&2
exit 1
}
# Seconds until 15:30 while the window is open; fails when it is closed.
# Plain arithmetic, not `date -d 15:30`: DST never changes on a Mon/Fri day.
window_seconds_left() {
local day h m s now
read -r day h m s <<<"$(date '+%u %H %M %S')"
now=$((10#$h * 3600 + 10#$m * 60 + 10#$s))
[[ $WINDOW_DAYS == *" $day "* ]] || return 1
((now >= OPEN_SEC && now < CLOSE_SEC)) || return 1
printf '%s\n' "$((CLOSE_SEC - now))"
}
cmd_open() {
local left
left="$(window_seconds_left)" || die "WARP window is Mon/Fri 09:00-15:30 only."
# Re-opening must refresh the timeout, and add keeps an old one: flush first.
# shellcheck disable=SC2086 # NFT_SET is three words on purpose
nft flush set $NFT_SET 2>/dev/null || die "no warp_open set: is gateway mode on?"
# shellcheck disable=SC2086
nft add element $NFT_SET "{ 0.0.0.0/0 timeout ${left}s }"
printf 'WARP open for the LAN until 15:30.\nseconds_left=%s\n' "$left"
}
cmd_close() {
# shellcheck disable=SC2086
nft flush set $NFT_SET
printf 'WARP blocked again.\n'
}
cmd_status() {
local set
# shellcheck disable=SC2086
set="$(nft list set $NFT_SET)" || die "no warp_open set: is gateway mode on?"
if [[ $set == *expires* ]]; then
printf 'open (%s)\n' "$(grep -o 'expires [0-9hms]*' <<<"$set")"
else
printf 'closed\n'
fi
}
# The forced-command entry point. SSH_ORIGINAL_COMMAND is whatever the client
# typed, so it is matched whole: "open; anything" is not "open".
cmd_ssh() {
case "${SSH_ORIGINAL_COMMAND:-}" in
open) cmd_open ;;
status) cmd_status ;;
*) die "allowed over ssh: open, status" ;;
esac
}
cmd_install() {
local pub="${1:-}" tmp key keys
[[ -f $pub ]] || die "usage: install <laptop-key.pub>"
[[ -n $KEY_HOME ]] || die "no home directory for $KEY_USER"
install -m 0755 "${BASH_SOURCE[0]}" "$INSTALL_PATH"
tmp="$(mktemp)"
# sudo scrubs the environment; cmd_ssh needs the client's command to filter it.
printf 'Defaults!%s env_keep += "SSH_ORIGINAL_COMMAND"\n' "$INSTALL_PATH" >"$tmp"
printf '%s ALL=(root) NOPASSWD: %s ssh\n' "$KEY_USER" "$INSTALL_PATH" >>"$tmp"
visudo -cf "$tmp" >/dev/null || {
rm -f "$tmp"
die "sudoers drop-in failed visudo -c"
}
install -m 0440 "$tmp" "$SUDOERS_FILE"
rm -f "$tmp"
# One line per laptop key: drop any older copy of it, keep every other key.
read -r key <"$pub"
keys="$KEY_HOME/.ssh/authorized_keys"
mkdir -p "$KEY_HOME/.ssh"
touch "$keys"
grep -vF "$key" "$keys" >"$keys.new" || true
printf 'restrict,from="%s",command="sudo -n %s ssh" %s\n' \
"$KEY_FROM" "$INSTALL_PATH" "$key" >>"$keys.new"
mv "$keys.new" "$keys"
chmod 600 "$keys"
chown "$KEY_USER:" "$keys"
printf 'Installed %s; the laptop key may now run open/status.\n' "$INSTALL_PATH"
}
main() {
case "${1:-}" in
open | close | status | ssh) "cmd_$1" ;;
install) cmd_install "${2:-}" ;;
*)
printf 'usage: %s open|close|status|install <key.pub>\n' "$0" >&2
exit 2
;;
esac
}
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
main "$@"
fi

View File

@ -28,6 +28,11 @@
# evidence of whose home the sibling repos are in. # evidence of whose home the sibling repos are in.
extracted_repo_home() { extracted_repo_home() {
local real_user real_home local real_user real_home
# Test seam: resolve against a fake home without faking a passwd entry.
if [[ -n ${EXTRACTED_REPOS_HOME:-} ]]; then
printf '%s' "$EXTRACTED_REPOS_HOME"
return 0
fi
real_user="${SUDO_USER:-${USER:-$(id -un)}}" real_user="${SUDO_USER:-${USER:-$(id -un)}}"
if [[ $real_user == "root" ]]; then if [[ $real_user == "root" ]]; then
real_user="${REPO_OWNER_USER:-$(stat -c %U "${BASH_SOURCE[0]}" 2>/dev/null)}" real_user="${REPO_OWNER_USER:-$(stat -c %U "${BASH_SOURCE[0]}" 2>/dev/null)}"
@ -42,15 +47,24 @@ extracted_repo_home() {
# Prints the expected checkout path. An override env var (e.g. # Prints the expected checkout path. An override env var (e.g.
# HOSTS_BLOCKER_DIR for hosts-blocker) wins, so a non-standard checkout does # HOSTS_BLOCKER_DIR for hosts-blocker) wins, so a non-standard checkout does
# not require editing these scripts. # not require editing these scripts.
#
# Repos live in ~/src/<name> since the 2026-09-11 home tidy. A checkout still
# at the legacy ~/<name> is honoured only when ~/src/<name> is absent; a repo
# found in neither place resolves to ~/src/<name>, where the clone lands.
extracted_repo_dir() { extracted_repo_dir() {
local name="$1" var override local name="$1" var override home
var="$(printf '%s' "$name" | tr '[:lower:]-' '[:upper:]_')_DIR" var="$(printf '%s' "$name" | tr '[:lower:]-' '[:upper:]_')_DIR"
override="${!var:-}" override="${!var:-}"
if [[ -n $override ]]; then if [[ -n $override ]]; then
printf '%s' "$override" printf '%s' "$override"
return 0 return 0
fi fi
printf '%s/%s' "$(extracted_repo_home)" "$name" home="$(extracted_repo_home)"
if [[ ! -d $home/src/$name && -d $home/$name ]]; then
printf '%s/%s' "$home" "$name"
return 0
fi
printf '%s/src/%s' "$home" "$name"
} }
# extracted_repo_clone <repo-name> # extracted_repo_clone <repo-name>

View File

@ -0,0 +1,52 @@
#!/bin/bash
# Regression tests for lib/extracted_repos.sh.
#
# The 2026-09-11 home tidy moved every extracted repo from ~/<name> to
# ~/src/<name>, but extracted_repo_dir kept building ~/<name>. Every caller
# broke silently; dns-blocklist-refresh.service failed nightly for six weeks
# with "Feed generator not found: /home/kuhy/hosts-blocker/...".
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
REPO_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd)
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
TMP_DIR=$(mktemp -d)
cleanup() { rm -rf "$TMP_DIR"; }
trap cleanup EXIT
# Resolve <name> against a fake home, in a clean shell, with no override set.
resolve() {
EXTRACTED_REPOS_HOME="$TMP_DIR" bash -c '
unset HOSTS_BLOCKER_DIR SCREEN_LOCKER_DIR
source "$1/lib/extracted_repos.sh"
extracted_repo_dir "$2"
' _ "$REPO_DIR" "$1"
}
# 1. Repo checked out under ~/src (the current layout) -> ~/src/<name>.
mkdir -p "$TMP_DIR/src/hosts-blocker"
got=$(resolve hosts-blocker)
[[ $got == "$TMP_DIR/src/hosts-blocker" ]] || fail "src layout: got '$got'"
# 2. Only the legacy ~/<name> checkout exists -> keep finding it.
mkdir -p "$TMP_DIR/screen-locker"
got=$(resolve screen-locker)
[[ $got == "$TMP_DIR/screen-locker" ]] || fail "legacy layout: got '$got'"
# 3. Neither exists -> ~/src/<name>, which is also where a clone lands.
got=$(resolve wake-alarm)
[[ $got == "$TMP_DIR/src/wake-alarm" ]] || fail "missing repo: got '$got'"
# 4. The per-repo override still wins over both layouts.
got=$(EXTRACTED_REPOS_HOME="$TMP_DIR" HOSTS_BLOCKER_DIR=/opt/hb bash -c '
source "$1/lib/extracted_repos.sh"; extracted_repo_dir hosts-blocker
' _ "$REPO_DIR")
[[ $got == "/opt/hb" ]] || fail "override: got '$got'"
printf 'PASS: test_extracted_repos.sh\n'

View File

@ -26,7 +26,7 @@ trap cleanup EXIT
# --- Dispatch tests (real script, no root needed) -------------------------- # --- Dispatch tests (real script, no root needed) --------------------------
help_out=$("$TARGET_SCRIPT" help) help_out=$("$TARGET_SCRIPT" help)
for cmd in setup status refresh dhcp dhcp-off; do for cmd in setup status refresh dhcp dhcp-off gateway gateway-off; do
[[ "$help_out" == *"$cmd"* ]] || fail "help output missing '$cmd' subcommand" [[ "$help_out" == *"$cmd"* ]] || fail "help output missing '$cmd' subcommand"
done done