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)); } // zone name -> the helper's zone-info shape, for the zone browser. Cached // because browsing means asking about the same handful of zones as a chip // row repaints, and each answer is two firewall-cmd calls. property var zoneDetails: ({}) property var pendingZones: [] function refresh(): void { if (query.running) return; query.command = [root.helperPath, "snapshot"]; query.running = true; } // What a zone allows, for reading before choosing one. Returns the cached // answer, or null while the first one is on its way -- and asks for it, so // a chip that binds to this fills itself in. // // Read-only, so it needs no authorization and never prompts: this is the // difference between looking at a zone and moving an interface into it. function zoneInfo(zoneName: string): var { if (zoneName === "") return null; if (root.zoneDetails[zoneName] !== undefined) return root.zoneDetails[zoneName]; root.requestZoneInfo(zoneName); return null; } function requestZoneInfo(zoneName: string): void { if (zoneName === "" || root.pendingZones.indexOf(zoneName) >= 0) return; root.pendingZones = root.pendingZones.concat([zoneName]); root.drainZones(); } function drainZones(): void { if (zoneQuery.running || root.pendingZones.length === 0) return; zoneQuery.subject = root.pendingZones[0]; zoneQuery.command = [root.helperPath, "zone-info", zoneQuery.subject]; zoneQuery.running = true; } function absorbZone(zoneName: string, text: string): void { try { const parsed = JSON.parse(text); // Reassigned rather than mutated: QML does not notice a property // change made inside a var object. const next = Object.assign({}, root.zoneDetails); next[zoneName] = parsed; root.zoneDetails = next; if (String(parsed.error ?? "") !== "") root.lastError = String(parsed.error); } catch (error) { root.lastError = "Could not read what that zone allows."; console.warn("Firewall: could not parse zone-info output:", error); } } 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 = ""; // Every mutation here can change what a zone allows or which zone an // interface is in, so the browser's cached descriptions are dropped // rather than left to describe the firewall as it used to be. root.zoneDetails = ({}); 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]); } // One press, one change. The open range a page shows as a single rule is // usually two rules underneath -- Fedora's zone opens the high ports for // tcp and for udp -- and a loop of removePort() calls would drop every // iteration after the first on run()'s `mutation.running` guard, leaving // half the range open under a confirmation that promised all of it. The // helper takes the whole list, so it is one firewall-cmd invocation, one // password prompt, and one snapshot afterwards. function removePorts(specs: var): void { const list = (specs ?? []).map(spec => String(spec)).filter(spec => spec !== ""); if (list.length === 0) return; root.run(["remove-port"].concat(list)); } 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() } } // Kept out of `busy` on purpose: reading what a zone allows changes nothing, // so it must not disable the buttons that do. Process { id: zoneQuery property string subject: "" stdout: StdioCollector { onStreamFinished: root.absorbZone(zoneQuery.subject, this.text) } stderr: StdioCollector { onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() } onExited: { root.pendingZones = root.pendingZones.filter(name => name !== zoneQuery.subject); zoneDrain.restart(); } } // One tick later, because `running` has not gone false inside onExited and // the queue would stall on its own guard. Timer { id: zoneDrain interval: 0 onTriggered: root.drainZones() } }