Add a Firewall page, led by what is actually reachable
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. On this machine that crossing is the whole story. The rules look unremarkable -- one zone, three services, a port range -- and 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 exactly how a tidy rules list coexists with an open database. 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. Closing the port range names what it would cut off, by service, before doing it, and removing ssh says so when someone is connected over it. Rich rules are shown and never edited: a syntax is not a setting, but hiding it would misrepresent the configuration. The contract needed a recorded firewall, and the reason is worth keeping. The rule this page exists for cannot be tested against this machine -- its zone permits everything above 1024, so "listening" and "listening and permitted" give identical answers, and a blocked listener needs a port below 1024, which needs root. With the crossing deleted, the contract passed. It now runs against a fixture where two listeners are blocked, and catches it. Also here: polkit response files are written 0600 rather than at the default mask, the agent sweeps requests left by an instance that did not exit cleanly, and the write sweep waits for its harness to be ready instead of reporting the startup race as settings that failed. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -126,6 +126,7 @@ Rectangle {
|
|||||||
case "updates": return updatesPage;
|
case "updates": return updatesPage;
|
||||||
case "users": return usersPage;
|
case "users": return usersPage;
|
||||||
case "sharing": return sharingPage;
|
case "sharing": return sharingPage;
|
||||||
|
case "firewall": return firewallPage;
|
||||||
case "printers": return printersPage;
|
case "printers": return printersPage;
|
||||||
case "services": return healthPage;
|
case "services": return healthPage;
|
||||||
case "about": return aboutPage;
|
case "about": return aboutPage;
|
||||||
@@ -170,6 +171,7 @@ Rectangle {
|
|||||||
Component { id: updatesPage; UpdatesPage {} }
|
Component { id: updatesPage; UpdatesPage {} }
|
||||||
Component { id: usersPage; UsersPage {} }
|
Component { id: usersPage; UsersPage {} }
|
||||||
Component { id: sharingPage; SharingPage {} }
|
Component { id: sharingPage; SharingPage {} }
|
||||||
|
Component { id: firewallPage; FirewallPage {} }
|
||||||
Component { id: printersPage; PrintersPage {} }
|
Component { id: printersPage; PrintersPage {} }
|
||||||
Component { id: accessibilityPage; AccessibilityPage {} }
|
Component { id: accessibilityPage; AccessibilityPage {} }
|
||||||
Component { id: powerPage; PowerPage {} }
|
Component { id: powerPage; PowerPage {} }
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ Rectangle {
|
|||||||
{ page: "displays", label: "Displays", icon: "\u{F0379}" },
|
{ page: "displays", label: "Displays", icon: "\u{F0379}" },
|
||||||
{ page: "connectivity", label: "Network & Devices", icon: "\u{F08D4}" },
|
{ page: "connectivity", label: "Network & Devices", icon: "\u{F08D4}" },
|
||||||
{ page: "sharing", label: "Sharing", icon: "\u{F04E6}" },
|
{ page: "sharing", label: "Sharing", icon: "\u{F04E6}" },
|
||||||
|
{ page: "firewall", label: "Firewall", icon: "\u{F0483}" },
|
||||||
{ page: "printers", label: "Printers", icon: "\u{F042A}" },
|
{ page: "printers", label: "Printers", icon: "\u{F042A}" },
|
||||||
{ page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}" },
|
{ page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}" },
|
||||||
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" },
|
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ AboutPage 1.0 AboutPage.qml
|
|||||||
AppearancePage 1.0 AppearancePage.qml
|
AppearancePage 1.0 AppearancePage.qml
|
||||||
AvatarPicker 1.0 AvatarPicker.qml
|
AvatarPicker 1.0 AvatarPicker.qml
|
||||||
ConnectivityPage 1.0 ConnectivityPage.qml
|
ConnectivityPage 1.0 ConnectivityPage.qml
|
||||||
|
FirewallPage 1.0 FirewallPage.qml
|
||||||
GamingPage 1.0 GamingPage.qml
|
GamingPage 1.0 GamingPage.qml
|
||||||
HomePhonePage 1.0 HomePhonePage.qml
|
HomePhonePage 1.0 HomePhonePage.qml
|
||||||
HomeFavoriteCard 1.0 HomeFavoriteCard.qml
|
HomeFavoriteCard 1.0 HomeFavoriteCard.qml
|
||||||
|
|||||||
+371
@@ -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:]))
|
||||||
@@ -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:
|
def runtime_dir() -> Path:
|
||||||
base = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) / "panama-polkit"
|
base = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) / "panama-polkit"
|
||||||
base.mkdir(mode=0o700, parents=True, exist_ok=True)
|
base.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
# Enforced rather than assumed: an inherited directory with looser
|
# Enforced rather than assumed: an inherited directory with looser
|
||||||
# permissions would expose every request that passes through it.
|
# permissions would expose every request that passes through it.
|
||||||
os.chmod(base, 0o700)
|
os.chmod(base, 0o700)
|
||||||
|
sweep_stale(base)
|
||||||
return base
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,8 +92,12 @@ Singleton {
|
|||||||
// everything about the request.
|
// everything about the request.
|
||||||
function dismiss(result: string): void {
|
function dismiss(result: string): void {
|
||||||
if (root.requestPath !== "") {
|
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",
|
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))];
|
+ " > " + JSON.stringify(root.responsePathFor(root.requestPath))];
|
||||||
answer.running = true;
|
answer.running = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ Singleton {
|
|||||||
{ label: "Print queue", detail: "What is waiting to print, and cancelling it", page: "printers" },
|
{ 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: "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: "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 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: "Remote desktop", detail: "See and control this desktop from elsewhere", page: "sharing" },
|
||||||
{ label: "Network name", detail: "The name other machines see", page: "sharing" },
|
{ label: "Network name", detail: "The name other machines see", page: "sharing" },
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openSettings(page: string): void {
|
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";
|
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
|
||||||
DesktopPreferences.set("lastPage", root.settingsPage);
|
DesktopPreferences.set("lastPage", root.settingsPage);
|
||||||
root.settingsOpen = true;
|
root.settingsOpen = true;
|
||||||
|
|||||||
+10
@@ -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
|
||||||
@@ -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,
|
**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.
|
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
|
### 3.2 Containers
|
||||||
|
|
||||||
podman is here and Storage already found 5.31 GB of reclaimable images. Running
|
podman is here and Storage already found 5.31 GB of reclaimable images. Running
|
||||||
|
|||||||
Executable
+158
@@ -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")"
|
||||||
@@ -244,6 +244,15 @@ def main() -> int:
|
|||||||
ipc(config_home, harness, "commit", entry["key"], json.dumps(current))
|
ipc(config_home, harness, "commit", entry["key"], json.dumps(current))
|
||||||
|
|
||||||
# ── Settings Panama stores itself ────────────────────────────────────────
|
# ── 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 = [], [], []
|
local_verified, local_skipped, local_failures = [], [], []
|
||||||
for entry in local_entries():
|
for entry in local_entries():
|
||||||
raw = ipc(config_home, harness, "stored", entry["key"])
|
raw = ipc(config_home, harness, "stored", entry["key"])
|
||||||
|
|||||||
Reference in New Issue
Block a user