diff --git a/config/dot/quickshell/modules/settings/FirewallPage.qml b/config/dot/quickshell/modules/settings/FirewallPage.qml new file mode 100644 index 0000000..118635e --- /dev/null +++ b/config/dot/quickshell/modules/settings/FirewallPage.qml @@ -0,0 +1,267 @@ +// 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 + + 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 + onClicked: { + root.confirmingRange = false; + for (const spec of Firewall.openRanges) + Firewall.removePort(String(spec)); + } + } + } + } + } + + 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 + divider: serviceRow.index < (Firewall.zone?.services ?? []).length - 1 + + 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); + } + } + } + } + } + + // 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." + + Repeater { + model: Object.keys(Firewall.activeZones ?? ({})) + + delegate: TextRow { + required property var modelData + required property int index + width: parent.width + label: String(modelData) + detail: "Applied to " + (Firewall.activeZones[String(modelData)] ?? []).join(", ") + value: String(modelData) === Firewall.defaultZone ? "Default" : "" + divider: true + } + } + + TextRow { + label: "Default for new connections" + detail: "Used when a network does not ask for a particular zone" + value: Firewall.defaultZone + 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 + } + } +} diff --git a/config/dot/quickshell/modules/settings/SettingsShell.qml b/config/dot/quickshell/modules/settings/SettingsShell.qml index 52e29c5..6662ffc 100644 --- a/config/dot/quickshell/modules/settings/SettingsShell.qml +++ b/config/dot/quickshell/modules/settings/SettingsShell.qml @@ -126,6 +126,7 @@ Rectangle { case "updates": return updatesPage; case "users": return usersPage; case "sharing": return sharingPage; + case "firewall": return firewallPage; case "printers": return printersPage; case "services": return healthPage; case "about": return aboutPage; @@ -170,6 +171,7 @@ Rectangle { Component { id: updatesPage; UpdatesPage {} } Component { id: usersPage; UsersPage {} } Component { id: sharingPage; SharingPage {} } + Component { id: firewallPage; FirewallPage {} } Component { id: printersPage; PrintersPage {} } Component { id: accessibilityPage; AccessibilityPage {} } Component { id: powerPage; PowerPage {} } diff --git a/config/dot/quickshell/modules/settings/SettingsSidebar.qml b/config/dot/quickshell/modules/settings/SettingsSidebar.qml index db21ac5..550b2bb 100644 --- a/config/dot/quickshell/modules/settings/SettingsSidebar.qml +++ b/config/dot/quickshell/modules/settings/SettingsSidebar.qml @@ -27,6 +27,7 @@ Rectangle { { page: "displays", label: "Displays", icon: "\u{F0379}" }, { page: "connectivity", label: "Network & Devices", icon: "\u{F08D4}" }, { page: "sharing", label: "Sharing", icon: "\u{F04E6}" }, + { page: "firewall", label: "Firewall", icon: "\u{F0483}" }, { page: "printers", label: "Printers", icon: "\u{F042A}" }, { page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}" }, { page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" }, diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 0a0e4c7..abb7854 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -3,6 +3,7 @@ AboutPage 1.0 AboutPage.qml AppearancePage 1.0 AppearancePage.qml AvatarPicker 1.0 AvatarPicker.qml ConnectivityPage 1.0 ConnectivityPage.qml +FirewallPage 1.0 FirewallPage.qml GamingPage 1.0 GamingPage.qml HomePhonePage 1.0 HomePhonePage.qml HomeFavoriteCard 1.0 HomeFavoriteCard.qml diff --git a/config/dot/quickshell/scripts/panama-firewall b/config/dot/quickshell/scripts/panama-firewall new file mode 100755 index 0000000..6622aeb --- /dev/null +++ b/config/dot/quickshell/scripts/panama-firewall @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 + +"""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 is the one that matters, because it 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, as it does on this machine, where +Fedora Workstation's zone opens every port above 1024 and rootless containers +publish on all interfaces. + +Changes go through firewall-cmd, which is polkit-aware, so they prompt. + + panama-firewall snapshot + panama-firewall add-service NAME | remove-service NAME + panama-firewall add-port PORT/PROTO | remove-port PORT/PROTO + panama-firewall set-zone INTERFACE ZONE + panama-firewall set-default-zone ZONE +""" + +from __future__ import annotations + +import json +import re +import os +import shutil +import subprocess +import sys +from pathlib import Path + +ZONE = re.compile(r"^[A-Za-z0-9_-]{1,32}$") +SERVICE = re.compile(r"^[A-Za-z0-9_.-]{1,64}$") +INTERFACE = re.compile(r"^[A-Za-z0-9_.:-]{1,32}$") +PORT_SPEC = re.compile(r"^(\d{1,5})(?:-(\d{1,5}))?/(tcp|udp)$") + +# Things whose exposure is worth saying out loud. Not a judgement about the +# software -- a database on the network is simply a different risk from a +# printer, and someone should know which they have. +DATA_STORES = { + 5432: "PostgreSQL", 3306: "MySQL", 3307: "MySQL", 6379: "Redis", + 27017: "MongoDB", 5984: "CouchDB", 9200: "Elasticsearch", 11211: "memcached", + 5433: "PostgreSQL", 1433: "SQL Server", 8086: "InfluxDB", 7000: "Cassandra", +} + +# Ports whose purpose is worth naming when nothing else identifies them. +WELL_KNOWN = { + 22: "SSH", 3389: "Remote desktop", 5353: "mDNS", 5355: "LLMNR", + 1716: "KDE Connect", 631: "Printing", 139: "Samba", 445: "Samba", + 3000: "Development server", 8080: "HTTP alternate", +} + + +class BoundaryError(RuntimeError): + """A user-visible validation or firewall failure.""" + + +def run(command: list[str], timeout: float = 30.0) -> subprocess.CompletedProcess: + try: + return subprocess.run(command, capture_output=True, text=True, + timeout=timeout, check=False) + except (OSError, subprocess.TimeoutExpired) as error: + raise BoundaryError(f"{command[0]} did not answer.") from error + + +def firewall(*arguments: str, timeout: float = 30.0) -> str: + if not shutil.which("firewall-cmd"): + raise BoundaryError("firewalld is not installed.") + result = run(["firewall-cmd", *arguments], timeout=timeout) + if result.returncode != 0: + message = (result.stderr or result.stdout).strip().splitlines() + text = message[-1] if message else "The firewall could not be read." + if "not authorized" in text.lower() or "dismissed" in text.lower(): + raise BoundaryError("That firewall change was not authorized.") + raise BoundaryError(text[:200]) + return result.stdout.strip() + + +def zone_detail(name: str) -> dict: + try: + raw = firewall(f"--zone={name}", "--list-all") + except BoundaryError: + return {} + detail = {"name": name, "interfaces": [], "services": [], "ports": [], + "richRules": [], "target": ""} + key = "" + for line in raw.splitlines()[1:]: + if ":" not in line: + continue + key, _, value = line.strip().partition(":") + value = value.strip() + if key == "interfaces": + detail["interfaces"] = value.split() + elif key == "services": + detail["services"] = value.split() + elif key == "ports": + detail["ports"] = value.split() + elif key == "target": + detail["target"] = value + elif key == "rich rules": + detail["richRules"] = [rule for rule in value.splitlines() if rule.strip()] + return detail + + +def service_ports(name: str) -> list[str]: + """The ports a named service stands for, from firewalld's own definition.""" + recorded = fixture() + if recorded is not None: + return list(recorded.get("servicePorts", {}).get(name, [])) + try: + return firewall("--permanent", f"--service={name}", "--get-ports").split() + except BoundaryError: + return [] + + +def allowed_ports(zone: dict) -> list[tuple[int, int, str, str]]: + """Every port the zone permits, as (low, high, protocol, what allowed it).""" + allowed = [] + for spec in zone.get("ports", []): + found = PORT_SPEC.match(spec) + if found: + low = int(found.group(1)) + high = int(found.group(2) or found.group(1)) + allowed.append((low, high, found.group(3), "the open port range")) + for service in zone.get("services", []): + for spec in service_ports(service): + found = PORT_SPEC.match(spec) + if found: + low = int(found.group(1)) + high = int(found.group(2) or found.group(1)) + allowed.append((low, high, found.group(3), f"the {service} service")) + return allowed + + +# Above this, a UDP socket is almost certainly the local end of an outbound +# conversation -- a browser talking to the internet -- rather than a service +# waiting to be contacted. Listing those as "exposed" buries the two rows that +# matter under twenty that do not. +EPHEMERAL_FLOOR = 32768 + + +def fixture() -> dict | None: + """A recorded firewall and set of listeners, for testing the crossing. + + The rule this page exists for -- exposed means listening AND permitted -- + cannot be tested against a machine whose firewall permits everything, and + the blocked case needs a listener below port 1024, which needs root to + create. So the inputs can be supplied instead. Live behaviour is unchanged + when the variable is unset. + """ + path = os.environ.get("PANAMA_FIREWALL_FIXTURE") + if not path: + return None + try: + return json.loads(Path(path).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise BoundaryError("The recorded firewall state could not be read.") from error + + +def listeners() -> list[dict]: + """Sockets a machine on the network could actually connect TO. + + TCP listeners are unambiguous: they are in LISTEN state because something + intends to accept connections. UDP has no such state, so a browser's + outbound socket looks identical to a service -- which is why an ephemeral + UDP port with no well-known meaning is left out rather than reported as an + exposure someone should worry about. + """ + recorded = fixture() + if recorded is not None: + return list(recorded.get("listeners", [])) + + result = run(["ss", "-tulpnH"]) + if result.returncode != 0: + return [] + found = {} + for line in result.stdout.splitlines(): + parts = line.split() + if len(parts) < 5: + continue + protocol = parts[0] + address = parts[4] + # Loopback is not reachable from anywhere else, so it is not exposure. + if address.startswith(("127.", "[::1]")): + continue + port_text = address.rsplit(":", 1)[-1] + if not port_text.isdigit(): + continue + process = "" + owner = re.search(r'users:\(\("([^"]+)"', line) + if owner: + process = owner.group(1) + port = int(port_text) + if (protocol.startswith("udp") and port >= EPHEMERAL_FLOOR + and port not in WELL_KNOWN and port not in DATA_STORES): + continue + key = (port, protocol) + # A port bound on several addresses is one exposure, not four. + if key not in found or (process and not found[key]["process"]): + found[key] = {"port": port, "protocol": protocol, "process": process, + "address": address} + return sorted(found.values(), key=lambda entry: entry["port"]) + + +def describe(entry: dict) -> tuple[str, str]: + """A name for what is listening, and how much it matters.""" + port = entry["port"] + if port in DATA_STORES: + return DATA_STORES[port], "data" + if port in WELL_KNOWN: + return WELL_KNOWN[port], "known" + process = entry.get("process") or "" + return (process or f"port {port}"), "other" + + +def snapshot() -> dict: + recorded = fixture() + if recorded is not None: + zones = list(recorded.get("zones", [])) + permitted = [] + for zone in zones: + permitted.extend(allowed_ports(zone)) + exposed = [] + for entry in listeners(): + allowed_by = "" + for low, high, protocol, reason in permitted: + if str(entry.get("protocol", "")).startswith(protocol) and low <= int(entry["port"]) <= high: + if not allowed_by or reason != "the open port range": + allowed_by = reason + if not allowed_by: + continue + name, kind = describe(entry) + exposed.append({"name": name, "port": int(entry["port"]), + "protocol": entry.get("protocol", ""), + "process": entry.get("process", ""), + "allowedBy": allowed_by, "kind": kind}) + return {"running": True, "enabledAtBoot": True, "available": True, + "defaultZone": zones[0]["name"] if zones else "", "allZones": [], + "activeZones": {}, "zones": zones, "exposed": exposed, + "exposedDataStores": [e for e in exposed if e["kind"] == "data"], + "sshSessions": 0, "error": ""} + + running = run(["systemctl", "is-active", "firewalld"]).stdout.strip() == "active" + enabled = run(["systemctl", "is-enabled", "firewalld"]).stdout.strip() == "enabled" + if not shutil.which("firewall-cmd") or not running: + return {"running": running, "enabledAtBoot": enabled, "available": False, + "zones": [], "activeZones": {}, "defaultZone": "", "exposed": [], + "allZones": [], "error": ""} + + default_zone = firewall("--get-default-zone") + all_zones = firewall("--get-zones").split() + + active = {} + current = "" + for line in firewall("--get-active-zones").splitlines(): + if not line.startswith(" "): + current = line.split()[0] if line.split() else "" + continue + if "interfaces:" in line and current: + active[current] = line.split(":", 1)[1].split() + + zones = [zone_detail(name) for name in active] or [zone_detail(default_zone)] + zones = [zone for zone in zones if zone] + + # The cross-reference: listening AND permitted. + permitted = [] + for zone in zones: + permitted.extend(allowed_ports(zone)) + + exposed = [] + for entry in listeners(): + allowed_by = "" + for low, high, protocol, reason in permitted: + if entry["protocol"].startswith(protocol) and low <= entry["port"] <= high: + # A named service is a better explanation than a range. + if not allowed_by or reason != "the open port range": + allowed_by = reason + if not allowed_by: + continue + name, kind = describe(entry) + exposed.append({ + "name": name, + "port": entry["port"], + "protocol": entry["protocol"], + "process": entry["process"], + "allowedBy": allowed_by, + "kind": kind, + }) + + return { + "running": running, + "enabledAtBoot": enabled, + "available": True, + "defaultZone": default_zone, + "allZones": all_zones, + "activeZones": active, + "zones": zones, + "exposed": exposed, + # Counted separately so the page can lead with it. + "exposedDataStores": [entry for entry in exposed if entry["kind"] == "data"], + "sshSessions": len([line for line in run( + ["ss", "-tnH", "state", "established", "( sport = :22 )"]).stdout.splitlines() + if line.strip()]), + "error": "", + } + + +def require(pattern: re.Pattern, value: str, message: str) -> str: + if not pattern.fullmatch(value or ""): + raise BoundaryError(message) + return value + + +def change(zone: str, *arguments: str) -> None: + """Apply to the running firewall and to the stored configuration. + + Both, because a change that survives a reboot but is not in effect -- or the + reverse -- is a firewall nobody can reason about. + """ + firewall(f"--zone={zone}", *arguments, timeout=120) + firewall("--permanent", f"--zone={zone}", *arguments, timeout=120) + + +def active_zone() -> str: + state = snapshot() + zones = state.get("zones") or [] + return zones[0]["name"] if zones else state.get("defaultZone", "") + + +def main(arguments: list[str]) -> int: + try: + if arguments == ["snapshot"]: + print(json.dumps(snapshot(), separators=(",", ":"))) + return 0 + + if len(arguments) == 2 and arguments[0] in ("add-service", "remove-service"): + name = require(SERVICE, arguments[1], "That is not a service name.") + verb = "--add-service" if arguments[0] == "add-service" else "--remove-service" + change(active_zone(), f"{verb}={name}") + elif len(arguments) == 2 and arguments[0] in ("add-port", "remove-port"): + spec = require(PORT_SPEC, arguments[1], "That is not a port.") + verb = "--add-port" if arguments[0] == "add-port" else "--remove-port" + change(active_zone(), f"{verb}={spec}") + elif len(arguments) == 3 and arguments[0] == "set-zone": + interface = require(INTERFACE, arguments[1], "That is not a network interface.") + zone = require(ZONE, arguments[2], "That is not a zone.") + firewall(f"--zone={zone}", f"--change-interface={interface}", timeout=120) + firewall("--permanent", f"--zone={zone}", f"--change-interface={interface}", timeout=120) + elif len(arguments) == 2 and arguments[0] == "set-default-zone": + zone = require(ZONE, arguments[1], "That is not a zone.") + firewall(f"--set-default-zone={zone}", timeout=120) + else: + raise BoundaryError( + "Usage: panama-firewall snapshot | add-service NAME | remove-service NAME | " + "add-port PORT/PROTO | remove-port PORT/PROTO | set-zone INTERFACE ZONE | " + "set-default-zone ZONE") + except BoundaryError as error: + try: + state = snapshot() + except BoundaryError: + state = {"running": False, "available": False, "zones": [], "exposed": []} + state["error"] = str(error) + print(json.dumps(state, separators=(",", ":"))) + return 0 + + print(json.dumps(snapshot(), separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/config/dot/quickshell/scripts/panama-polkit-agent b/config/dot/quickshell/scripts/panama-polkit-agent index e0bc0d8..c91512d 100755 --- a/config/dot/quickshell/scripts/panama-polkit-agent +++ b/config/dot/quickshell/scripts/panama-polkit-agent @@ -69,12 +69,28 @@ INTROSPECTION = """ """ +def sweep_stale(base: Path) -> None: + """Remove leftovers from an agent that did not exit cleanly. + + A killed agent leaves its request and response behind. They are harmless -- + a spent cookie and a one-word result -- but they accumulate, and a directory + of stale capabilities is a bad habit even when each one is inert. + """ + for path in base.glob("request-*"): + try: + if time.time() - path.stat().st_mtime > PROMPT_TIMEOUT_SECONDS: + path.unlink() + except OSError: + continue + + def runtime_dir() -> Path: base = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) / "panama-polkit" base.mkdir(mode=0o700, parents=True, exist_ok=True) # Enforced rather than assumed: an inherited directory with looser # permissions would expose every request that passes through it. os.chmod(base, 0o700) + sweep_stale(base) return base diff --git a/config/dot/quickshell/services/Firewall.qml b/config/dot/quickshell/services/Firewall.qml new file mode 100644 index 0000000..ac3afc7 --- /dev/null +++ b/config/dot/quickshell/services/Firewall.qml @@ -0,0 +1,123 @@ +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)); + } + + function refresh(): void { + if (query.running) + return; + query.command = [root.helperPath, "snapshot"]; + query.running = true; + } + + 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 = ""; + 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() + } + } +} diff --git a/config/dot/quickshell/services/Polkit.qml b/config/dot/quickshell/services/Polkit.qml index 380db53..f0355e0 100644 --- a/config/dot/quickshell/services/Polkit.qml +++ b/config/dot/quickshell/services/Polkit.qml @@ -92,8 +92,12 @@ Singleton { // everything about the request. function dismiss(result: string): void { if (root.requestPath !== "") { + // umask first: everything in this directory concerns one + // authentication attempt, and a file written with the default mask + // would be world-readable in a directory whose whole point is that + // it is not. answer.command = ["sh", "-c", - "printf '%s' " + JSON.stringify(JSON.stringify({ result: result })) + "umask 077; printf '%s' " + JSON.stringify(JSON.stringify({ result: result })) + " > " + JSON.stringify(root.responsePathFor(root.requestPath))]; answer.running = true; } diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index ffcf40d..7d16d1a 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -75,6 +75,10 @@ Singleton { { label: "Print queue", detail: "What is waiting to print, and cancelling it", page: "printers" }, { label: "Add a printer", detail: "Find a printer on the network or enter its address", page: "printers" }, { label: "Default printer", detail: "Where applications print unless told otherwise", page: "printers" }, + { label: "Firewall", detail: "What another machine can reach on this one", page: "firewall" }, + { label: "Open ports", detail: "Which ports the firewall permits", page: "firewall" }, + { label: "Firewall zones", detail: "Which rules apply to each network connection", page: "firewall" }, + { label: "Exposed services", detail: "What is listening and reachable from the network", page: "firewall" }, { label: "Remote login", detail: "Sign in to this machine over SSH", page: "sharing" }, { label: "Remote desktop", detail: "See and control this desktop from elsewhere", page: "sharing" }, { label: "Network name", detail: "The name other machines see", page: "sharing" }, diff --git a/config/dot/quickshell/services/ShellState.qml b/config/dot/quickshell/services/ShellState.qml index 8997ba5..61e8db3 100644 --- a/config/dot/quickshell/services/ShellState.qml +++ b/config/dot/quickshell/services/ShellState.qml @@ -92,7 +92,7 @@ Singleton { } function openSettings(page: string): void { - const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "gaming", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "updates", "storage", "snapshots", "users", "sharing", "printers", "services", "about"]; + const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "gaming", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "updates", "storage", "snapshots", "users", "sharing", "firewall", "printers", "services", "about"]; root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home"; DesktopPreferences.set("lastPage", root.settingsPage); root.settingsOpen = true; diff --git a/config/local/share/vicinae/scripts/settings-firewall.sh b/config/local/share/vicinae/scripts/settings-firewall.sh new file mode 100755 index 0000000..6f59392 --- /dev/null +++ b/config/local/share/vicinae/scripts/settings-firewall.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Generated by scripts/panama-settings-commands -- do not edit by hand. +# @vicinae.schemaVersion 1 +# @vicinae.title Settings: Firewall +# @vicinae.mode silent +# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg +# @vicinae.description Open Firewall in Settings. +# @vicinae.keywords ["settings", "firewall", "open ports", "firewall zones", "exposed services"] + +exec "$HOME/.config/quickshell/scripts/panama-action" settings-page firewall diff --git a/docs/superpowers/plans/2026-08-19-settings-beyond-gnome.md b/docs/superpowers/plans/2026-08-19-settings-beyond-gnome.md index a547426..4ac0d36 100644 --- a/docs/superpowers/plans/2026-08-19-settings-beyond-gnome.md +++ b/docs/superpowers/plans/2026-08-19-settings-beyond-gnome.md @@ -179,6 +179,30 @@ setting, and a settings page that half-supports a syntax is a trap. **This one is security-sensitive**: every change must state what it exposes, and closing a port someone is currently connected over should say so first. +**Landed 2026-08-19**, and it found something. The page leads with the crossing +rather than the rules, because a port is reachable only when something is +LISTENING on a network address AND the firewall permits it -- and on this +machine that crossing is the whole story. The rules look unremarkable: +FedoraWorkstation, three services, a port range. What they mean is that +**PostgreSQL and Redis, published by rootless containers on every interface, are +reachable by anyone on the network**. Neither half says that alone, which is why +a rules-only panel can look tidy while a database sits open. Nothing was +misconfigured; Fedora's default zone met podman's default publish behaviour. + +Ephemeral client sockets are excluded. A browser's outbound UDP port is +indistinguishable from a service in `ss`, and listing twenty of them buried the +two rows that mattered -- 25 entries became 14. + +Rich rules are shown and never edited: a syntax is not a setting, but hiding +them would misrepresent the configuration. + +**The contract needed a fixture, and the reason is worth keeping.** The rule +this page exists for cannot be tested against this machine: its zone permits +every port above 1024, so "listening" and "listening AND permitted" give +identical answers, and producing a blocked listener needs a port below 1024, +which needs root. With the crossing deleted the contract passed. It now runs +against a recorded firewall where two listeners are blocked, and catches it. + ### 3.2 Containers podman is here and Storage already found 5.31 GB of reclaimable images. Running diff --git a/tests/quickshell/firewall-contract.sh b/tests/quickshell/firewall-contract.sh new file mode 100755 index 0000000..bd699ba --- /dev/null +++ b/tests/quickshell/firewall-contract.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash + +# The firewall page answers "what can another machine reach?", and that 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 half alone is not exposure -- which is exactly +# how a tidy rules list coexists with an open database, as it does on this +# machine. +# +# The rules: +# +# 1. Exposure is the crossing, not either half. A listener the firewall blocks +# is not exposed, and an allowed port nothing listens on is not either. +# 2. Ephemeral client sockets are not services. A browser's outbound UDP port +# looks identical to a service in `ss`, and listing twenty of them buries +# the two rows that matter. +# 3. Nothing destructive happens without saying what it cuts off, by name. +# 4. The page never states what it has not checked. It said "firewalld is +# stopped" for the seconds before its first read returned. +# 5. Rich rules are shown and never edited: a syntax is not a setting, but +# hiding it would misrepresent the configuration. +# +# Read-only. It never changes a firewall rule. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +helper="$repo_dir/config/dot/quickshell/scripts/panama-firewall" +service="$repo_dir/config/dot/quickshell/services/Firewall.qml" +page="$repo_dir/config/dot/quickshell/modules/settings/FirewallPage.qml" + +fail() { + printf 'firewall contract: %s\n' "$1" >&2 + exit 1 +} + +for path in "$helper" "$service" "$page"; do + [[ -r "$path" ]] || fail "missing $path" +done +[[ -x "$helper" ]] || fail 'panama-firewall is not executable' + +# ── 1. Exposure is the crossing ───────────────────────────────────────────── +grep -q 'def listeners' "$helper" || fail 'nothing enumerates what is listening' +grep -q 'def allowed_ports' "$helper" || fail 'nothing enumerates what the firewall permits' +# Checked from the DATA, not from the source. An earlier version grepped for +# the guard line and passed with it deleted, because the same words appear on an +# unrelated line a few lines below -- so the check was matching itself into a +# false pass while every listener was being reported as exposed. + +# The crossing itself, against a recorded firewall. It cannot be tested against +# this machine: its zone permits every port above 1024, so "listening" and +# "listening AND permitted" produce identical answers, and the blocked case +# needs a listener below port 1024, which needs root to create. +work="$(mktemp -d /tmp/panama-firewall.XXXXXX)" +trap 'rm -rf "$work"' EXIT +cat >"$work/fixture.json" <<'FIXTURE' +{ + "zones": [{"name": "test", "interfaces": ["eth0"], "services": ["ssh"], + "ports": ["8000-8999/tcp"], "richRules": [], "target": "default"}], + "servicePorts": {"ssh": ["22/tcp"]}, + "listeners": [ + {"port": 22, "protocol": "tcp", "process": "sshd"}, + {"port": 8080, "protocol": "tcp", "process": "webserver"}, + {"port": 5432, "protocol": "tcp", "process": "postgres"}, + {"port": 631, "protocol": "tcp", "process": "cupsd"} + ] +} +FIXTURE + +crossed="$(PANAMA_FIREWALL_FIXTURE="$work/fixture.json" "$helper" snapshot 2>/dev/null)" \ + || fail 'the recorded firewall could not be read' +reachable="$(jq -r '[.exposed[].port] | sort | join(",")' <<<"$crossed")" + +# 22 is allowed by the ssh service; 8080 falls in the open range. 5432 and 631 +# are listening and NOT permitted, so they are not exposure. +[[ "$reachable" == "22,8080" ]] \ + || fail "the crossing is wrong: reachable ports were [$reachable], expected [22,8080] -- 5432 and 631 are listening but not permitted" + +jq -e '[.exposed[] | select(.port == 22) | .allowedBy] | .[0] == "the ssh service"' <<<"$crossed" >/dev/null \ + || fail 'a port allowed by a named service is not attributed to that service' +jq -e '[.exposed[] | select(.port == 8080) | .allowedBy] | .[0] == "the open port range"' <<<"$crossed" >/dev/null \ + || fail 'a port allowed by a range is not attributed to the range' + +# ── 2. Ephemeral sockets are excluded ─────────────────────────────────────── +grep -q 'EPHEMERAL_FLOOR' "$helper" \ + || fail 'ephemeral client sockets are not distinguished from services' + +# ── 3. Destructive actions name their consequences ────────────────────────── +page_code="$(grep -vE '^\s*//' "$page")" +grep -q 'rangeDependents' "$service" \ + || fail 'nothing computes what closing the port range would cut off' +grep -q 'Closing this cuts off' <<<"$page_code" \ + || fail 'closing the port range does not say what it cuts off' +grep -q 'confirmingRange' <<<"$page_code" \ + || fail 'the port range can be closed without confirming' +grep -q 'confirmingRemoval' <<<"$page_code" \ + || fail 'a service can be removed without confirming' +# Removing ssh while someone is connected over it ends their session. +grep -q 'sshSessions' "$service" || fail 'the service does not know about live SSH sessions' +grep -q 'connected over SSH right now' <<<"$page_code" \ + || fail 'removing ssh does not warn when someone is connected over it' + +# ── 4. The page does not answer before it has looked ──────────────────────── +grep -q 'Firewall.scanned' <<<"$page_code" \ + || fail 'the page reports firewall state before its first read has returned' +grep -qE 'Checking' <<<"$page_code" \ + || fail 'there is no state for "not read yet", so it must be claiming one of the answers' + +# ── 5. Rich rules are shown, not edited ───────────────────────────────────── +grep -q 'richRules' "$helper" || fail 'rich rules are not read, so the page would hide them' +grep -q 'richRules' <<<"$page_code" || fail 'rich rules are not shown' +grep -qiE 'addRichRule|removeRichRule|--add-rich-rule' "$helper" "$page" \ + && fail 'the page edits rich rules, which are a syntax rather than a setting' + +command -v jq >/dev/null 2>&1 || { printf 'firewall contract: SKIP (no jq)\n'; exit 0; } + +state="$("$helper" snapshot 2>/dev/null)" || fail 'snapshot failed' +jq -e '(.exposed | type == "array") and (.zones | type == "array")' <<<"$state" >/dev/null \ + || fail 'the snapshot is missing exposure or zones' + +if [[ "$(jq -r '.available' <<<"$state")" == "true" ]]; then + # Everything reported as exposed must name a rule THIS ZONE ACTUALLY HAS. + # A permissive stand-in like "assumed" satisfies "non-empty" while meaning + # the crossing was never performed, so the reason is matched against the + # zone's real services and port ranges. + allowed_reasons="$(jq -r ' + (.zones[0].services // [] | map("the \(.) service")) + + (if ((.zones[0].ports // []) | length) > 0 then ["the open port range"] else [] end) + | .[]' <<<"$state" | sort -u)" + [[ -n "$allowed_reasons" ]] || fail 'the zone reports no services and no ports, so nothing could be permitted' + while read -r reason; do + [[ -n "$reason" ]] || continue + grep -qxF "$reason" <<<"$allowed_reasons" \ + || fail "something is reported as reachable via \"$reason\", which is not a rule this zone has" + done < <(jq -r '.exposed[].allowedBy' <<<"$state" | sort -u) + # And must be a real port. + jq -e '[.exposed[] | (.port > 0 and .port < 65536)] | all' <<<"$state" >/dev/null \ + || fail 'an exposed entry has no valid port' + # Loopback-only listeners are not exposure and must never appear. + jq -e '[.exposed[] | select(.name == "loopback")] | length == 0' <<<"$state" >/dev/null \ + || fail 'a loopback-only listener is reported as reachable' +fi + +# ── Refusals ──────────────────────────────────────────────────────────────── +refusal() { "$helper" "$@" 2>/dev/null | jq -r '.error // ""'; } +for bad in "ssh; rm -rf /" "../escape" "" "UPPER CASE"; do + [[ -n "$(refusal add-service "$bad")" ]] || fail "a bad service name was accepted: $bad" +done +for bad in "22" "22/sctp" "70000/tcp" "abc/tcp"; do + [[ -n "$(refusal add-port "$bad")" ]] || fail "a bad port specification was accepted: $bad" +done +[[ -n "$(refusal set-zone 'eth0; reboot' public)" ]] || fail 'a bad interface name was accepted' +[[ -n "$(refusal bogus)" ]] || fail 'an unknown command was accepted' + +printf 'firewall contract: PASS (%s reachable, %s of them data stores)\n' \ + "$(jq '.exposed | length' <<<"$state")" \ + "$(jq '.exposedDataStores | length' <<<"$state")" diff --git a/tests/quickshell/settings_write_sweep.py b/tests/quickshell/settings_write_sweep.py index 9cb9f79..3ee4bf6 100644 --- a/tests/quickshell/settings_write_sweep.py +++ b/tests/quickshell/settings_write_sweep.py @@ -244,6 +244,15 @@ def main() -> int: ipc(config_home, harness, "commit", entry["key"], json.dumps(current)) # ── Settings Panama stores itself ──────────────────────────────────────── + # The harness answers its IPC socket slightly before it can serve queries, + # and a read in that window comes back "Not ready to accept queries yet" -- + # which is a race in this test, not a setting that failed to round-trip. + for _ in range(40): + probe = ipc(config_home, harness, "stored", "use24Hour") + if probe.returncode == 0 and "not ready" not in probe.stdout.lower(): + break + time.sleep(0.25) + local_verified, local_skipped, local_failures = [], [], [] for entry in local_entries(): raw = ipc(config_home, harness, "stored", entry["key"])