Own the network: details, VPN, enterprise Wi-Fi, and a firewall that can also allow

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 16:31:52 -04:00
parent aba2d16ffa
commit b30bf40407
29 changed files with 4452 additions and 241 deletions
+97
View File
@@ -113,6 +113,86 @@ grep -q 'richRules' <<<"$page_code" || fail 'rich rules are not shown'
grep -qiE 'addRichRule|removeRichRule|--add-rich-rule' "$helper" "$page" \
&& fail 'the page edits rich rules, which are a syntax rather than a setting'
# ── 6. Opening something is not the same act as closing it ──────────────────
#
# The page gained its add side long after its remove side, and the temptation
# was to give both the same confirm-then-act shape for symmetry. That would be
# wrong, and wrong in the direction that matters: a confirmation dialog is how
# this page says "this has a consequence you cannot see from here". Allowing a
# port has exactly one consequence, and it is the sentence the user just read on
# the button. Spending a confirm on it teaches people to click through the ones
# that mean something.
#
# So: additions go straight through, removals and zone changes do not.
grep -q 'Firewall.addService\|addService(' <<<"$page_code" \
|| fail 'the page cannot allow a named service, so the firewall is still read-only from here'
grep -q 'Firewall.addPort\|addPort(' <<<"$page_code" \
|| fail 'the page cannot allow a port'
grep -qE 'confirming(Add|Allow|Service|Port)\b' <<<"$page_code" \
&& fail 'allowing something asks for a confirmation; that ceremony belongs to the actions that cut people off'
# What it must say instead, because both facts are invisible from the button:
# the rule outlives a reboot, and firewalld will raise a polkit prompt.
grep -qi 'permanent' <<<"$page_code" \
|| fail 'the add flow never says the rule is permanent'
grep -qi 'ask for your password' <<<"$page_code" \
|| fail 'the add flow never warns that the system will ask for a password'
# A zone change IS consequence-bearing, and its consequence is specific: it
# changes which rules apply to one named interface, and every other interface
# keeps the zone it had. A confirm that says "change zone?" tells the user
# nothing they did not already know, so this pins that the interface is named.
grep -q 'setZone' <<<"$page_code" \
|| fail 'the page cannot change a connection zone'
grep -q 'setDefaultZone' <<<"$page_code" \
|| fail 'the page cannot change the default zone'
grep -qE '(confirming|pending)(Zone|Interface)' <<<"$page_code" \
|| fail 'a connection zone can be changed without confirming, and it decides which rules apply to that link'
python3 - "$page" <<'PY' || fail 'the zone-change confirmation does not name the interface it applies to'
import re
import sys
lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
anchors = [i for i, line in enumerate(lines)
if re.search(r"(confirming|pending)(Zone|Interface)", line)]
if not anchors:
raise SystemExit(1)
# Any one of the two-stage anchors may be the one carrying the prose; the
# declaration of the state is usually not.
for anchor in anchors:
window = "\n".join(lines[max(0, anchor - 20):anchor + 60])
named = re.search(r"(interface|iface)", window, re.I)
interpolated = re.search(r"\$\{|\" \+ |\+ \"", window)
if named and interpolated:
raise SystemExit(0)
raise SystemExit(1)
PY
# ── 7. The zone browser reads and does not write ────────────────────────────
#
# `zone-info` exists so somebody can look at what a zone would do before moving
# an interface into it. A read that can write is not a browser, it is a foot-gun
# with a magnifying glass on it.
grep -q 'zone-info' "$helper" \
|| fail 'the helper cannot describe a zone, so the zone browser has nothing to show'
grep -q 'zoneInfo' "$service" \
|| fail 'the service does not expose zone descriptions'
python3 - "$helper" <<'PY' || fail 'the zone-info path can change the firewall'
import re
import sys
source = open(sys.argv[1], encoding="utf-8").read()
match = re.search(r"\ndef zone_info\b.*?(?=\ndef |\Z)", source, re.S)
if not match:
raise SystemExit(1)
body = match.group(0)
# --info-zone and --list-* are reads. Anything that adds, removes, changes or
# makes permanent is not.
if re.search(r"--(add|remove|change|set|permanent|reload)", body):
print(body[:400], file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
command -v jq >/dev/null 2>&1 || { printf 'firewall contract: SKIP (no jq)\n'; exit 0; }
state="$("$helper" snapshot 2>/dev/null)" || fail 'snapshot failed'
@@ -142,8 +222,25 @@ if [[ "$(jq -r '.available' <<<"$state")" == "true" ]]; then
|| fail 'a loopback-only listener is reported as reachable'
fi
# ── zone-info describes a zone and leaves it exactly as it found it ─────────
if [[ "$(jq -r '.available' <<<"$state")" == "true" ]]; then
zone_name="$(jq -r '.zones[0].name // ""' <<<"$state")"
if [[ -n "$zone_name" ]]; then
info="$("$helper" zone-info "$zone_name" 2>/dev/null)" \
|| fail "zone-info failed for the zone this machine is actually in ($zone_name)"
jq -e '(.services | type == "array") and (.ports | type == "array") and has("summary")' \
<<<"$info" >/dev/null \
|| fail "zone-info does not describe services, ports and a summary: $info"
after="$("$helper" snapshot 2>/dev/null)" || fail 'the snapshot after zone-info failed'
[[ "$(jq -cS '.zones' <<<"$after")" == "$(jq -cS '.zones' <<<"$state")" ]] \
|| fail 'reading a zone changed the firewall, which is the one thing a browser must not do'
fi
fi
# ── Refusals ────────────────────────────────────────────────────────────────
refusal() { "$helper" "$@" 2>/dev/null | jq -r '.error // ""'; }
[[ -n "$(refusal zone-info 'public; reboot')" ]] || fail 'a bad zone name was accepted by zone-info'
[[ -n "$(refusal zone-info '')" ]] || fail 'an empty zone name was accepted by zone-info'
for bad in "ssh; rm -rf /" "../escape" "" "UPPER CASE"; do
[[ -n "$(refusal add-service "$bad")" ]] || fail "a bad service name was accepted: $bad"
done