Files
Panama/config/dot/quickshell/modules/settings/FirewallPage.qml
T

585 lines
24 KiB
QML

// 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
// The add flow. Additions are not destructive -- they open something rather
// than closing it -- so there is no confirm here, only the caption saying
// the rule is permanent and will prompt.
property bool addOpen: false
property string addKind: "service"
property string addValue: ""
// Changing a connection's zone IS consequence-bearing: it silently rewrites
// what every machine on that network can reach. Two-stage, and the confirm
// names the interface it is about to move.
property string pendingInterface: ""
property string pendingZone: ""
// The zone being read in the browser. Read-only: this looks at a zone
// without applying it to anything.
property string browsingZone: ""
readonly property bool addValid: root.addKind === "service"
? /^[a-z0-9][a-z0-9-]*$/.test(root.addValue.trim())
: /^[0-9]{1,5}(-[0-9]{1,5})?\/(tcp|udp)$/.test(root.addValue.trim())
// [{ iface, zone }] -- one row per network interface the firewall has
// actually placed in a zone, flattened out of activeZones.
readonly property var zonedInterfaces: {
const placed = Firewall.activeZones ?? ({});
const rows = [];
for (const zoneName of Object.keys(placed)) {
for (const iface of (placed[zoneName] ?? []))
rows.push({ iface: String(iface), zone: String(zoneName) });
}
rows.sort((a, b) => a.iface.localeCompare(b.iface));
return rows;
}
readonly property var zoneOptions: (Firewall.allZones ?? []).map(zoneName => ({
value: String(zoneName),
label: String(zoneName),
detail: String(zoneName) === Firewall.defaultZone
? "The default for new connections" : ""
}))
// What an interface is carrying, said the way a person names it: the Wi-Fi
// network or the wired profile, falling back to the kernel's name for it.
function connectionOn(iface: string): string {
if (Connectivity.wifiDevice && Connectivity.wifiDevice.name === iface
&& Connectivity.activeNetwork)
return Connectivity.activeNetwork.name;
if (Connectivity.wiredDevice && Connectivity.wiredDevice.name === iface
&& Connectivity.wiredDevice.network)
return String(Connectivity.wiredDevice.network.name ?? iface);
return iface;
}
// The rules by name, then what the zone does with everything else. The
// helper's `summary` counts them; the names are what someone comparing two
// zones actually needs, so the names are listed and the helper's own
// sentence about the target is kept for the tail.
function describeZone(zoneName: string): string {
const info = Firewall.zoneInfo(zoneName);
if (!info)
return "Reading what " + zoneName + " allows…";
const services = (info.services ?? []).map(entry => String(entry));
const ports = (info.ports ?? []).map(entry => String(entry));
const parts = [];
if (services.length > 0)
parts.push(services.join(", "));
if (ports.length > 0)
parts.push("ports " + ports.join(", "));
let text = parts.length === 0
? zoneName + " allows nothing in."
: zoneName + " allows: " + parts.join(", and ") + ".";
// "3 services, 1 port rule; anything no rule allows is rejected" --
// everything after the semicolon is the helper's phrasing for the
// zone's target, which the list above does not say.
const summary = String(info.summary ?? "");
const cut = summary.indexOf("; ");
if (cut >= 0)
text += " Otherwise, " + summary.slice(cut + 2) + ".";
const rich = (info.richRules ?? []).length;
if (rich > 0)
text += " It also carries " + rich + " rich rule"
+ (rich === 1 ? "" : "s") + ", which this page never edits.";
return text;
}
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
// Every range in one call. The label says "the range",
// and a range is a tcp rule and a udp rule.
onClicked: {
root.confirmingRange = false;
Firewall.removePorts(Firewall.openRanges);
}
}
}
}
}
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
// The add row always follows, so the last service still needs
// its hairline.
divider: true
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);
}
}
}
}
}
// ── The add side ─────────────────────────────────────────────────────
//
// Opening a port is not destructive, so it is not confirmed. It is
// permanent and it prompts, and both of those are said before the
// button rather than discovered by the polkit dialog appearing.
ActionRow {
width: parent.width
label: "Allow something new"
detail: "A service firewalld already knows by name, or a port and protocol"
action: root.addOpen ? "Cancel" : "Add…"
enabled: !Firewall.busy
divider: root.addOpen
onTriggered: {
root.addOpen = !root.addOpen;
root.addValue = "";
}
}
Column {
width: parent.width
visible: root.addOpen
SegmentRow {
width: parent.width
label: "What to allow"
detail: "A named service carries its own ports, so it keeps working if they change"
controlWidth: 230
options: [
{ value: "service", label: "Named service" },
{ value: "port", label: "Port" }
]
value: root.addKind
enabled: !Firewall.busy
onSelected: value => {
root.addKind = String(value);
root.addValue = "";
}
}
TextFieldRow {
width: parent.width
label: root.addKind === "service" ? "Service name" : "Port and protocol"
detail: root.addKind === "service"
? "One of firewalld's own service names, in lower case"
: "A port or range, then tcp or udp"
placeholder: root.addKind === "service" ? "syncthing" : "8080/tcp"
text: root.addValue
enabled: !Firewall.busy
onAccepted: value => root.addValue = value
}
ActionRow {
width: parent.width
label: root.addValue.trim() === "" || root.addValid
? "Allow it"
: (root.addKind === "service"
? "That is not a service name firewalld would accept"
: "That is not a port firewalld would accept")
detail: "This writes a permanent rule — the system will ask for your password."
action: "Allow"
enabled: root.addValid && !Firewall.busy
divider: false
onTriggered: {
if (root.addKind === "service")
Firewall.addService(root.addValue.trim());
else
Firewall.addPort(root.addValue.trim());
root.addOpen = false;
root.addValue = "";
}
}
}
// 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, and moving a connection between zones changes what the machines on that network can reach."
Repeater {
model: root.zonedInterfaces
delegate: Column {
id: placement
required property var modelData
required property int index
readonly property string iface: String(placement.modelData.iface ?? "")
readonly property string zone: String(placement.modelData.zone ?? "")
readonly property bool pending: root.pendingInterface === placement.iface
width: parent.width
OptionPickerRow {
width: parent.width
label: root.connectionOn(placement.iface)
detail: root.connectionOn(placement.iface) === placement.iface
? "This interface"
: "On " + placement.iface
enabled: !Firewall.busy
options: root.zoneOptions
current: placement.zone
divider: !placement.pending
onPicked: value => {
root.pendingInterface = placement.iface;
root.pendingZone = String(value);
}
}
SettingRow {
width: parent.width
visible: placement.pending
label: "Move " + placement.iface + " to " + root.pendingZone + "?"
// The interface is named because that is the thing being
// moved, and because "public" on the wrong one is the
// difference between a safe café and an unreachable desk.
detail: placement.iface + " leaves " + placement.zone + " for "
+ root.pendingZone + ". Everything reachable over "
+ placement.iface + " is decided by " + root.pendingZone
+ " from then on."
controlWidth: 210
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Keep " + placement.zone
enabled: !Firewall.busy
onClicked: {
root.pendingInterface = "";
root.pendingZone = "";
}
}
SettingsButton {
text: "Move it"
tone: "danger"
enabled: !Firewall.busy
onClicked: {
const target = root.pendingZone;
root.pendingInterface = "";
root.pendingZone = "";
Firewall.setZone(placement.iface, target);
}
}
}
}
}
}
TextRow {
width: parent.width
visible: Firewall.scanned && root.zonedInterfaces.length === 0
label: "No connection is in a zone"
detail: !Firewall.available
? "The firewall is not running, so nothing has been placed"
: "firewalld reports no active zones, which usually means no interface is up"
value: ""
}
OptionPickerRow {
width: parent.width
label: "Default for new connections"
detail: "Used when a network does not ask for a particular zone"
enabled: !Firewall.busy
options: root.zoneOptions
current: Firewall.defaultZone
onPicked: value => Firewall.setDefaultZone(String(value))
}
// ── The zone browser ─────────────────────────────────────────────────
//
// Read-only. Picking a chip here applies nothing: it answers "what would
// this zone do", which is the question you have to answer before the
// dropdowns above are anything but a guess.
TextRow {
width: parent.width
visible: (Firewall.allZones ?? []).length > 0
label: "Browse zones"
detail: "What each of the " + (Firewall.allZones ?? []).length
+ " zones would allow. Nothing here applies anything."
value: ""
divider: false
}
// Full width rather than in the row's trailing slot: fourteen chips wrap
// to several lines, and a row's control area is one line tall.
Flow {
width: parent.width
visible: (Firewall.allZones ?? []).length > 0
spacing: 6
bottomPadding: 12
Repeater {
model: Firewall.allZones ?? []
delegate: Rectangle {
id: chip
required property var modelData
readonly property string zoneName: String(chip.modelData)
readonly property bool current: root.browsingZone === chip.zoneName
width: chipLabel.implicitWidth + 20
height: 26
radius: Theme.pillRadius
color: chip.current
? Theme.alpha(Theme.accent, 0.18)
: Theme.alpha(Theme.fg, chipHover.hovered ? 0.11 : 0.055)
border.width: 1
border.color: chip.current
? Theme.alpha(Theme.accent, 0.45)
: Theme.alpha(Theme.fg, 0.09)
Text {
id: chipLabel
anchors.centerIn: parent
text: chip.zoneName
color: chip.current ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: chip.current ? Font.DemiBold : Font.Normal
}
HoverHandler {
id: chipHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: root.browsingZone = chip.current ? "" : chip.zoneName
}
}
}
}
TextRow {
width: parent.width
visible: root.browsingZone !== ""
label: root.browsingZone
detail: root.describeZone(root.browsingZone)
value: root.browsingZone === Firewall.defaultZone ? "Default" : ""
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
}
}
}