diff --git a/docs/superpowers/contracts/wg-firewall-allow-workout-poke-2026-10-09.json b/docs/superpowers/contracts/wg-firewall-allow-workout-poke-2026-10-09.json new file mode 100644 index 00000000..b4526e9c --- /dev/null +++ b/docs/superpowers/contracts/wg-firewall-allow-workout-poke-2026-10-09.json @@ -0,0 +1,15 @@ +{ + "title": "Firewall: LAN-only tcp/8773 for the workout poke", + "objective": "setup_wireguard_ssh.sh allow-workout-poke opens tcp/8773 from the LAN subnet only (never wg0), persists ALLOW_WORKOUT_POKE so every later re-apply keeps it, and leaves it closed when the flag is unset or false.", + "acceptance_criteria": [ + "ALLOW_WORKOUT_POKE unset or false renders no dport 8773 rule (test_wg_firewall.sh)", + "ALLOW_WORKOUT_POKE=true renders exactly one 8773 rule: ip saddr ${LAN_SUBNET} tcp dport 8773 accept (test_wg_firewall.sh)", + "allow_workout_poke saves the flag as true before verify-then-apply, and the applied ruleset carries the rule (test_wg_firewall.sh)", + "Host: nft list ruleset shows the rule after the user ran allow-workout-poke" + ], + "out_of_scope": [ + "Any wg0 rule for 8773", + "The poke server itself (screen-locker repo)" + ], + "verifier": "lib/tests/test_wg_firewall.sh, lib/tests/run_all.sh in shell_coverage_jail.sh, nft list ruleset on the host" +} diff --git a/docs/superpowers/evidence/wg-firewall-allow-workout-poke-2026-10-09.json b/docs/superpowers/evidence/wg-firewall-allow-workout-poke-2026-10-09.json new file mode 100644 index 00000000..5ebc9a26 --- /dev/null +++ b/docs/superpowers/evidence/wg-firewall-allow-workout-poke-2026-10-09.json @@ -0,0 +1,43 @@ +{ + "intent": "screen-locker's workout poke (phone POSTs a finished workout to tcp/8773) can reach the PC from the home LAN, persisted across firewall re-applies, without exposing the port on WireGuard.", + "scope": [ + "linux_configuration/features/lib/wg_firewall.sh: ALLOW_WORKOUT_POKE renders 'ip saddr ${LAN_SUBNET} tcp dport 8773 accept'; allow_workout_poke()", + "linux_configuration/features/setup_wireguard_ssh.sh: default, save_config, require_root list, allow-workout-poke dispatch", + "linux_configuration/features/lib/wg_sshd.sh: usage line", + "linux_configuration/features/lib/tests/test_wg_firewall.sh: render + apply-path tests", + "Non-goal: no wg0 rule for 8773; off the LAN the poke fails fast and the normal sync credits the workout" + ], + "changes": [ + "New ALLOW_WORKOUT_POKE flag (default false) persisted in .wireguard_ssh.conf like ALLOW_DOT", + "allow-workout-poke subcommand: export flag, save_config, render, verify-then-apply, install verify units" + ], + "verification": [ + { + "command": "bash linux_configuration/features/lib/tests/test_wg_firewall.sh", + "result": "pass", + "evidence": "18 passed, 0 failed: unset/false render no 8773 rule; true renders exactly one, LAN-only; allow_workout_poke saves the flag as true before applying and the applied ruleset carries the rule" + }, + { + "command": "shell_coverage_jail.sh --subject linux_configuration/features/lib/tests/run_all.sh --min 0 --fail-on-case-error", + "result": "pass", + "evidence": "jailed suite exit 0" + }, + { + "command": "sudo setup_wireguard_ssh.sh allow-workout-poke on the host (run by the user)", + "result": "pass", + "evidence": "nft list ruleset shows ip saddr tcp dport 8773 accept in inet filter input" + }, + { + "command": "pre-commit run --files ", + "result": "pass", + "evidence": "all hooks passed" + } + ], + "risks": [ + "Any LAN host can reach tcp/8773; the poke server authenticates requests itself." + ], + "rollback": [ + "git revert this commit, set ALLOW_WORKOUT_POKE=false in .wireguard_ssh.conf and re-run any allow-* to re-render", + "After rollback: setup_wireguard_ssh.sh verify reports no drift and no 8773 rule" + ] +} diff --git a/linux_configuration/features/lib/tests/test_wg_firewall.sh b/linux_configuration/features/lib/tests/test_wg_firewall.sh index 171f8c17..736dcba3 100755 --- a/linux_configuration/features/lib/tests/test_wg_firewall.sh +++ b/linux_configuration/features/lib/tests/test_wg_firewall.sh @@ -63,8 +63,28 @@ if grep -q 'dport 853' "$RULES"; then else _t_pass "ALLOW_DOT=false renders no 853 rule" fi +printf '\n-- the workout poke is LAN-only --\n' +if grep -q 'dport 8773' "$RULES"; then + _t_fail "ALLOW_WORKOUT_POKE unset renders no 8773 rule" +else + _t_pass "ALLOW_WORKOUT_POKE unset renders no 8773 rule" +fi +ALLOW_WORKOUT_POKE="true" +render_nftables_ruleset "$RULES" +_t_file_has "$RULES" 'ip saddr 192.168.1.0/24 tcp dport 8773 accept' \ + "ALLOW_WORKOUT_POKE opens 8773 to the LAN subnet" +_t_eq "1" "$(grep -c 'dport 8773' "$RULES")" \ + "exactly one 8773 rule: no wg0 rule, the poke never rides WireGuard" +ALLOW_WORKOUT_POKE="false" +render_nftables_ruleset "$RULES" +if grep -q 'dport 8773' "$RULES"; then + _t_fail "ALLOW_WORKOUT_POKE=false renders no 8773 rule" +else + _t_pass "ALLOW_WORKOUT_POKE=false renders no 8773 rule" +fi if command -v nft >/dev/null 2>&1; then ALLOW_DOT="true" + ALLOW_WORKOUT_POKE="true" render_nftables_ruleset "$RULES" if nft -c -f "$RULES" >/dev/null 2>&1; then _t_pass "nft -c accepts the rendered ruleset" @@ -100,4 +120,22 @@ else _t_pass "an inactive docker is not touched" fi +printf '\n-- allow-workout-poke persists the flag, then applies it --\n' +# save_config belongs to setup_wireguard_ssh.sh and the apply needs root, so +# both are recorded instead; the ruleset itself is the real render. +save_config() { + printf 'save_config ALLOW_WORKOUT_POKE=%s\n' "$ALLOW_WORKOUT_POKE" >>"$TEST_TMPDIR/calls.log" +} +verify_nftables_then_apply() { printf 'apply\n' >>"$TEST_TMPDIR/calls.log"; } +: >"$TEST_TMPDIR/calls.log" +rm -f "$NFT_CONF" "${NFT_CONF}.new" +ALLOW_WORKOUT_POKE="false" +out="$(allow_workout_poke 2>&1)" +mapfile -t calls <"$TEST_TMPDIR/calls.log" +_t_eq "save_config ALLOW_WORKOUT_POKE=true|apply" "$(IFS='|' && printf '%s' "${calls[*]}")" \ + "the flag is saved as true before the ruleset is applied" +_t_file_has "${NFT_CONF}.new" 'ip saddr 192.168.1.0/24 tcp dport 8773 accept' \ + "the applied ruleset carries the LAN-only 8773 rule" +_t_has "$out" 'not wg0' "the confirmation says the port stays off WireGuard" + _t_report "test_wg_firewall.sh" diff --git a/linux_configuration/features/lib/wg_firewall.sh b/linux_configuration/features/lib/wg_firewall.sh index 902735cf..4af03a26 100755 --- a/linux_configuration/features/lib/wg_firewall.sh +++ b/linux_configuration/features/lib/wg_firewall.sh @@ -35,6 +35,14 @@ render_nftables_ruleset() { ha_rule=$'\n\t\tip saddr '"${LAN_SUBNET}"$' tcp dport 8123 accept' ha_rule+=$'\n\t\tiifname "'"${WG_IFACE}"$'" tcp dport 8123 accept' fi + # Workout poke (screen-locker workout-poke.service): the phone POSTs a + # finished workout to tcp/8773 the moment it ends. LAN only, deliberately + # NO wg0 rule: off the home LAN the poke fails fast and the normal sync + # credits the workout instead. + local poke_rule="" + if [[ ${ALLOW_WORKOUT_POKE:-false} == "true" ]]; then + poke_rule=$'\n\t\tip saddr '"${LAN_SUBNET}"$' tcp dport 8773 accept' + fi # DNS-over-TLS (setup_dot_resolver.sh, bound to the WireGuard address): # rendered here so a re-apply keeps it -- a runtime 'nft add rule' did not. local dot_rule="" @@ -64,7 +72,7 @@ table inet filter { udp dport ${WG_PORT} accept iifname "${WG_IFACE}" tcp dport 22 accept - ip saddr ${LAN_SUBNET} tcp dport 22 accept${web_rule}${dns_rule}${ha_rule}${dot_rule} + ip saddr ${LAN_SUBNET} tcp dport 22 accept${web_rule}${dns_rule}${ha_rule}${poke_rule}${dot_rule} } chain forward { type filter hook forward priority 0; policy drop; @@ -171,3 +179,11 @@ allow_dot() { verify_nftables_then_apply log_ok "Opened tcp/853 on ${WG_IFACE} (persisted)." } + +allow_workout_poke() { + export ALLOW_WORKOUT_POKE=true + save_config + write_nftables_ruleset + verify_nftables_then_apply + log_ok "Opened tcp/8773 from ${LAN_SUBNET:-the LAN} only, not ${WG_IFACE} (persisted)." +} diff --git a/linux_configuration/features/lib/wg_sshd.sh b/linux_configuration/features/lib/wg_sshd.sh index f2145115..2640958d 100755 --- a/linux_configuration/features/lib/wg_sshd.sh +++ b/linux_configuration/features/lib/wg_sshd.sh @@ -236,6 +236,7 @@ Commands: allow-web Open tcp/80 and tcp/443 in the firewall (for a web server on this host). allow-dns Open udp/tcp 53 from the LAN in the firewall (for the DNS blocker on this host). allow-dot Open tcp/853 on wg0 in the firewall (for the DoT resolver on this host). + allow-workout-poke Open tcp/8773 from the LAN only (screen-locker's workout poke). verify Check /etc/nftables.conf matches this script and that the live forward chain still lets Docker through. install-verify Install the units that run 'verify' at every boot, so diff --git a/linux_configuration/features/setup_wireguard_ssh.sh b/linux_configuration/features/setup_wireguard_ssh.sh index 61a0c6f6..d7b9d785 100755 --- a/linux_configuration/features/setup_wireguard_ssh.sh +++ b/linux_configuration/features/setup_wireguard_ssh.sh @@ -41,6 +41,7 @@ export ALLOW_WEB="${ALLOW_WEB:-false}" export ALLOW_DNS="${ALLOW_DNS:-false}" export ALLOW_HOMEASSISTANT="${ALLOW_HOMEASSISTANT:-false}" export ALLOW_DOT="${ALLOW_DOT:-false}" +export ALLOW_WORKOUT_POKE="${ALLOW_WORKOUT_POKE:-false}" die() { log_error "$1" @@ -56,6 +57,7 @@ ALLOW_WEB="${ALLOW_WEB}" ALLOW_DNS="${ALLOW_DNS}" ALLOW_HOMEASSISTANT="${ALLOW_HOMEASSISTANT}" ALLOW_DOT="${ALLOW_DOT}" +ALLOW_WORKOUT_POKE="${ALLOW_WORKOUT_POKE}" EOF chmod 600 "$CONFIG_FILE" } @@ -95,7 +97,7 @@ main() { # off -- exec sudo "$0" "$@" inside require_root must re-launch with the # subcommand still present, or sudo would silently run with no args. case "$cmd" in - setup | add-peer | revoke | allow-web | allow-dns | allow-homeassistant | allow-dot | verify | install-verify) + setup | add-peer | revoke | allow-web | allow-dns | allow-homeassistant | allow-dot | allow-workout-poke | verify | install-verify) require_root "$@" ;; esac @@ -140,6 +142,10 @@ main() { allow_dot install_verify_units ;; + allow-workout-poke) + allow_workout_poke + install_verify_units + ;; verify) verify_nft ;;