mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 19:27:03 +01:00
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:
parent
ef037ed098
commit
1ea94e142c
142
docs/DOCS-lan-blocker-contingency.md
Normal file
142
docs/DOCS-lan-blocker-contingency.md
Normal 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`.
|
||||||
@ -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"
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
150
linux_configuration/features/lib/dns_gateway.sh
Executable file
150
linux_configuration/features/lib/dns_gateway.sh
Executable 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."
|
||||||
|
}
|
||||||
@ -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.
|
||||||
|
|||||||
70
linux_configuration/features/lib/tests/e2e_dhcp_gateway_tags.sh
Executable file
70
linux_configuration/features/lib/tests/e2e_dhcp_gateway_tags.sh
Executable 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"
|
||||||
129
linux_configuration/features/lib/tests/e2e_warp_forward_drop.sh
Executable file
129
linux_configuration/features/lib/tests/e2e_warp_forward_drop.sh
Executable 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"
|
||||||
@ -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() {
|
||||||
|
|||||||
89
linux_configuration/features/lib/tests/test_dns_gateway.sh
Executable file
89
linux_configuration/features/lib/tests/test_dns_gateway.sh
Executable 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"
|
||||||
@ -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 ]]
|
||||||
|
|||||||
168
linux_configuration/features/lib/tests/test_warp_home.sh
Executable file
168
linux_configuration/features/lib/tests/test_warp_home.sh
Executable 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"
|
||||||
118
linux_configuration/features/lib/tests/test_warp_window.sh
Executable file
118
linux_configuration/features/lib/tests/test_warp_window.sh
Executable 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"
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
111
linux_configuration/features/lib/wg_gateway.sh
Executable file
111
linux_configuration/features/lib/wg_gateway.sh
Executable 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)."
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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 ;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
@ -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
|
||||||
;;
|
;;
|
||||||
|
|||||||
160
linux_configuration/features/warp_home.sh
Executable file
160
linux_configuration/features/warp_home.sh
Executable 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
|
||||||
132
linux_configuration/features/warp_window.sh
Executable file
132
linux_configuration/features/warp_window.sh
Executable 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
|
||||||
@ -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>
|
||||||
|
|||||||
52
linux_configuration/tests/test_extracted_repos.sh
Executable file
52
linux_configuration/tests/test_extracted_repos.sh
Executable 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'
|
||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user