Files
Panama/config/dot/quickshell/services/Firewall.qml
T

207 lines
7.8 KiB
QML

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]); }
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()
}
}