Add a Firewall page, led by what is actually reachable
Listing zones and services is what firewall-cmd already does. The question it does not answer needs both halves at once: a port is reachable only when something is LISTENING on a network address AND the firewall permits it. On this machine that crossing is the whole story. The rules look unremarkable -- one zone, three services, a port range -- and what they mean is that PostgreSQL and Redis, published by rootless containers on every interface, are reachable by anyone on the network. Neither half says that alone, which is exactly how a tidy rules list coexists with an open database. Nothing was misconfigured: Fedora's default zone met podman's default publish behaviour. Ephemeral client sockets are excluded. A browser's outbound UDP port is indistinguishable from a service in ss, and listing twenty of them buried the two rows that mattered. Closing the port range names what it would cut off, by service, before doing it, and removing ssh says so when someone is connected over it. Rich rules are shown and never edited: a syntax is not a setting, but hiding it would misrepresent the configuration. The contract needed a recorded firewall, and the reason is worth keeping. The rule this page exists for cannot be tested against this machine -- its zone permits everything above 1024, so "listening" and "listening and permitted" give identical answers, and a blocked listener needs a port below 1024, which needs root. With the crossing deleted, the contract passed. It now runs against a fixture where two listeners are blocked, and catches it. Also here: polkit response files are written 0600 rather than at the default mask, the agent sweeps requests left by an instance that did not exit cleanly, and the write sweep waits for its harness to be ready instead of reporting the startup race as settings that failed. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
Executable
+158
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The firewall page answers "what can another machine reach?", and that answer
|
||||
# needs both halves at once.
|
||||
#
|
||||
# A port is reachable only when something is LISTENING on a network address AND
|
||||
# the firewall permits it. Either half alone is not exposure -- which is exactly
|
||||
# how a tidy rules list coexists with an open database, as it does on this
|
||||
# machine.
|
||||
#
|
||||
# The rules:
|
||||
#
|
||||
# 1. Exposure is the crossing, not either half. A listener the firewall blocks
|
||||
# is not exposed, and an allowed port nothing listens on is not either.
|
||||
# 2. Ephemeral client sockets are not services. A browser's outbound UDP port
|
||||
# looks identical to a service in `ss`, and listing twenty of them buries
|
||||
# the two rows that matter.
|
||||
# 3. Nothing destructive happens without saying what it cuts off, by name.
|
||||
# 4. The page never states what it has not checked. It said "firewalld is
|
||||
# stopped" for the seconds before its first read returned.
|
||||
# 5. Rich rules are shown and never edited: a syntax is not a setting, but
|
||||
# hiding it would misrepresent the configuration.
|
||||
#
|
||||
# Read-only. It never changes a firewall rule.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-firewall"
|
||||
service="$repo_dir/config/dot/quickshell/services/Firewall.qml"
|
||||
page="$repo_dir/config/dot/quickshell/modules/settings/FirewallPage.qml"
|
||||
|
||||
fail() {
|
||||
printf 'firewall contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for path in "$helper" "$service" "$page"; do
|
||||
[[ -r "$path" ]] || fail "missing $path"
|
||||
done
|
||||
[[ -x "$helper" ]] || fail 'panama-firewall is not executable'
|
||||
|
||||
# ── 1. Exposure is the crossing ─────────────────────────────────────────────
|
||||
grep -q 'def listeners' "$helper" || fail 'nothing enumerates what is listening'
|
||||
grep -q 'def allowed_ports' "$helper" || fail 'nothing enumerates what the firewall permits'
|
||||
# Checked from the DATA, not from the source. An earlier version grepped for
|
||||
# the guard line and passed with it deleted, because the same words appear on an
|
||||
# unrelated line a few lines below -- so the check was matching itself into a
|
||||
# false pass while every listener was being reported as exposed.
|
||||
|
||||
# The crossing itself, against a recorded firewall. It cannot be tested against
|
||||
# this machine: its zone permits every port above 1024, so "listening" and
|
||||
# "listening AND permitted" produce identical answers, and the blocked case
|
||||
# needs a listener below port 1024, which needs root to create.
|
||||
work="$(mktemp -d /tmp/panama-firewall.XXXXXX)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
cat >"$work/fixture.json" <<'FIXTURE'
|
||||
{
|
||||
"zones": [{"name": "test", "interfaces": ["eth0"], "services": ["ssh"],
|
||||
"ports": ["8000-8999/tcp"], "richRules": [], "target": "default"}],
|
||||
"servicePorts": {"ssh": ["22/tcp"]},
|
||||
"listeners": [
|
||||
{"port": 22, "protocol": "tcp", "process": "sshd"},
|
||||
{"port": 8080, "protocol": "tcp", "process": "webserver"},
|
||||
{"port": 5432, "protocol": "tcp", "process": "postgres"},
|
||||
{"port": 631, "protocol": "tcp", "process": "cupsd"}
|
||||
]
|
||||
}
|
||||
FIXTURE
|
||||
|
||||
crossed="$(PANAMA_FIREWALL_FIXTURE="$work/fixture.json" "$helper" snapshot 2>/dev/null)" \
|
||||
|| fail 'the recorded firewall could not be read'
|
||||
reachable="$(jq -r '[.exposed[].port] | sort | join(",")' <<<"$crossed")"
|
||||
|
||||
# 22 is allowed by the ssh service; 8080 falls in the open range. 5432 and 631
|
||||
# are listening and NOT permitted, so they are not exposure.
|
||||
[[ "$reachable" == "22,8080" ]] \
|
||||
|| fail "the crossing is wrong: reachable ports were [$reachable], expected [22,8080] -- 5432 and 631 are listening but not permitted"
|
||||
|
||||
jq -e '[.exposed[] | select(.port == 22) | .allowedBy] | .[0] == "the ssh service"' <<<"$crossed" >/dev/null \
|
||||
|| fail 'a port allowed by a named service is not attributed to that service'
|
||||
jq -e '[.exposed[] | select(.port == 8080) | .allowedBy] | .[0] == "the open port range"' <<<"$crossed" >/dev/null \
|
||||
|| fail 'a port allowed by a range is not attributed to the range'
|
||||
|
||||
# ── 2. Ephemeral sockets are excluded ───────────────────────────────────────
|
||||
grep -q 'EPHEMERAL_FLOOR' "$helper" \
|
||||
|| fail 'ephemeral client sockets are not distinguished from services'
|
||||
|
||||
# ── 3. Destructive actions name their consequences ──────────────────────────
|
||||
page_code="$(grep -vE '^\s*//' "$page")"
|
||||
grep -q 'rangeDependents' "$service" \
|
||||
|| fail 'nothing computes what closing the port range would cut off'
|
||||
grep -q 'Closing this cuts off' <<<"$page_code" \
|
||||
|| fail 'closing the port range does not say what it cuts off'
|
||||
grep -q 'confirmingRange' <<<"$page_code" \
|
||||
|| fail 'the port range can be closed without confirming'
|
||||
grep -q 'confirmingRemoval' <<<"$page_code" \
|
||||
|| fail 'a service can be removed without confirming'
|
||||
# Removing ssh while someone is connected over it ends their session.
|
||||
grep -q 'sshSessions' "$service" || fail 'the service does not know about live SSH sessions'
|
||||
grep -q 'connected over SSH right now' <<<"$page_code" \
|
||||
|| fail 'removing ssh does not warn when someone is connected over it'
|
||||
|
||||
# ── 4. The page does not answer before it has looked ────────────────────────
|
||||
grep -q 'Firewall.scanned' <<<"$page_code" \
|
||||
|| fail 'the page reports firewall state before its first read has returned'
|
||||
grep -qE 'Checking' <<<"$page_code" \
|
||||
|| fail 'there is no state for "not read yet", so it must be claiming one of the answers'
|
||||
|
||||
# ── 5. Rich rules are shown, not edited ─────────────────────────────────────
|
||||
grep -q 'richRules' "$helper" || fail 'rich rules are not read, so the page would hide them'
|
||||
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'
|
||||
|
||||
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'
|
||||
jq -e '(.exposed | type == "array") and (.zones | type == "array")' <<<"$state" >/dev/null \
|
||||
|| fail 'the snapshot is missing exposure or zones'
|
||||
|
||||
if [[ "$(jq -r '.available' <<<"$state")" == "true" ]]; then
|
||||
# Everything reported as exposed must name a rule THIS ZONE ACTUALLY HAS.
|
||||
# A permissive stand-in like "assumed" satisfies "non-empty" while meaning
|
||||
# the crossing was never performed, so the reason is matched against the
|
||||
# zone's real services and port ranges.
|
||||
allowed_reasons="$(jq -r '
|
||||
(.zones[0].services // [] | map("the \(.) service"))
|
||||
+ (if ((.zones[0].ports // []) | length) > 0 then ["the open port range"] else [] end)
|
||||
| .[]' <<<"$state" | sort -u)"
|
||||
[[ -n "$allowed_reasons" ]] || fail 'the zone reports no services and no ports, so nothing could be permitted'
|
||||
while read -r reason; do
|
||||
[[ -n "$reason" ]] || continue
|
||||
grep -qxF "$reason" <<<"$allowed_reasons" \
|
||||
|| fail "something is reported as reachable via \"$reason\", which is not a rule this zone has"
|
||||
done < <(jq -r '.exposed[].allowedBy' <<<"$state" | sort -u)
|
||||
# And must be a real port.
|
||||
jq -e '[.exposed[] | (.port > 0 and .port < 65536)] | all' <<<"$state" >/dev/null \
|
||||
|| fail 'an exposed entry has no valid port'
|
||||
# Loopback-only listeners are not exposure and must never appear.
|
||||
jq -e '[.exposed[] | select(.name == "loopback")] | length == 0' <<<"$state" >/dev/null \
|
||||
|| fail 'a loopback-only listener is reported as reachable'
|
||||
fi
|
||||
|
||||
# ── Refusals ────────────────────────────────────────────────────────────────
|
||||
refusal() { "$helper" "$@" 2>/dev/null | jq -r '.error // ""'; }
|
||||
for bad in "ssh; rm -rf /" "../escape" "" "UPPER CASE"; do
|
||||
[[ -n "$(refusal add-service "$bad")" ]] || fail "a bad service name was accepted: $bad"
|
||||
done
|
||||
for bad in "22" "22/sctp" "70000/tcp" "abc/tcp"; do
|
||||
[[ -n "$(refusal add-port "$bad")" ]] || fail "a bad port specification was accepted: $bad"
|
||||
done
|
||||
[[ -n "$(refusal set-zone 'eth0; reboot' public)" ]] || fail 'a bad interface name was accepted'
|
||||
[[ -n "$(refusal bogus)" ]] || fail 'an unknown command was accepted'
|
||||
|
||||
printf 'firewall contract: PASS (%s reachable, %s of them data stores)\n' \
|
||||
"$(jq '.exposed | length' <<<"$state")" \
|
||||
"$(jq '.exposedDataStores | length' <<<"$state")"
|
||||
Reference in New Issue
Block a user