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
@@ -25,6 +25,96 @@ SettingsPage {
property string confirmingRemoval: ""
property bool confirmingRange: false
// The add flow. Additions are not destructive -- they open something rather
// than closing it -- so there is no confirm here, only the caption saying
// the rule is permanent and will prompt.
property bool addOpen: false
property string addKind: "service"
property string addValue: ""
// Changing a connection's zone IS consequence-bearing: it silently rewrites
// what every machine on that network can reach. Two-stage, and the confirm
// names the interface it is about to move.
property string pendingInterface: ""
property string pendingZone: ""
// The zone being read in the browser. Read-only: this looks at a zone
// without applying it to anything.
property string browsingZone: ""
readonly property bool addValid: root.addKind === "service"
? /^[a-z0-9][a-z0-9-]*$/.test(root.addValue.trim())
: /^[0-9]{1,5}(-[0-9]{1,5})?\/(tcp|udp)$/.test(root.addValue.trim())
// [{ iface, zone }] -- one row per network interface the firewall has
// actually placed in a zone, flattened out of activeZones.
readonly property var zonedInterfaces: {
const placed = Firewall.activeZones ?? ({});
const rows = [];
for (const zoneName of Object.keys(placed)) {
for (const iface of (placed[zoneName] ?? []))
rows.push({ iface: String(iface), zone: String(zoneName) });
}
rows.sort((a, b) => a.iface.localeCompare(b.iface));
return rows;
}
readonly property var zoneOptions: (Firewall.allZones ?? []).map(zoneName => ({
value: String(zoneName),
label: String(zoneName),
detail: String(zoneName) === Firewall.defaultZone
? "The default for new connections" : ""
}))
// What an interface is carrying, said the way a person names it: the Wi-Fi
// network or the wired profile, falling back to the kernel's name for it.
function connectionOn(iface: string): string {
if (Connectivity.wifiDevice && Connectivity.wifiDevice.name === iface
&& Connectivity.activeNetwork)
return Connectivity.activeNetwork.name;
if (Connectivity.wiredDevice && Connectivity.wiredDevice.name === iface
&& Connectivity.wiredDevice.network)
return String(Connectivity.wiredDevice.network.name ?? iface);
return iface;
}
// The rules by name, then what the zone does with everything else. The
// helper's `summary` counts them; the names are what someone comparing two
// zones actually needs, so the names are listed and the helper's own
// sentence about the target is kept for the tail.
function describeZone(zoneName: string): string {
const info = Firewall.zoneInfo(zoneName);
if (!info)
return "Reading what " + zoneName + " allows…";
const services = (info.services ?? []).map(entry => String(entry));
const ports = (info.ports ?? []).map(entry => String(entry));
const parts = [];
if (services.length > 0)
parts.push(services.join(", "));
if (ports.length > 0)
parts.push("ports " + ports.join(", "));
let text = parts.length === 0
? zoneName + " allows nothing in."
: zoneName + " allows: " + parts.join(", and ") + ".";
// "3 services, 1 port rule; anything no rule allows is rejected" --
// everything after the semicolon is the helper's phrasing for the
// zone's target, which the list above does not say.
const summary = String(info.summary ?? "");
const cut = summary.indexOf("; ");
if (cut >= 0)
text += " Otherwise, " + summary.slice(cut + 2) + ".";
const rich = (info.richRules ?? []).length;
if (rich > 0)
text += " It also carries " + rich + " rich rule"
+ (rich === 1 ? "" : "s") + ", which this page never edits.";
return text;
}
Component.onCompleted: Firewall.refresh()
TextRow {
@@ -180,7 +270,9 @@ SettingsPage {
: "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
// The add row always follows, so the last service still needs
// its hairline.
divider: true
Row {
anchors.right: parent.right
@@ -208,6 +300,80 @@ SettingsPage {
}
}
// ── The add side ─────────────────────────────────────────────────────
//
// Opening a port is not destructive, so it is not confirmed. It is
// permanent and it prompts, and both of those are said before the
// button rather than discovered by the polkit dialog appearing.
ActionRow {
width: parent.width
label: "Allow something new"
detail: "A service firewalld already knows by name, or a port and protocol"
action: root.addOpen ? "Cancel" : "Add…"
enabled: !Firewall.busy
divider: root.addOpen
onTriggered: {
root.addOpen = !root.addOpen;
root.addValue = "";
}
}
Column {
width: parent.width
visible: root.addOpen
SegmentRow {
width: parent.width
label: "What to allow"
detail: "A named service carries its own ports, so it keeps working if they change"
controlWidth: 230
options: [
{ value: "service", label: "Named service" },
{ value: "port", label: "Port" }
]
value: root.addKind
enabled: !Firewall.busy
onSelected: value => {
root.addKind = String(value);
root.addValue = "";
}
}
TextFieldRow {
width: parent.width
label: root.addKind === "service" ? "Service name" : "Port and protocol"
detail: root.addKind === "service"
? "One of firewalld's own service names, in lower case"
: "A port or range, then tcp or udp"
placeholder: root.addKind === "service" ? "syncthing" : "8080/tcp"
text: root.addValue
enabled: !Firewall.busy
onAccepted: value => root.addValue = value
}
ActionRow {
width: parent.width
label: root.addValue.trim() === "" || root.addValid
? "Allow it"
: (root.addKind === "service"
? "That is not a service name firewalld would accept"
: "That is not a port firewalld would accept")
detail: "This writes a permanent rule — the system will ask for your password."
action: "Allow"
enabled: root.addValid && !Firewall.busy
divider: false
onTriggered: {
if (root.addKind === "service")
Firewall.addService(root.addValue.trim());
else
Firewall.addPort(root.addValue.trim());
root.addOpen = false;
root.addValue = "";
}
}
}
// Shown, never edited.
TextRow {
visible: (Firewall.zone?.richRules ?? []).length > 0
@@ -222,26 +388,176 @@ SettingsPage {
SettingsCard {
title: "Zones"
subtitle: "A zone is a set of rules. Each network connection uses one."
subtitle: "A zone is a set of rules. Each network connection uses one, and moving a connection between zones changes what the machines on that network can reach."
Repeater {
model: Object.keys(Firewall.activeZones ?? ({}))
model: root.zonedInterfaces
delegate: Column {
id: placement
delegate: TextRow {
required property var modelData
required property int index
readonly property string iface: String(placement.modelData.iface ?? "")
readonly property string zone: String(placement.modelData.zone ?? "")
readonly property bool pending: root.pendingInterface === placement.iface
width: parent.width
label: String(modelData)
detail: "Applied to " + (Firewall.activeZones[String(modelData)] ?? []).join(", ")
value: String(modelData) === Firewall.defaultZone ? "Default" : ""
divider: true
OptionPickerRow {
width: parent.width
label: root.connectionOn(placement.iface)
detail: root.connectionOn(placement.iface) === placement.iface
? "This interface"
: "On " + placement.iface
enabled: !Firewall.busy
options: root.zoneOptions
current: placement.zone
divider: !placement.pending
onPicked: value => {
root.pendingInterface = placement.iface;
root.pendingZone = String(value);
}
}
SettingRow {
width: parent.width
visible: placement.pending
label: "Move " + placement.iface + " to " + root.pendingZone + "?"
// The interface is named because that is the thing being
// moved, and because "public" on the wrong one is the
// difference between a safe café and an unreachable desk.
detail: placement.iface + " leaves " + placement.zone + " for "
+ root.pendingZone + ". Everything reachable over "
+ placement.iface + " is decided by " + root.pendingZone
+ " from then on."
controlWidth: 210
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Keep " + placement.zone
enabled: !Firewall.busy
onClicked: {
root.pendingInterface = "";
root.pendingZone = "";
}
}
SettingsButton {
text: "Move it"
tone: "danger"
enabled: !Firewall.busy
onClicked: {
const target = root.pendingZone;
root.pendingInterface = "";
root.pendingZone = "";
Firewall.setZone(placement.iface, target);
}
}
}
}
}
}
TextRow {
width: parent.width
visible: Firewall.scanned && root.zonedInterfaces.length === 0
label: "No connection is in a zone"
detail: !Firewall.available
? "The firewall is not running, so nothing has been placed"
: "firewalld reports no active zones, which usually means no interface is up"
value: ""
}
OptionPickerRow {
width: parent.width
label: "Default for new connections"
detail: "Used when a network does not ask for a particular zone"
value: Firewall.defaultZone
enabled: !Firewall.busy
options: root.zoneOptions
current: Firewall.defaultZone
onPicked: value => Firewall.setDefaultZone(String(value))
}
// ── The zone browser ─────────────────────────────────────────────────
//
// Read-only. Picking a chip here applies nothing: it answers "what would
// this zone do", which is the question you have to answer before the
// dropdowns above are anything but a guess.
TextRow {
width: parent.width
visible: (Firewall.allZones ?? []).length > 0
label: "Browse zones"
detail: "What each of the " + (Firewall.allZones ?? []).length
+ " zones would allow. Nothing here applies anything."
value: ""
divider: false
}
// Full width rather than in the row's trailing slot: fourteen chips wrap
// to several lines, and a row's control area is one line tall.
Flow {
width: parent.width
visible: (Firewall.allZones ?? []).length > 0
spacing: 6
bottomPadding: 12
Repeater {
model: Firewall.allZones ?? []
delegate: Rectangle {
id: chip
required property var modelData
readonly property string zoneName: String(chip.modelData)
readonly property bool current: root.browsingZone === chip.zoneName
width: chipLabel.implicitWidth + 20
height: 26
radius: Theme.pillRadius
color: chip.current
? Theme.alpha(Theme.accent, 0.18)
: Theme.alpha(Theme.fg, chipHover.hovered ? 0.11 : 0.055)
border.width: 1
border.color: chip.current
? Theme.alpha(Theme.accent, 0.45)
: Theme.alpha(Theme.fg, 0.09)
Text {
id: chipLabel
anchors.centerIn: parent
text: chip.zoneName
color: chip.current ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: chip.current ? Font.DemiBold : Font.Normal
}
HoverHandler {
id: chipHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: root.browsingZone = chip.current ? "" : chip.zoneName
}
}
}
}
TextRow {
width: parent.width
visible: root.browsingZone !== ""
label: root.browsingZone
detail: root.describeZone(root.browsingZone)
value: root.browsingZone === Firewall.defaultZone ? "Default" : ""
divider: false
}
}