221 lines
8.5 KiB
QML
221 lines
8.5 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]); }
|
|
|
|
// 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()
|
|
}
|
|
}
|