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
@@ -0,0 +1,267 @@
// The firewall, led by what another machine can actually reach.
//
// A rules list alone is not an answer: 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 while
// a database and a cache sit open, because Fedora Workstation's zone opens
// every port above 1024 and rootless containers publish on all interfaces.
//
// Rich rules are shown but never edited. They are a syntax rather than a
// setting, and a page that half-supports a syntax is a trap -- but hiding them
// would mean the page misrepresents the configuration.
import Quickshell
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
objectName: "firewall"
title: "Firewall"
lede: "What another machine on your network can reach, and what allows it."
property string confirmingRemoval: ""
property bool confirmingRange: false
Component.onCompleted: Firewall.refresh()
TextRow {
visible: Firewall.lastError !== ""
label: "The firewall needs attention"
detail: Firewall.lastError
value: ""
divider: false
}
// ── The finding, when there is one ───────────────────────────────────────
SettingsCard {
visible: Firewall.exposedDataStores.length > 0
title: Firewall.exposedDataStores.length === 1
? "A database is reachable from your network"
: "Databases are reachable from your network"
subtitle: {
const names = Firewall.exposedDataStores.map(entry => String(entry.name));
return names.join(" and ") + " "
+ (names.length === 1 ? "is" : "are")
+ " listening on every interface, and this zone permits it. Anyone on your network can connect.";
}
Repeater {
model: Firewall.exposedDataStores
delegate: TextRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.name ?? "")
detail: "Port " + modelData.port + "/" + String(modelData.protocol ?? "")
+ (String(modelData.process ?? "") !== ""
? " · " + String(modelData.process) : "")
+ " · allowed by " + String(modelData.allowedBy ?? "")
value: ""
divider: index < Firewall.exposedDataStores.length - 1
}
}
}
// ── Everything reachable ─────────────────────────────────────────────────
SettingsCard {
title: "Reachable right now"
subtitle: !Firewall.scanned
? "Checking what is listening and what the firewall permits…"
: (Firewall.available
? "Listening on a network address, and permitted by the firewall. Both have to be true."
: "The firewall is not running, so nothing here is being filtered.")
Repeater {
model: Firewall.exposed
delegate: TextRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.name ?? "")
detail: "Port " + modelData.port + "/" + String(modelData.protocol ?? "")
+ (String(modelData.process ?? "") !== "" && String(modelData.process) !== String(modelData.name)
? " · " + String(modelData.process) : "")
+ " · allowed by " + String(modelData.allowedBy ?? "")
value: String(modelData.kind ?? "") === "data" ? "Database" : ""
divider: index < Firewall.exposed.length - 1
}
}
TextRow {
visible: Firewall.exposed.length === 0 && Firewall.scanned && Firewall.available
label: "Nothing is reachable"
detail: "No service is both listening on a network address and permitted"
value: ""
divider: false
}
}
// ── The rules that allow it ──────────────────────────────────────────────
SettingsCard {
visible: Firewall.zone !== null
title: "What this zone allows"
subtitle: Firewall.zone
? String(Firewall.zone.name) + ", applied to "
+ (Firewall.zone.interfaces ?? []).join(" and ")
: ""
// The single rule that explains almost every row above.
Column {
width: parent.width
visible: Firewall.wideOpen
SettingRow {
width: parent.width
label: "Ports " + Firewall.openRanges.join(", ")
detail: root.confirmingRange
? "Closing this cuts off " + Firewall.rangeDependents().length
+ " reachable service" + (Firewall.rangeDependents().length === 1 ? "" : "s")
+ ", including " + Firewall.rangeDependents().slice(0, 3)
.map(entry => String(entry.name)).join(", ")
+ ". Anything that needs a port will have to be allowed by name."
: "Fedora Workstation opens these so applications can listen without asking. It is why most of the list above is reachable."
controlWidth: 230
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: root.confirmingRange ? "Keep it open" : "Close the range…"
enabled: !Firewall.busy
onClicked: root.confirmingRange = !root.confirmingRange
}
SettingsButton {
visible: root.confirmingRange
text: "Close it"
tone: "danger"
enabled: !Firewall.busy
onClicked: {
root.confirmingRange = false;
for (const spec of Firewall.openRanges)
Firewall.removePort(String(spec));
}
}
}
}
}
Repeater {
model: Firewall.zone?.services ?? []
delegate: SettingRow {
id: serviceRow
required property var modelData
required property int index
readonly property string serviceName: String(serviceRow.modelData)
readonly property bool confirming: root.confirmingRemoval === serviceRow.serviceName
// Removing ssh while someone is connected over it ends their
// session. Worth saying before, not after.
readonly property bool risky: serviceRow.serviceName === "ssh"
&& Firewall.sshSessions > 0
width: parent.width
label: serviceRow.serviceName
detail: serviceRow.confirming
? (serviceRow.risky
? "Someone is connected over SSH right now. Removing this ends that session."
: "Anything relying on this service stops being reachable.")
: "Allowed by name, so it works whatever the port range says"
controlWidth: 210
divider: serviceRow.index < (Firewall.zone?.services ?? []).length - 1
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: serviceRow.confirming ? "Keep" : "Remove…"
enabled: !Firewall.busy
onClicked: root.confirmingRemoval =
serviceRow.confirming ? "" : serviceRow.serviceName
}
SettingsButton {
visible: serviceRow.confirming
text: "Remove"
tone: "danger"
enabled: !Firewall.busy
onClicked: {
root.confirmingRemoval = "";
Firewall.removeService(serviceRow.serviceName);
}
}
}
}
}
// Shown, never edited.
TextRow {
visible: (Firewall.zone?.richRules ?? []).length > 0
label: "Rich rules"
detail: "Custom rules in firewalld's own syntax. Shown here so this page does not misrepresent your configuration; edit them with firewall-cmd."
value: (Firewall.zone?.richRules ?? []).length + " defined"
divider: false
}
}
// ── Zones ────────────────────────────────────────────────────────────────
SettingsCard {
title: "Zones"
subtitle: "A zone is a set of rules. Each network connection uses one."
Repeater {
model: Object.keys(Firewall.activeZones ?? ({}))
delegate: TextRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData)
detail: "Applied to " + (Firewall.activeZones[String(modelData)] ?? []).join(", ")
value: String(modelData) === Firewall.defaultZone ? "Default" : ""
divider: true
}
}
TextRow {
label: "Default for new connections"
detail: "Used when a network does not ask for a particular zone"
value: Firewall.defaultZone
divider: false
}
}
// ── The service underneath ───────────────────────────────────────────────
SettingsCard {
title: "Firewall service"
TextRow {
label: "firewalld"
detail: !Firewall.scanned
? "Reading the firewall's state…"
: (Firewall.running
? (Firewall.enabledAtBoot
? "Running, and starts with the system"
: "Running, but not started at boot")
: "Not running, so nothing is being filtered")
value: !Firewall.scanned ? "Checking…" : (Firewall.running ? "Running" : "Stopped")
divider: false
}
}
}
@@ -126,6 +126,7 @@ Rectangle {
case "updates": return updatesPage;
case "users": return usersPage;
case "sharing": return sharingPage;
case "firewall": return firewallPage;
case "printers": return printersPage;
case "services": return healthPage;
case "about": return aboutPage;
@@ -170,6 +171,7 @@ Rectangle {
Component { id: updatesPage; UpdatesPage {} }
Component { id: usersPage; UsersPage {} }
Component { id: sharingPage; SharingPage {} }
Component { id: firewallPage; FirewallPage {} }
Component { id: printersPage; PrintersPage {} }
Component { id: accessibilityPage; AccessibilityPage {} }
Component { id: powerPage; PowerPage {} }
@@ -27,6 +27,7 @@ Rectangle {
{ page: "displays", label: "Displays", icon: "\u{F0379}" },
{ page: "connectivity", label: "Network & Devices", icon: "\u{F08D4}" },
{ page: "sharing", label: "Sharing", icon: "\u{F04E6}" },
{ page: "firewall", label: "Firewall", icon: "\u{F0483}" },
{ page: "printers", label: "Printers", icon: "\u{F042A}" },
{ page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}" },
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" },
@@ -3,6 +3,7 @@ AboutPage 1.0 AboutPage.qml
AppearancePage 1.0 AppearancePage.qml
AvatarPicker 1.0 AvatarPicker.qml
ConnectivityPage 1.0 ConnectivityPage.qml
FirewallPage 1.0 FirewallPage.qml
GamingPage 1.0 GamingPage.qml
HomePhonePage 1.0 HomePhonePage.qml
HomeFavoriteCard 1.0 HomeFavoriteCard.qml