From 1ea94e142cf84b6bac12a7501ed3fc1850b51999 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 25 Oct 2026 09:52:40 +0100 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_019C8pdLR4MePsLocE1pWz7F --- docs/DOCS-lan-blocker-contingency.md | 142 +++++++++++++++ .../lan-gateway-warp-window-2026-10-25.json | 16 ++ .../lan-gateway-warp-window-2026-10-25.json | 51 ++++++ .../features/lib/dns_gateway.sh | 150 ++++++++++++++++ linux_configuration/features/lib/dns_units.sh | 32 +++- .../lib/tests/e2e_dhcp_gateway_tags.sh | 70 ++++++++ .../lib/tests/e2e_warp_forward_drop.sh | 129 ++++++++++++++ .../features/lib/tests/features_harness.sh | 13 ++ .../features/lib/tests/test_dns_gateway.sh | 89 ++++++++++ .../features/lib/tests/test_dns_units.sh | 19 +- .../features/lib/tests/test_warp_home.sh | 168 ++++++++++++++++++ .../features/lib/tests/test_warp_window.sh | 118 ++++++++++++ .../features/lib/tests/test_wg_firewall.sh | 62 +++++++ .../features/lib/wg_firewall.sh | 11 +- .../features/lib/wg_gateway.sh | 111 ++++++++++++ linux_configuration/features/lib/wg_sshd.sh | 1 + .../features/setup_dns_blocker.sh | 23 ++- .../features/setup_wireguard_ssh.sh | 14 +- linux_configuration/features/warp_home.sh | 160 +++++++++++++++++ linux_configuration/features/warp_window.sh | 132 ++++++++++++++ linux_configuration/lib/extracted_repos.sh | 18 +- .../tests/test_extracted_repos.sh | 52 ++++++ .../tests/test_setup_dns_blocker.sh | 2 +- 23 files changed, 1565 insertions(+), 18 deletions(-) create mode 100644 docs/DOCS-lan-blocker-contingency.md create mode 100644 docs/superpowers/contracts/lan-gateway-warp-window-2026-10-25.json create mode 100644 docs/superpowers/evidence/lan-gateway-warp-window-2026-10-25.json create mode 100755 linux_configuration/features/lib/dns_gateway.sh create mode 100755 linux_configuration/features/lib/tests/e2e_dhcp_gateway_tags.sh create mode 100755 linux_configuration/features/lib/tests/e2e_warp_forward_drop.sh create mode 100755 linux_configuration/features/lib/tests/test_dns_gateway.sh create mode 100755 linux_configuration/features/lib/tests/test_warp_home.sh create mode 100755 linux_configuration/features/lib/tests/test_warp_window.sh create mode 100755 linux_configuration/features/lib/wg_gateway.sh create mode 100755 linux_configuration/features/warp_home.sh create mode 100755 linux_configuration/features/warp_window.sh create mode 100755 linux_configuration/tests/test_extracted_repos.sh diff --git a/docs/DOCS-lan-blocker-contingency.md b/docs/DOCS-lan-blocker-contingency.md new file mode 100644 index 00000000..77e7bdbb --- /dev/null +++ b/docs/DOCS-lan-blocker-contingency.md @@ -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 `: 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`. diff --git a/docs/superpowers/contracts/lan-gateway-warp-window-2026-10-25.json b/docs/superpowers/contracts/lan-gateway-warp-window-2026-10-25.json new file mode 100644 index 00000000..cad280bf --- /dev/null +++ b/docs/superpowers/contracts/lan-gateway-warp-window-2026-10-25.json @@ -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" +} diff --git a/docs/superpowers/evidence/lan-gateway-warp-window-2026-10-25.json b/docs/superpowers/evidence/lan-gateway-warp-window-2026-10-25.json new file mode 100644 index 00000000..e19a01d4 --- /dev/null +++ b/docs/superpowers/evidence/lan-gateway-warp-window-2026-10-25.json @@ -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/ 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 --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" + ] +} diff --git a/linux_configuration/features/lib/dns_gateway.sh b/linux_configuration/features/lib/dns_gateway.sh new file mode 100755 index 00000000..eb7cd360 --- /dev/null +++ b/linux_configuration/features/lib/dns_gateway.sh @@ -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" <"$1" </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." +} diff --git a/linux_configuration/features/lib/dns_units.sh b/linux_configuration/features/lib/dns_units.sh index 4216c6ab..fde71afe 100755 --- a/linux_configuration/features/lib/dns_units.sh +++ b/linux_configuration/features/lib/dns_units.sh @@ -4,9 +4,22 @@ # 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. +# 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() { 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" </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" < 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:-}" + 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" diff --git a/linux_configuration/features/lib/tests/e2e_warp_forward_drop.sh b/linux_configuration/features/lib/tests/e2e_warp_forward_drop.sh new file mode 100755 index 00000000..fa885681 --- /dev/null +++ b/linux_configuration/features/lib/tests/e2e_warp_forward_drop.sh @@ -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. 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 - </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:-}" + 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" diff --git a/linux_configuration/features/lib/tests/features_harness.sh b/linux_configuration/features/lib/tests/features_harness.sh index 78c7ed80..834348eb 100755 --- a/linux_configuration/features/lib/tests/features_harness.sh +++ b/linux_configuration/features/lib/tests/features_harness.sh @@ -50,6 +50,19 @@ _t_file_has() { 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 # in an `if` condition, so failures are asserted inside a subshell. _t_exits_nonzero() { diff --git a/linux_configuration/features/lib/tests/test_dns_gateway.sh b/linux_configuration/features/lib/tests/test_dns_gateway.sh new file mode 100755 index 00000000..a246828c --- /dev/null +++ b/linux_configuration/features/lib/tests/test_dns_gateway.sh @@ -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" diff --git a/linux_configuration/features/lib/tests/test_dns_units.sh b/linux_configuration/features/lib/tests/test_dns_units.sh index fb3842a9..496df8b4 100755 --- a/linux_configuration/features/lib/tests/test_dns_units.sh +++ b/linux_configuration/features/lib/tests/test_dns_units.sh @@ -28,7 +28,6 @@ DNSMASQ_DROPIN_DIR="$TEST_TMPDIR/dnsmasq.service.d" DNSMASQ_DROPIN="$DNSMASQ_DROPIN_DIR/blocker.conf" LAN_IFACE="enp6s0" LAN_IP="192.168.1.43" -UPSTREAM="1.1.1.1" FEED="$TEST_TMPDIR/blocklist.hosts" LOG_FILE="$TEST_TMPDIR/dnsmasq.log" @@ -52,5 +51,23 @@ else _t_pass "no network-online ordering" 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" [[ $FAIL -eq 0 ]] diff --git a/linux_configuration/features/lib/tests/test_warp_home.sh b/linux_configuration/features/lib/tests/test_warp_home.sh new file mode 100755 index 00000000..62717b43 --- /dev/null +++ b/linux_configuration/features/lib/tests/test_warp_home.sh @@ -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" diff --git a/linux_configuration/features/lib/tests/test_warp_window.sh b/linux_configuration/features/lib/tests/test_warp_window.sh new file mode 100755 index 00000000..0b86c786 --- /dev/null +++ b/linux_configuration/features/lib/tests/test_warp_window.sh @@ -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" diff --git a/linux_configuration/features/lib/tests/test_wg_firewall.sh b/linux_configuration/features/lib/tests/test_wg_firewall.sh index 179c0e98..7baccf4a 100755 --- a/linux_configuration/features/lib/tests/test_wg_firewall.sh +++ b/linux_configuration/features/lib/tests/test_wg_firewall.sh @@ -37,6 +37,8 @@ die() { } is_service_active() { [[ ${DOCKER_ACTIVE:-yes} == yes ]]; } +# shellcheck source=../wg_gateway.sh +. "$FEATURES_LIB_DIR/wg_gateway.sh" # shellcheck source=../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" _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" diff --git a/linux_configuration/features/lib/wg_firewall.sh b/linux_configuration/features/lib/wg_firewall.sh index 4f0bdf40..b1efb38a 100755 --- a/linux_configuration/features/lib/wg_firewall.sh +++ b/linux_configuration/features/lib/wg_firewall.sh @@ -49,6 +49,11 @@ render_nftables_ruleset() { if [[ ${ALLOW_DOT:-false} == "true" ]]; then dot_rule=$'\n\t\tiifname "'"${WG_IFACE}"$'" tcp dport 853 accept' 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" <&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" <"$units/warp-home.timer" <&2 + exit 2 + ;; + esac +} + +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + main "$@" +fi diff --git a/linux_configuration/features/warp_window.sh b/linux_configuration/features/warp_window.sh new file mode 100755 index 00000000..1458df20 --- /dev/null +++ b/linux_configuration/features/warp_window.sh @@ -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 +# +# 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 " + [[ -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 \n' "$0" >&2 + exit 2 + ;; + esac +} + +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + main "$@" +fi diff --git a/linux_configuration/lib/extracted_repos.sh b/linux_configuration/lib/extracted_repos.sh index a1909125..9627d7c0 100755 --- a/linux_configuration/lib/extracted_repos.sh +++ b/linux_configuration/lib/extracted_repos.sh @@ -28,6 +28,11 @@ # evidence of whose home the sibling repos are in. extracted_repo_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)}}" if [[ $real_user == "root" ]]; then 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. # HOSTS_BLOCKER_DIR for hosts-blocker) wins, so a non-standard checkout does # not require editing these scripts. +# +# Repos live in ~/src/ since the 2026-09-11 home tidy. A checkout still +# at the legacy ~/ is honoured only when ~/src/ is absent; a repo +# found in neither place resolves to ~/src/, where the clone lands. extracted_repo_dir() { - local name="$1" var override + local name="$1" var override home var="$(printf '%s' "$name" | tr '[:lower:]-' '[:upper:]_')_DIR" override="${!var:-}" if [[ -n $override ]]; then printf '%s' "$override" return 0 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 diff --git a/linux_configuration/tests/test_extracted_repos.sh b/linux_configuration/tests/test_extracted_repos.sh new file mode 100755 index 00000000..b26de513 --- /dev/null +++ b/linux_configuration/tests/test_extracted_repos.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Regression tests for lib/extracted_repos.sh. +# +# The 2026-09-11 home tidy moved every extracted repo from ~/ to +# ~/src/, but extracted_repo_dir kept building ~/. 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 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/. +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 ~/ 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/, 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' diff --git a/linux_configuration/tests/test_setup_dns_blocker.sh b/linux_configuration/tests/test_setup_dns_blocker.sh index 0c5bbbfd..82fdc659 100755 --- a/linux_configuration/tests/test_setup_dns_blocker.sh +++ b/linux_configuration/tests/test_setup_dns_blocker.sh @@ -26,7 +26,7 @@ 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 +for cmd in setup status refresh dhcp dhcp-off gateway gateway-off; do [[ "$help_out" == *"$cmd"* ]] || fail "help output missing '$cmd' subcommand" done