// 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 // 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 default is the same change with a wider blast radius: it decides for // every connection that does not ask for a zone by name. It used to apply // on the pick, one dropdown below a picker that made you confirm. property string pendingDefaultZone: "" // Two armed changes on screen at once is how the wrong one gets pressed. function armInterfaceMove(iface: string, zoneName: string): void { root.pendingDefaultZone = ""; root.pendingInterface = iface; root.pendingZone = zoneName; } function armDefaultZone(zoneName: string): void { root.pendingInterface = ""; root.pendingZone = ""; root.pendingDefaultZone = zoneName; } // 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 { 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 // Every range in one call. The label says "the range", // and a range is a tcp rule and a udp rule. onClicked: { root.confirmingRange = false; Firewall.removePorts(Firewall.openRanges); } } } } } 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 // The add row always follows, so the last service still needs // its hairline. divider: true 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); } } } } } // ── 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 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, and moving a connection between zones changes what the machines on that network can reach." Repeater { model: root.zonedInterfaces delegate: Column { id: placement 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 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.armInterfaceMove(placement.iface, 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" enabled: !Firewall.busy options: root.zoneOptions current: Firewall.defaultZone divider: root.pendingDefaultZone === "" onPicked: value => root.armDefaultZone(String(value)) } SettingRow { width: parent.width visible: root.pendingDefaultZone !== "" label: "Make " + root.pendingDefaultZone + " the default?" // What moves is named the way the per-interface confirm names its // interface: not "the default changes", but which machines end up // deciding differently because of it. detail: "Every connection firewalld has not placed in a zone of its " + "own follows the default — each one leaves " + Firewall.defaultZone + " for " + root.pendingDefaultZone + ", and so does every network joined from now on." controlWidth: 240 Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 8 SettingsButton { text: "Keep " + Firewall.defaultZone enabled: !Firewall.busy onClicked: root.pendingDefaultZone = "" } SettingsButton { text: "Change it" tone: "danger" enabled: !Firewall.busy onClicked: { const target = root.pendingDefaultZone; root.pendingDefaultZone = ""; Firewall.setDefaultZone(target); } } } } // ── 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 } } // ── 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 } } }