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:
Gabriel Brown
2026-08-19 19:46:16 -04:00
parent a412e3d894
commit fd99569666
14 changed files with 992 additions and 2 deletions
+123
View File
@@ -0,0 +1,123 @@
pragma Singleton
// The firewall, answered as "what can another machine reach?"
//
// 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.
// Either alone tells you nothing, which is how a tidy set of rules coexists
// with an exposed database.
//
// Changes go through firewall-cmd, which is polkit-aware, so they prompt --
// through Panama's own prompt now.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-firewall"
property bool running: false
property bool enabledAtBoot: false
property bool available: false
property string defaultZone: ""
property var allZones: []
property var activeZones: ({})
property var zones: []
property var exposed: []
property var exposedDataStores: []
property int sshSessions: 0
property bool scanned: false
property string lastError: ""
readonly property bool busy: query.running || mutation.running
readonly property var zone: root.zones.length > 0 ? root.zones[0] : null
// The range Fedora Workstation opens by default, if this zone has it. Named
// separately because it is the single rule that explains almost everything
// on the exposed list.
readonly property var openRanges: (root.zone?.ports ?? []).filter(
spec => String(spec).indexOf("-") > 0)
readonly property bool wideOpen: root.openRanges.length > 0
function serviceCount(): int { return (root.zone?.services ?? []).length; }
function allowedByRange(entry: var): bool {
return String(entry?.allowedBy ?? "").indexOf("range") >= 0;
}
// What closing the open range would cut off, by name, so the consequence is
// stated before it happens rather than discovered afterwards.
function rangeDependents(): var {
return root.exposed.filter(entry => root.allowedByRange(entry));
}
function refresh(): void {
if (query.running)
return;
query.command = [root.helperPath, "snapshot"];
query.running = true;
}
function absorb(text: string): void {
try {
const parsed = JSON.parse(text);
root.running = parsed.running === true;
root.enabledAtBoot = parsed.enabledAtBoot === true;
root.available = parsed.available === true;
root.defaultZone = String(parsed.defaultZone ?? "");
root.allZones = Array.isArray(parsed.allZones) ? parsed.allZones : [];
root.activeZones = parsed.activeZones ?? ({});
root.zones = Array.isArray(parsed.zones) ? parsed.zones : [];
root.exposed = Array.isArray(parsed.exposed) ? parsed.exposed : [];
root.exposedDataStores = Array.isArray(parsed.exposedDataStores)
? parsed.exposedDataStores : [];
root.sshSessions = Number(parsed.sshSessions ?? 0);
root.lastError = String(parsed.error ?? "");
} catch (error) {
root.lastError = "Could not read the firewall's state.";
console.warn("Firewall: could not parse helper output:", error);
}
root.scanned = true;
}
function run(arguments: var): void {
if (mutation.running)
return;
root.lastError = "";
mutation.command = [root.helperPath].concat(arguments);
mutation.running = true;
}
function removeService(name: string): void { root.run(["remove-service", name]); }
function addService(name: string): void { root.run(["add-service", name]); }
function removePort(spec: string): void { root.run(["remove-port", spec]); }
function addPort(spec: string): void { root.run(["add-port", spec]); }
function setZone(interfaceName: string, zoneName: string): void {
root.run(["set-zone", interfaceName, zoneName]);
}
function setDefaultZone(zoneName: string): void { root.run(["set-default-zone", zoneName]); }
Component.onCompleted: root.refresh()
Process {
id: query
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
Process {
id: mutation
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
}
+5 -1
View File
@@ -92,8 +92,12 @@ Singleton {
// everything about the request.
function dismiss(result: string): void {
if (root.requestPath !== "") {
// umask first: everything in this directory concerns one
// authentication attempt, and a file written with the default mask
// would be world-readable in a directory whose whole point is that
// it is not.
answer.command = ["sh", "-c",
"printf '%s' " + JSON.stringify(JSON.stringify({ result: result }))
"umask 077; printf '%s' " + JSON.stringify(JSON.stringify({ result: result }))
+ " > " + JSON.stringify(root.responsePathFor(root.requestPath))];
answer.running = true;
}
@@ -75,6 +75,10 @@ Singleton {
{ label: "Print queue", detail: "What is waiting to print, and cancelling it", page: "printers" },
{ label: "Add a printer", detail: "Find a printer on the network or enter its address", page: "printers" },
{ label: "Default printer", detail: "Where applications print unless told otherwise", page: "printers" },
{ label: "Firewall", detail: "What another machine can reach on this one", page: "firewall" },
{ label: "Open ports", detail: "Which ports the firewall permits", page: "firewall" },
{ label: "Firewall zones", detail: "Which rules apply to each network connection", page: "firewall" },
{ label: "Exposed services", detail: "What is listening and reachable from the network", page: "firewall" },
{ label: "Remote login", detail: "Sign in to this machine over SSH", page: "sharing" },
{ label: "Remote desktop", detail: "See and control this desktop from elsewhere", page: "sharing" },
{ label: "Network name", detail: "The name other machines see", page: "sharing" },
@@ -92,7 +92,7 @@ Singleton {
}
function openSettings(page: string): void {
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "gaming", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "updates", "storage", "snapshots", "users", "sharing", "printers", "services", "about"];
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "gaming", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "updates", "storage", "snapshots", "users", "sharing", "firewall", "printers", "services", "about"];
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
DesktopPreferences.set("lastPage", root.settingsPage);
root.settingsOpen = true;