638 lines
26 KiB
QML
638 lines
26 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 default is the same change with a wider blast radius: it decides for
|
|
// every connection that does not ask for a zone by name. It used to apply
|
|
// on the pick, one dropdown below a picker that made you confirm.
|
|
property string pendingDefaultZone: ""
|
|
|
|
// Two armed changes on screen at once is how the wrong one gets pressed.
|
|
function armInterfaceMove(iface: string, zoneName: string): void {
|
|
root.pendingDefaultZone = "";
|
|
root.pendingInterface = iface;
|
|
root.pendingZone = zoneName;
|
|
}
|
|
|
|
function armDefaultZone(zoneName: string): void {
|
|
root.pendingInterface = "";
|
|
root.pendingZone = "";
|
|
root.pendingDefaultZone = zoneName;
|
|
}
|
|
|
|
// 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.armInterfaceMove(placement.iface, 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
|
|
divider: root.pendingDefaultZone === ""
|
|
onPicked: value => root.armDefaultZone(String(value))
|
|
}
|
|
|
|
SettingRow {
|
|
width: parent.width
|
|
visible: root.pendingDefaultZone !== ""
|
|
label: "Make " + root.pendingDefaultZone + " the default?"
|
|
// What moves is named the way the per-interface confirm names its
|
|
// interface: not "the default changes", but which machines end up
|
|
// deciding differently because of it.
|
|
detail: "Every connection firewalld has not placed in a zone of its "
|
|
+ "own follows the default — each one leaves " + Firewall.defaultZone
|
|
+ " for " + root.pendingDefaultZone
|
|
+ ", and so does every network joined from now on."
|
|
controlWidth: 240
|
|
|
|
Row {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 8
|
|
|
|
SettingsButton {
|
|
text: "Keep " + Firewall.defaultZone
|
|
enabled: !Firewall.busy
|
|
onClicked: root.pendingDefaultZone = ""
|
|
}
|
|
|
|
SettingsButton {
|
|
text: "Change it"
|
|
tone: "danger"
|
|
enabled: !Firewall.busy
|
|
onClicked: {
|
|
const target = root.pendingDefaultZone;
|
|
root.pendingDefaultZone = "";
|
|
Firewall.setDefaultZone(target);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 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
|
|
}
|
|
}
|
|
}
|