#!/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")"