Own the network: details, VPN, enterprise Wi-Fi, and a firewall that can also allow
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -25,6 +25,96 @@ SettingsPage {
|
||||
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 {
|
||||
@@ -180,7 +270,9 @@ SettingsPage {
|
||||
: "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
|
||||
// The add row always follows, so the last service still needs
|
||||
// its hairline.
|
||||
divider: true
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
@@ -208,6 +300,80 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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
|
||||
@@ -222,26 +388,176 @@ SettingsPage {
|
||||
|
||||
SettingsCard {
|
||||
title: "Zones"
|
||||
subtitle: "A zone is a set of rules. Each network connection uses one."
|
||||
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: Object.keys(Firewall.activeZones ?? ({}))
|
||||
model: root.zonedInterfaces
|
||||
|
||||
delegate: Column {
|
||||
id: placement
|
||||
|
||||
delegate: TextRow {
|
||||
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
|
||||
label: String(modelData)
|
||||
detail: "Applied to " + (Firewall.activeZones[String(modelData)] ?? []).join(", ")
|
||||
value: String(modelData) === Firewall.defaultZone ? "Default" : ""
|
||||
divider: true
|
||||
|
||||
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"
|
||||
value: Firewall.defaultZone
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user