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:
Gabriel Brown
2026-08-24 16:31:52 -04:00
parent aba2d16ffa
commit b30bf40407
29 changed files with 4452 additions and 241 deletions
+1 -1
View File
@@ -151,7 +151,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests ## Tests
169 of them, under `tests/`. Run the lot, or a subset by pattern: 170 of them, under `tests/`. Run the lot, or a subset by pattern:
```sh ```sh
panama test # everything panama test # everything
@@ -16,6 +16,21 @@ Column {
spacing: 0 spacing: 0
// Connected and paired devices always render (they lead the sort, so the
// slice keeps them); unpaired strangers fill up to the cap and the rest
// wait behind the "nearby devices" row. Discovery in a busy room finds
// dozens of phones and TVs that are not yours.
property bool showAll: false
readonly property int visibleCap: 5
readonly property var shown: {
const list = Connectivity.bluetoothDevices;
if (root.showAll || list.length <= root.visibleCap)
return list;
const pinned = list.filter(device => device.connected || device.paired).length;
return list.slice(0, Math.max(root.visibleCap, pinned));
}
readonly property int hiddenCount: Connectivity.bluetoothDevices.length - root.shown.length
function primaryAction(device: var): void { function primaryAction(device: var): void {
if (device.connected) { if (device.connected) {
device.disconnect(); device.disconnect();
@@ -41,7 +56,7 @@ Column {
} }
Repeater { Repeater {
model: Connectivity.bluetoothDevices model: root.shown
SettingRow { SettingRow {
id: entry id: entry
@@ -52,7 +67,8 @@ Column {
width: parent.width width: parent.width
label: entry.modelData.name || entry.modelData.address || "Unknown device" label: entry.modelData.name || entry.modelData.address || "Unknown device"
detail: root.stateLabel(entry.modelData) detail: root.stateLabel(entry.modelData)
divider: entry.index < Connectivity.bluetoothDevices.length - 1 divider: entry.index < root.shown.length - 1
|| root.hiddenCount > 0 || root.showAll
controlWidth: 200 controlWidth: 200
activatable: !entry.modelData.pairing activatable: !entry.modelData.pairing
onActivated: root.primaryAction(entry.modelData) onActivated: root.primaryAction(entry.modelData)
@@ -81,6 +97,19 @@ Column {
} }
} }
SettingRow {
width: parent.width
visible: root.hiddenCount > 0
|| (root.showAll && Connectivity.bluetoothDevices.length > root.visibleCap)
label: root.showAll
? "Show fewer devices"
: root.hiddenCount + (root.hiddenCount === 1 ? " more nearby device" : " more nearby devices")
detail: root.showAll ? "" : "Unpaired devices in range, folded to keep the list short"
activatable: true
onActivated: root.showAll = !root.showAll
divider: false
}
SettingRow { SettingRow {
width: parent.width width: parent.width
visible: Connectivity.bluetoothDevices.length === 0 visible: Connectivity.bluetoothDevices.length === 0
@@ -0,0 +1,148 @@
// The facts about one connection that otherwise need a terminal.
//
// IP address, gateway, DNS and MAC are the four things people leave this app
// for, and they were the reason the Connections page still pointed at GNOME.
// They are read-only here: editing them properly means static addressing, which
// is a page of its own rather than four fields smuggled into a details drawer.
//
// Nothing here is ever a secret. panama-network's `details` verb returns
// addresses only -- no PSK, no enterprise password -- so this component can be
// shown for any connection without deciding what is safe to draw.
//
// The values are set in the interface face with tabular figures rather than a
// monospaced one. Theme bans monospaced text outright (fontMono is the icon
// face, not a text face), and an address only needs its digits to line up.
import QtQuick
import qs.config
Column {
id: root
// { ip4, gateway, dns: [], mac, macRandomized } as the helper reports it,
// or null while the read has not come back. Null is NOT "no address": the
// component says it is still reading rather than claiming an answer.
property var details: null
// [{ key, value, note }] -- only the facts that actually have a value, so a
// connection with no gateway shows three rows rather than a blank one.
readonly property var facts: {
const source = root.details;
if (!source)
return [];
const rows = [];
const ip4 = String(source.ip4 ?? "");
if (ip4 !== "")
rows.push({ key: "IPv4 address", value: ip4, note: "" });
const ip6 = String(source.ip6 ?? "");
if (ip6 !== "")
rows.push({ key: "IPv6 address", value: ip6, note: "" });
const gateway = String(source.gateway ?? "");
if (gateway !== "")
rows.push({ key: "Gateway", value: gateway, note: "" });
const dns = Array.isArray(source.dns)
? source.dns.map(entry => String(entry)).filter(entry => entry !== "")
: [];
if (dns.length > 0)
rows.push({
key: dns.length === 1 ? "DNS" : "DNS servers",
value: dns.join(" · "),
note: ""
});
const mac = String(source.mac ?? "");
if (mac !== "")
rows.push({
key: "MAC address",
value: mac,
note: source.macRandomized === true ? "randomized" : ""
});
return rows;
}
width: parent ? parent.width : 620
spacing: 5
topPadding: 8
bottomPadding: 10
Repeater {
model: root.facts
Item {
id: fact
required property var modelData
width: parent.width
implicitHeight: Math.max(key.implicitHeight, value.implicitHeight)
Text {
id: key
anchors.left: parent.left
anchors.top: parent.top
width: 132
text: String(fact.modelData.key ?? "")
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
Text {
id: value
anchors.left: key.right
anchors.right: parent.right
anchors.top: parent.top
text: String(fact.modelData.value ?? "")
+ (String(fact.modelData.note ?? "") !== ""
? " · " + String(fact.modelData.note) : "")
color: Theme.fg
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WrapAnywhere
}
}
}
// Two honest empty states, and they say different things. "Still reading"
// is not "no address", and a page that renders the first as the second is
// how a working connection comes to look broken for half a second.
Text {
width: parent.width
visible: !root.details
text: "Reading this connection's addresses…"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Text {
width: parent.width
visible: !!root.details && root.facts.length === 0
text: "NetworkManager reports no addresses for this connection."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
// The helper's own aside -- chiefly "this takes effect on the next
// reconnect" after a MAC randomization change. It belongs to the answer,
// so it is shown with the answer rather than guessed at by the page.
Text {
width: parent.width
visible: String(root.details?.note ?? "") !== ""
topPadding: 4
text: String(root.details?.note ?? "")
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
@@ -1,10 +1,18 @@
// Connections — wired, Wi-Fi, and Bluetooth. // Connections — the whole network, not the half that was easy.
// //
// Wi-Fi and Bluetooth are handled here rather than delegated. Everything goes // This page used to end in a card headed "Owned by Fedora" with two doors back
// through Quickshell.Networking and Quickshell.Bluetooth -- NetworkManager and // to GNOME's panels: one for hidden and enterprise networks, one for VPN and
// BlueZ over DBus -- and nothing shells out to nmcli or bluetoothctl. That was // proxies. Everything behind those doors now lives here, so the card is gone.
// the founding requirement for this desktop: never having to drop to a terminal //
// to join a network. // Two mechanisms, deliberately kept apart:
//
// * Wi-Fi and Bluetooth state -- scanning, joining, pairing, the radio
// switches -- go through Quickshell.Networking and Quickshell.Bluetooth,
// which speak to NetworkManager and BlueZ over DBus. Connectivity.qml is
// pinned shell-out-free and stays that way.
// * Everything NetworkManager exposes only through nmcli -- per-connection
// addresses, forgetting a profile, MAC randomization, VPN import, hotspot,
// enterprise join -- goes through NetworkTools.qml and scripts/panama-network.
// //
// Scanning follows this page being on screen. Wi-Fi scanning and especially // Scanning follows this page being on screen. Wi-Fi scanning and especially
// Bluetooth discovery hold the radio, and doing either for a list nobody is // Bluetooth discovery hold the radio, and doing either for a list nobody is
@@ -19,26 +27,78 @@ import qs.services
SettingsPage { SettingsPage {
id: root id: root
objectName: "connectivity"
title: "Connections" title: "Connections"
lede: Connectivity.activeNetwork lede: {
? "Connected to " + Connectivity.activeNetwork.name const wifi = Connectivity.activeNetwork ? Connectivity.activeNetwork.name : "";
: "Wi-Fi, Bluetooth, and the things Fedora owns." if (wifi !== "" && Connectivity.wiredOn)
return "On " + wifi + " and wired.";
if (wifi !== "")
return "On " + wifi + ".";
if (Connectivity.wiredOn)
return "Wired.";
return "Wi-Fi, Bluetooth, VPN, and what this machine can reach.";
}
// Drive the scanners only while this page is the one being shown. // The wired connection's own drawer, and the two inline forms that are only
// open while someone is filling them in.
property bool wiredOpen: false
property bool hotspotOpen: false
property string hotspotName: ""
property bool importOpen: false
property string importPath: ""
readonly property string wiredConnection:
String(Connectivity.wiredDevice?.network?.name ?? "")
readonly property string activeWifi:
Connectivity.activeNetwork ? Connectivity.activeNetwork.name : ""
// Drive the scanners and the helper only while this page is the one being
// shown. Both cost radio time or nmcli invocations for a list nobody is
// reading.
Component.onCompleted: { Component.onCompleted: {
Connectivity.active = true; Connectivity.active = true;
NetworkTools.active = true;
if (!WifiShare.scanned) if (!WifiShare.scanned)
WifiShare.refresh(); WifiShare.refresh();
Vpn.refresh();
} }
Component.onDestruction: Connectivity.active = false Component.onDestruction: {
Connectivity.active = false;
NetworkTools.active = false;
}
// Addresses and the MAC in use both change with the connection, so the
// cached details for a network that just came up are stale the moment it
// does.
onActiveWifiChanged: {
if (root.activeWifi !== "")
NetworkTools.refreshDetails(root.activeWifi);
}
onWiredConnectionChanged: {
if (root.wiredConnection !== "")
NetworkTools.refreshDetails(root.wiredConnection);
}
TextRow {
visible: NetworkTools.lastError !== ""
label: "The network needs attention"
detail: NetworkTools.lastError
value: ""
divider: false
}
// ── Wired ────────────────────────────────────────────────────────────────
SettingsCard { SettingsCard {
title: "Wired" title: "Wired"
visible: Connectivity.wiredDevice !== null visible: Connectivity.wiredDevice !== null
SwitchRow { SettingRow {
label: "Ethernet" width: parent.width
label: root.wiredConnection !== "" ? root.wiredConnection : "Ethernet"
// Three states worth telling apart: on, off but plugged in, and // Three states worth telling apart: on, off but plugged in, and
// nothing in the socket. "Not connected" covered all three and // nothing in the socket. "Not connected" covered all three and
// explained none of them. // explained none of them.
@@ -54,13 +114,44 @@ SettingsPage {
// that blamed the hardware for what it had just done itself. // that blamed the hardware for what it had just done itself.
return device.name + " · off"; return device.name + " · off";
} }
checked: Connectivity.wiredOn controlWidth: 78
enabled: Connectivity.wiredAvailable
divider: false divider: false
onToggled: value => Connectivity.setWired(value) activatable: root.wiredConnection !== "" && Connectivity.wiredOn
onActivated: root.wiredOpen = !root.wiredOpen
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 9
SettingsToggle {
anchors.verticalCenter: parent.verticalCenter
checked: Connectivity.wiredOn
enabled: Connectivity.wiredAvailable
onToggled: value => Connectivity.setWired(value)
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: root.wiredConnection !== "" && Connectivity.wiredOn
text: root.wiredOpen ? "▴" : "▾"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
ConnectionDetails {
width: parent.width
visible: root.wiredOpen && Connectivity.wiredOn
details: root.wiredConnection !== ""
? NetworkTools.detailsFor(root.wiredConnection) : null
} }
} }
// ── Wi-Fi ────────────────────────────────────────────────────────────────
SettingsCard { SettingsCard {
title: "Wi-Fi" title: "Wi-Fi"
// A Wi-Fi switch reading "On" above the words "No Wi-Fi adapter" is a // A Wi-Fi switch reading "On" above the words "No Wi-Fi adapter" is a
@@ -79,7 +170,7 @@ SettingsPage {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
checked: Connectivity.wifiEnabled checked: Connectivity.wifiEnabled
enabled: Connectivity.wifiAvailable enabled: Connectivity.wifiAvailable
onToggled: value => Networking.wifiEnabled = value onToggled: value => Connectivity.setWifiEnabled(value)
} }
} }
@@ -87,71 +178,188 @@ SettingsPage {
width: parent.width width: parent.width
visible: Connectivity.wifiEnabled visible: Connectivity.wifiEnabled
} }
}
// Sharing a network by QR, the way GNOME's Wi-Fi panel does. The // ── Hotspot ──────────────────────────────────────────────────────────
// alternative is reading a passphrase out loud. //
// // One radio cannot be a client and an access point at the same time, so
// The image holds the password in machine-readable form, so it is generated // starting this drops whatever network the machine is on. Said before it
// on demand rather than up front, and the helper writes it to tmpfs under // happens rather than discovered when the browser stops loading.
// XDG_RUNTIME_DIR instead of anywhere persistent.
SettingsCard {
visible: Connectivity.wifiDevice !== null && WifiShare.shareable.length > 0
title: "Share a network"
subtitle: WifiShare.sharing !== ""
? "Point a phone's camera at the code to join " + WifiShare.sharing + "."
: "Shows a QR code a phone can scan to join, without reading the password out."
Repeater { ActionRow {
model: WifiShare.shareable width: parent.width
visible: Connectivity.wifiEnabled && !NetworkTools.hotspotActive
ActionRow { label: "Hotspot"
id: shareRow detail: "Share this machine's connection over Wi-Fi"
required property var modelData action: root.hotspotOpen ? "Cancel" : "Start hotspot…"
required property int index enabled: !NetworkTools.busy
divider: root.hotspotOpen
label: shareRow.modelData.ssid onTriggered: {
detail: WifiShare.sharing === shareRow.modelData.name root.hotspotOpen = !root.hotspotOpen;
? "Showing a code below — anyone who can see the screen can join" root.hotspotName = "";
: "Saved network"
action: WifiShare.sharing === shareRow.modelData.name ? "Hide" : "Show code"
divider: shareRow.index < WifiShare.shareable.length - 1 || WifiShare.sharing !== ""
onTriggered: WifiShare.sharing === shareRow.modelData.name
? WifiShare.stopSharing()
: WifiShare.share(shareRow.modelData.name)
} }
} }
// Drawn at its natural size on a white plate: a QR code inverted or Column {
// tinted to match a dark theme is unreliable to scan, and this one has
// exactly one job.
Item {
width: parent.width width: parent.width
visible: WifiShare.sharing !== "" && WifiShare.imagePath !== "" visible: root.hotspotOpen && !NetworkTools.hotspotActive
implicitHeight: visible ? plate.height + 20 : 0
Rectangle { TextFieldRow {
id: plate width: parent.width
anchors.horizontalCenter: parent.horizontalCenter label: "Network name"
y: 10 detail: "What the hotspot calls itself to phones and laptops nearby"
width: 208 placeholder: "panama-hotspot"
height: 208 text: root.hotspotName
radius: 10 enabled: !NetworkTools.busy
color: "white" onAccepted: value => root.hotspotName = value
}
Image { ActionRow {
anchors.centerIn: parent width: parent.width
width: 184 label: "Start the hotspot"
height: 184 detail: Connectivity.activeNetwork
smooth: false ? "This machine leaves " + Connectivity.activeNetwork.name
fillMode: Image.PreserveAspectFit + " while the hotspot runs — one radio cannot do both."
cache: false : "NetworkManager makes up a password and shows it once."
source: WifiShare.imagePath !== "" ? "file://" + WifiShare.imagePath : "" action: NetworkTools.busy ? "Starting…" : "Start"
enabled: root.hotspotName.trim() !== "" && !NetworkTools.busy
divider: false
onTriggered: {
NetworkTools.startHotspot(root.hotspotName.trim());
root.hotspotOpen = false;
}
}
}
TextRow {
width: parent.width
visible: NetworkTools.hotspotActive
label: "Hotspot is running"
detail: "Phones and laptops nearby can see this network and join it"
value: NetworkTools.hotspotSsid
}
// Shown once, on purpose. NetworkManager keeps the passphrase; this page
// never stores it, so leaving this screen means asking NetworkManager
// again rather than reading it back from Panama.
TextRow {
width: parent.width
visible: NetworkTools.hotspotActive && NetworkTools.hotspotPassword !== ""
label: "Password"
detail: "Shown once. Panama does not keep a copy — write it down or let someone type it in now."
value: NetworkTools.hotspotPassword
}
ActionRow {
width: parent.width
visible: NetworkTools.hotspotActive
label: "Stop the hotspot"
detail: "Anything connected through this machine loses its connection"
action: "Stop"
enabled: !NetworkTools.busy
divider: false
onTriggered: NetworkTools.stopHotspot()
}
}
// ── VPN ──────────────────────────────────────────────────────────────────
SettingsCard {
title: "VPN"
subtitle: "WireGuard and OpenVPN profiles NetworkManager holds for you."
TextRow {
width: parent.width
visible: Vpn.lastError !== ""
label: "The last VPN action did not finish"
detail: Vpn.lastError
value: ""
}
Repeater {
model: Vpn.connections
delegate: SwitchRow {
id: vpnRow
required property var modelData
required property int index
width: parent.width
label: String(vpnRow.modelData.name ?? "")
detail: String(vpnRow.modelData.kind ?? "VPN")
+ (vpnRow.modelData.active === true ? " · connected" : "")
checked: vpnRow.modelData.active === true
enabled: !Vpn.busy
onToggled: value => Vpn.setActive(String(vpnRow.modelData.uuid ?? ""), value)
}
}
TextRow {
width: parent.width
visible: Vpn.scanned && Vpn.connections.length === 0
label: "No VPNs configured"
detail: "Import a WireGuard or OpenVPN file to add one"
value: ""
}
// Names the profile that appeared rather than saying "done". An import
// that succeeds under a name you did not choose is otherwise invisible
// until you go looking for it in the list.
TextRow {
width: parent.width
visible: NetworkTools.lastImport !== ""
label: "Imported " + NetworkTools.lastImport
detail: "It is switched off until you turn it on above"
value: ""
}
ActionRow {
width: parent.width
label: "Import a VPN"
detail: "A .conf file from WireGuard, or a .ovpn file from OpenVPN"
action: root.importOpen ? "Cancel" : "Import…"
enabled: !NetworkTools.busy
divider: root.importOpen
onTriggered: {
root.importOpen = !root.importOpen;
root.importPath = "";
}
}
// A typed path rather than a file chooser this phase. A chooser is the
// better answer and is worth doing properly; a typed path is worth far
// more than the door back to GNOME it replaces.
Column {
width: parent.width
visible: root.importOpen
TextFieldRow {
width: parent.width
label: "File"
detail: "The full path to the profile you were given"
placeholder: "~/Downloads/work.ovpn"
text: root.importPath
enabled: !NetworkTools.busy
onAccepted: value => root.importPath = value
}
ActionRow {
width: parent.width
label: "Import this profile"
detail: "NetworkManager reads it and adds a profile. Nothing connects until you switch it on."
action: NetworkTools.busy ? "Importing…" : "Import"
enabled: root.importPath.trim() !== "" && !NetworkTools.busy
divider: false
onTriggered: {
NetworkTools.importVpn(root.importPath.trim());
root.importOpen = false;
} }
} }
} }
} }
// ── Bluetooth ────────────────────────────────────────────────────────────
SettingsCard { SettingsCard {
title: "Bluetooth" title: "Bluetooth"
visible: Connectivity.adapter !== null visible: Connectivity.adapter !== null
@@ -159,54 +367,119 @@ SettingsPage {
SettingRow { SettingRow {
label: "Bluetooth" label: "Bluetooth"
detail: Connectivity.adapter detail: Connectivity.bluetoothAvailable
? (Connectivity.adapter.enabled ? "On" : "Off") ? (Connectivity.bluetoothEnabled ? "On" : "Off")
: "Unavailable" : "Unavailable"
controlWidth: 48 controlWidth: 48
divider: !!(Connectivity.adapter && Connectivity.adapter.enabled) divider: Connectivity.bluetoothEnabled
SettingsToggle { SettingsToggle {
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
checked: !!(Connectivity.adapter && Connectivity.adapter.enabled) checked: Connectivity.bluetoothEnabled
enabled: Connectivity.adapter !== null enabled: Connectivity.bluetoothAvailable
onToggled: value => { onToggled: value => Connectivity.setBluetoothEnabled(value)
if (Connectivity.adapter)
Connectivity.adapter.enabled = value;
}
} }
} }
BluetoothPanel { BluetoothPanel {
width: parent.width width: parent.width
visible: !!(Connectivity.adapter && Connectivity.adapter.enabled) visible: Connectivity.bluetoothEnabled
} }
} }
// ── Radios and proxy ─────────────────────────────────────────────────────
SettingsCard { SettingsCard {
title: "Owned by Fedora" title: "Radios and proxy"
// These two panels drive NetworkManager over D-Bus, which is why they subtitle: "The two settings that apply to every connection at once."
// work in this session when most GNOME panels do not. They are split
// because GNOME splits them: "network" is wired, VPN and proxies, and
// does not contain Wi-Fi -- the one panel used to point everyone
// there, so the road to a hidden SSID or eduroam ended on a page
// without Wi-Fi on it.
subtitle: "Wired, VPN and Wi-Fi connection editing stay with GNOME's panels, which drive the same NetworkManager this page reads. Printers and online accounts have their own pages here."
ActionRow { // A hardware kill switch cannot be overridden from software, so the
label: "Wi-Fi networks" // switch says so rather than moving and having nothing happen.
detail: "Hidden networks, enterprise (802.1X) logins, and per-network settings" SwitchRow {
action: "Open" width: parent.width
onTriggered: SystemSettings.openGnomePanel("wifi") label: "Airplane mode"
detail: NetworkTools.airplaneHardBlocked
? "A switch on this machine is holding the radios off. Software cannot turn them back on."
: "Turns Wi-Fi and Bluetooth off together — the same switch the keyboard's airplane key throws"
checked: NetworkTools.airplaneOn
enabled: !NetworkTools.busy && !NetworkTools.airplaneHardBlocked
onToggled: value => NetworkTools.setAirplane(value)
} }
ActionRow { OptionPickerRow {
label: "Network connections" width: parent.width
detail: "VPN, proxies, and wired connection settings" label: "Network proxy"
action: "Open" detail: NetworkTools.proxyMode === "none"
? "Applications that honour the system proxy use this. Not every application does."
: "In use: " + NetworkTools.proxySummary
enabled: !NetworkTools.busy
options: [
{
value: "none",
label: "Off",
detail: "Applications reach the network directly"
},
{
value: "manual",
label: "Manual",
detail: "A host and port you enter, used for http, https and socks alike"
},
{
value: "auto",
label: "Automatic (PAC)",
detail: "A configuration URL decides, per address"
}
]
current: NetworkTools.proxyMode
onPicked: value => NetworkTools.setProxyMode(String(value))
}
Column {
width: parent.width
visible: NetworkTools.proxyMode === "manual"
// Host and port are written together, because the proxy is only
// usable as a pair -- so each field commits with whatever the other
// one currently holds, and neither writes a half-configuration.
TextFieldRow {
width: parent.width
label: "Proxy host"
detail: "The machine applications should go through"
placeholder: "proxy.example.com"
text: NetworkTools.proxyHost
enabled: !NetworkTools.busy
onAccepted: value => {
if (value.trim() !== "" && NetworkTools.proxyPort !== "")
NetworkTools.setProxyManual(value.trim(), NetworkTools.proxyPort);
}
}
TextFieldRow {
width: parent.width
label: "Port"
detail: "The port that proxy listens on"
placeholder: "8080"
text: NetworkTools.proxyPort
enabled: !NetworkTools.busy
divider: false
onAccepted: value => {
if (value.trim() !== "" && NetworkTools.proxyHost !== "")
NetworkTools.setProxyManual(NetworkTools.proxyHost, value.trim());
}
}
}
TextFieldRow {
width: parent.width
visible: NetworkTools.proxyMode === "auto"
label: "Configuration URL"
detail: "The .pac file whoever runs the network published"
placeholder: "http://example.com/proxy.pac"
text: NetworkTools.proxyPac
enabled: !NetworkTools.busy
divider: false divider: false
onTriggered: SystemSettings.openGnomePanel("network") onAccepted: value => NetworkTools.setProxyPac(value.trim())
} }
} }
} }
@@ -0,0 +1,98 @@
// Joining an 802.1X network -- eduroam and every corporate SSID -- inline,
// under the row you clicked.
//
// This is the single road that used to end at GNOME's Wi-Fi panel. A PSK field
// cannot join these networks: they want an authentication method, an identity,
// a password, and sometimes a certificate to check the network against.
//
// The password never becomes a command-line argument. It is handed to
// panama-network over stdin, because argv is world-readable through /proc for
// as long as the process lives -- see the pin in network-tools-contract. It is
// also dropped from this form the moment the form closes.
//
// No file dialog for the certificate this phase: a path typed in is worse than
// a picker and much better than a road back to GNOME.
import QtQuick
Column {
id: root
property string ssid: ""
property bool busy: false
// Live form state. There is no reset(): WifiPanel loads this form and
// destroys it when the row closes, so the typed password goes with it
// rather than sitting in a hidden object waiting to be reopened.
property string eap: "peap-mschapv2"
property string identity: ""
property string password: ""
property string caPath: ""
readonly property bool ready: root.identity.trim() !== "" && root.password !== ""
signal submitted(eap: string, identity: string, password: string, caPath: string)
width: parent ? parent.width : 620
spacing: 0
OptionPickerRow {
width: parent.width
label: "Authentication"
detail: "What the network expects. Whoever runs it publishes this."
options: [
{
value: "peap-mschapv2",
label: "PEAP · MSCHAPv2",
detail: "The usual choice for university and workplace networks"
},
{
value: "ttls-pap",
label: "TTLS · PAP",
detail: "For networks built around a plain-text inner method"
}
]
current: root.eap
onPicked: value => root.eap = String(value)
}
TextFieldRow {
width: parent.width
label: "Identity"
detail: "The username the network knows you by, often an email address"
placeholder: "[email protected]"
text: root.identity
enabled: !root.busy
onAccepted: value => root.identity = value
}
PasswordRow {
width: parent.width
label: "Password"
detail: "Handed to NetworkManager directly. It is never written to a command line."
placeholder: "Password for " + (root.ssid !== "" ? root.ssid : "this network")
onChanged: value => root.password = value
}
TextFieldRow {
width: parent.width
label: "CA certificate"
detail: "Optional. A path to the certificate that proves the network is the one it claims to be."
placeholder: "/etc/ssl/certs/example.pem"
text: root.caPath
enabled: !root.busy
onAccepted: value => root.caPath = value
}
ActionRow {
width: parent.width
label: "Connect to " + (root.ssid !== "" ? root.ssid : "this network")
detail: root.ready
? "NetworkManager saves this network, so it comes back on its own next time"
: "An identity and a password are needed before this network will answer"
action: root.busy ? "Connecting…" : "Connect"
enabled: root.ready && !root.busy
divider: false
onTriggered: root.submitted(root.eap, root.identity.trim(), root.password, root.caPath.trim())
}
}
@@ -25,6 +25,96 @@ SettingsPage {
property string confirmingRemoval: "" property string confirmingRemoval: ""
property bool confirmingRange: false 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() Component.onCompleted: Firewall.refresh()
TextRow { TextRow {
@@ -180,7 +270,9 @@ SettingsPage {
: "Anything relying on this service stops being reachable.") : "Anything relying on this service stops being reachable.")
: "Allowed by name, so it works whatever the port range says" : "Allowed by name, so it works whatever the port range says"
controlWidth: 210 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 { Row {
anchors.right: parent.right 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. // Shown, never edited.
TextRow { TextRow {
visible: (Firewall.zone?.richRules ?? []).length > 0 visible: (Firewall.zone?.richRules ?? []).length > 0
@@ -222,26 +388,176 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Zones" 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 { Repeater {
model: Object.keys(Firewall.activeZones ?? ({})) model: root.zonedInterfaces
delegate: Column {
id: placement
delegate: TextRow {
required property var modelData required property var modelData
required property int index 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 width: parent.width
label: String(modelData)
detail: "Applied to " + (Firewall.activeZones[String(modelData)] ?? []).join(", ") OptionPickerRow {
value: String(modelData) === Firewall.defaultZone ? "Default" : "" width: parent.width
divider: true 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 { 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" label: "Default for new connections"
detail: "Used when a network does not ask for a particular zone" 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 divider: false
} }
} }
@@ -408,6 +408,11 @@ SettingsPage {
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
} }
// Lands on System rather than Network. This button used to open the
// Network panel as a generic front door, which stopped being true
// the moment Connections absorbed VPN, proxies, hotspot and
// enterprise Wi-Fi: sending someone to GNOME for a page Panama now
// owns is exactly what gnome-handoff-contract exists to catch.
SettingsButton { SettingsButton {
id: gnomeSettingsButton id: gnomeSettingsButton
anchors.right: parent.right anchors.right: parent.right
@@ -416,9 +421,9 @@ SettingsPage {
activeFocusOnTab: true activeFocusOnTab: true
border.width: activeFocus ? 2 : 1 border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: SystemSettings.openGnomePanel("network") onClicked: SystemSettings.openGnomePanel("system")
Keys.onReturnPressed: SystemSettings.openGnomePanel("network") Keys.onReturnPressed: SystemSettings.openGnomePanel("system")
Keys.onSpacePressed: SystemSettings.openGnomePanel("network") Keys.onSpacePressed: SystemSettings.openGnomePanel("system")
} }
} }
@@ -29,6 +29,43 @@ SettingsPage {
readonly property bool manualReady: /^(ipp|ipps|socket):\/\/\S+$/.test(root.manualUri) readonly property bool manualReady: /^(ipp|ipps|socket):\/\/\S+$/.test(root.manualUri)
&& root.manualName.trim() !== "" && root.manualName.trim() !== ""
// The two defaults a queue can be told to change, dressed for reading. The
// values are the helper's closed vocabulary and nothing else reaches
// lpadmin; the choices come from what the printer said it supports, so a
// queue that cannot print two-sided never grows a two-sided dropdown.
//
// Empty while get-options is still on its way, which is why the rows are
// gated on the list rather than on the current value: an empty dropdown
// above a printer is worse than no dropdown at all.
function mediaChoices(reported: var): var {
const detail = {
"Letter": "8.5 × 11 in",
"A4": "210 × 297 mm",
"Legal": "8.5 × 14 in"
};
return (reported?.choices?.media ?? []).map(choice => ({
value: String(choice),
label: String(choice),
detail: detail[String(choice)] ?? ""
}));
}
function sidesChoices(reported: var): var {
const named = {
"one-sided": { label: "Off", detail: "One page per sheet" },
"two-sided-long-edge": { label: "Long edge", detail: "Bound like a book" },
"two-sided-short-edge": { label: "Short edge", detail: "Bound like a notepad" }
};
return (reported?.choices?.sides ?? []).map(choice => {
const known = named[String(choice)];
return {
value: String(choice),
label: known ? known.label : String(choice),
detail: known ? known.detail : ""
};
});
}
Component.onCompleted: Printers.refresh() Component.onCompleted: Printers.refresh()
TextRow { TextRow {
@@ -80,9 +117,18 @@ SettingsPage {
} }
Column { Column {
id: printerBody
width: printerBlock.width width: printerBlock.width
visible: printerBlock.open visible: printerBlock.open
// What this queue is set to, as CUPS reports it. Null until
// the read comes back, and a printer that reports neither
// key simply has no dropdowns -- an option offered for a
// printer that does not have it is a setting that silently
// does nothing.
readonly property var reported: Printers.optionsFor(printerBlock.name)
TextRow { TextRow {
width: printerBlock.width width: printerBlock.width
label: "Model" label: "Model"
@@ -90,6 +136,28 @@ SettingsPage {
value: String(printerBlock.modelData.makeAndModel ?? "Unknown") value: String(printerBlock.modelData.makeAndModel ?? "Unknown")
} }
OptionPickerRow {
width: printerBlock.width
visible: root.mediaChoices(printerBody.reported).length > 0
label: "Paper size"
detail: "What applications print onto unless they ask for something else"
enabled: !Printers.busy
options: root.mediaChoices(printerBody.reported)
current: String(printerBody.reported?.options?.media ?? "")
onPicked: value => Printers.setOption(printerBlock.name, "media", String(value))
}
OptionPickerRow {
width: printerBlock.width
visible: root.sidesChoices(printerBody.reported).length > 0
label: "Two-sided"
detail: "Long edge is the usual choice for text; short edge flips like a notepad"
enabled: !Printers.busy
options: root.sidesChoices(printerBody.reported)
current: String(printerBody.reported?.options?.sides ?? "")
onPicked: value => Printers.setOption(printerBlock.name, "sides", String(value))
}
ActionRow { ActionRow {
width: printerBlock.width width: printerBlock.width
visible: !printerBlock.modelData.isDefault visible: !printerBlock.modelData.isDefault
@@ -173,22 +241,47 @@ SettingsPage {
Repeater { Repeater {
model: Printers.jobs model: Printers.jobs
delegate: ActionRow { delegate: SettingRow {
id: jobRow id: jobRow
required property var modelData required property var modelData
required property int index required property int index
readonly property bool held: Printers.isHeld(jobRow.modelData)
width: parent.width width: parent.width
label: String(jobRow.modelData.name ?? "Untitled") label: String(jobRow.modelData.name ?? "Untitled")
detail: String(jobRow.modelData.printer ?? "") + " · " detail: String(jobRow.modelData.printer ?? "") + " · "
+ String(jobRow.modelData.state ?? "") + String(jobRow.modelData.state ?? "")
+ (Number(jobRow.modelData.pages ?? 0) > 0 + (Number(jobRow.modelData.pages ?? 0) > 0
? " · " + jobRow.modelData.pages + " pages" : "") ? " · " + jobRow.modelData.pages + " pages" : "")
action: "Cancel" controlWidth: 175
enabled: !Printers.busy
divider: jobRow.index < Printers.jobs.length - 1 divider: jobRow.index < Printers.jobs.length - 1
onTriggered: Printers.cancel(Number(jobRow.modelData.id))
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
// Holding is the answer to "stop this one, I have not
// finished with it" -- cancelling was the only way out of
// the queue before, and it is not reversible.
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: jobRow.held ? "Release" : "Hold"
enabled: !Printers.busy
onClicked: jobRow.held
? Printers.release(Number(jobRow.modelData.id))
: Printers.hold(Number(jobRow.modelData.id))
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: "Cancel"
enabled: !Printers.busy
onClicked: Printers.cancel(Number(jobRow.modelData.id))
}
}
} }
} }
@@ -201,42 +294,26 @@ SettingsPage {
} }
} }
// ── Nothing set up yet ───────────────────────────────────────────────────
SettingsCard {
visible: Printers.scanned && !Printers.anyPrinters
title: "No printers yet"
subtitle: "Printers that announce themselves on your network appear here on their own."
ActionRow {
label: "Search the network"
detail: Printers.searching
? "Listening for printers that announce themselves…"
: (Printers.searched
? Printers.addable.length + " found"
: "Looks for printers over mDNS, the same way phones and laptops find them")
action: Printers.searching ? "Searching…" : "Search"
enabled: !Printers.searching
divider: false
onTriggered: Printers.search()
}
}
// ── Adding ─────────────────────────────────────────────────────────────── // ── Adding ───────────────────────────────────────────────────────────────
//
// One card, not two. "No printers yet" and "Add a printer" carried the same
// Search row with two different wordings, so a machine with no printers
// offered the identical button twice under two headings.
SettingsCard { SettingsCard {
title: "Add a printer" title: Printers.anyPrinters ? "Add a printer" : "No printers yet"
subtitle: "Driverless printers only. One that needs a manufacturer driver has to be set up with the system printer tool." subtitle: Printers.anyPrinters
? "Driverless printers only. One that needs a manufacturer driver has to be set up with the system printer tool."
: "Printers on this network that speak modern IPP appear here on their own. Driverless only."
ActionRow { ActionRow {
visible: Printers.anyPrinters
label: "Search the network" label: "Search the network"
detail: Printers.searching detail: Printers.searching
? "Listening for printers that announce themselves…" ? "Listening for printers that announce themselves…"
: (Printers.searched : (Printers.searched
? Printers.addable.length + " printer" ? Printers.addable.length + " printer"
+ (Printers.addable.length === 1 ? "" : "s") + " found that are not set up here" + (Printers.addable.length === 1 ? "" : "s") + " found that are not set up here"
: "Looks for printers over mDNS") : "Looks for printers over mDNS, the same way phones and laptops find them")
action: Printers.searching ? "Searching…" : "Search" action: Printers.searching ? "Searching…" : "Search"
enabled: !Printers.searching enabled: !Printers.searching
onTriggered: Printers.search() onTriggered: Printers.search()
@@ -22,18 +22,21 @@ SettingsPage {
Component.onCompleted: Sharing.refresh() Component.onCompleted: Sharing.refresh()
TextRow {
visible: Sharing.lastError !== ""
label: "Sharing needs attention"
detail: Sharing.lastError
value: ""
divider: false
}
SettingsCard { SettingsCard {
title: "This machine" title: "This machine"
subtitle: "The name other machines see." subtitle: "The name other machines see."
// The error belongs to the machine, not to a floating banner above the
// title: a bare row above the first card read as part of the page
// furniture, which is exactly what an error must not do.
TextRow {
width: parent.width
visible: Sharing.lastError !== ""
label: "Sharing needs attention"
detail: Sharing.lastError
value: ""
}
TextFieldRow { TextFieldRow {
label: "Network name" label: "Network name"
detail: "Used for ssh and for anything else that finds this machine by name" detail: "Used for ssh and for anything else that finds this machine by name"
@@ -47,32 +50,37 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Remote login" title: "Remote login"
subtitle: "Sign in to a terminal on this machine over SSH." subtitle: Sharing.remoteLoginOn
? "Sign in to a terminal on this machine with: ssh " + Sharing.networkName
: "Sign in to a terminal on this machine over SSH."
// Port and the password-sign-in claim used to be two rows of their own
// below this one. They describe this switch rather than standing beside
// it, so they read as its detail line -- and the card is three rows
// shorter for saying the same things.
//
// "Keys only" is reported from sshd's configuration rather than assumed:
// claiming it on a machine that actually accepts passwords would be a
// security claim this page cannot back up.
SwitchRow { SwitchRow {
label: "Allow remote login" label: "Allow remote login"
detail: Sharing.remoteLogin?.installed === true detail: Sharing.remoteLogin?.installed === true
? (Sharing.remoteLoginOn ? "OpenSSH · port " + String(Sharing.remoteLogin?.port ?? "22")
? "Running, and starts automatically at boot" + " · password sign-in: " + Sharing.passwordLoginSummary().toLowerCase()
: "Not running") + (Sharing.remoteLoginOn ? " · starts at boot" : "")
: "OpenSSH server is not installed" : "OpenSSH server is not installed"
checked: Sharing.remoteLoginOn checked: Sharing.remoteLoginOn
enabled: !Sharing.busy && Sharing.remoteLogin?.installed === true enabled: !Sharing.busy && Sharing.remoteLogin?.installed === true
divider: Sharing.remoteLoginOn
onToggled: value => Sharing.setRemoteLogin(value) onToggled: value => Sharing.setRemoteLogin(value)
} }
TextRow {
visible: Sharing.remoteLoginOn
label: "Connect with"
detail: "From another machine on your network"
value: "ssh " + Sharing.networkName
}
TextRow { TextRow {
visible: Sharing.remoteLoginOn && Sharing.remoteSessions.length === 0 visible: Sharing.remoteLoginOn && Sharing.remoteSessions.length === 0
label: "Nobody is signed in" label: "Nobody is signed in remotely"
detail: "Remote login is on, and no one is connected from another machine." detail: "Remote login is on, and no one is connected from another machine."
value: "" value: ""
divider: false
} }
Repeater { Repeater {
@@ -80,30 +88,14 @@ SettingsPage {
delegate: TextRow { delegate: TextRow {
required property var modelData required property var modelData
required property int index
width: parent.width width: parent.width
label: String(modelData.user ?? "") + " is signed in from " + String(modelData.from ?? "") label: String(modelData.user ?? "") + " is signed in from " + String(modelData.from ?? "")
detail: "Since " + String(modelData.since ?? "") + " · " + String(modelData.line ?? "") detail: "Since " + String(modelData.since ?? "") + " · " + String(modelData.line ?? "")
value: "" value: ""
divider: index < Sharing.remoteSessions.length - 1
} }
} }
TextRow {
visible: Sharing.remoteLoginOn
label: "Port"
detail: "Where the SSH server is listening"
value: String(Sharing.remoteLogin?.port ?? "22")
}
// Reported from the configuration rather than assumed. Saying "keys
// only" on a machine that actually accepts passwords would be a
// security claim this page cannot back up.
TextRow {
visible: Sharing.remoteLoginOn
label: "Password sign-in"
detail: Sharing.passwordLoginSummary()
value: ""
divider: false
}
} }
SettingsCard { SettingsCard {
@@ -113,11 +105,12 @@ SettingsPage {
SwitchRow { SwitchRow {
label: "Allow remote desktop" label: "Allow remote desktop"
detail: Sharing.remoteDesktop?.available === true detail: Sharing.remoteDesktop?.available === true
? (Sharing.remoteDesktopOn ? "RDP · port " + String(Sharing.remoteDesktop?.port ?? "3389") + " · "
? "Running for your session" + (Sharing.remoteDesktopOn
? "running for your session"
: (Sharing.remoteDesktop?.hasCredentials === true : (Sharing.remoteDesktop?.hasCredentials === true
? "Not running" ? "not running"
: "Set a username and password before turning this on")) : "set a username and password before turning this on"))
: "Remote desktop support is not installed" : "Remote desktop support is not installed"
checked: Sharing.remoteDesktopOn checked: Sharing.remoteDesktopOn
enabled: !Sharing.busy enabled: !Sharing.busy
@@ -149,12 +142,16 @@ SettingsPage {
// terminal, never into this page. grdctl prompts for it on a terminal // terminal, never into this page. grdctl prompts for it on a terminal
// and crashes without one, and passing it as an argument would publish // and crashes without one, and passing it as an argument would publish
// it through /proc to every process on this machine. // it through /proc to every process on this machine.
//
// The row says why rather than naming the mechanism: "opens kitty" is
// an implementation detail, and the reason -- the password never passes
// through Panama -- is the part worth reading.
ActionRow { ActionRow {
visible: Sharing.remoteDesktop?.available === true visible: Sharing.remoteDesktop?.available === true
label: "Credentials" label: "Credentials"
detail: Sharing.remoteDesktop?.hasCredentials === true detail: Sharing.remoteDesktop?.hasCredentials === true
? "Stored in the login keyring · setting new ones opens a terminal to type into" ? "Stored in the login keyring · set in a terminal so the password never passes through Panama"
: "None stored yet · remote desktop cannot be turned on without them" : "None stored yet · set in a terminal so the password never passes through Panama"
action: "Set…" action: "Set…"
enabled: !Sharing.busy enabled: !Sharing.busy
onTriggered: Sharing.setRdpCredentials(Quickshell.env("USER") || "") onTriggered: Sharing.setRdpCredentials(Quickshell.env("USER") || "")
@@ -176,11 +173,15 @@ SettingsPage {
title: "File and media sharing" title: "File and media sharing"
subtitle: "Sharing folders and media needs software this machine does not necessarily have." subtitle: "Sharing folders and media needs software this machine does not necessarily have."
// Not a switch, because there is nothing behind it. Naming what
// installing Samba would unlock is the difference between a dead row
// and an answer -- the panel this replaces shows a switch that silently
// does nothing.
TextRow { TextRow {
label: "Share folders on the network" label: "Share folders on the network"
detail: Sharing.fileSharing?.installed === true detail: Sharing.fileSharing?.installed === true
? "Samba is installed" ? "Samba is installed, so folders can be published to Windows, macOS and Linux machines alike"
: "Needs Samba, which is not installed. Settings does not install software." : "Samba is not installed — install it and this becomes a switch. Settings does not install software."
value: Sharing.fileSharing?.installed === true ? "Available" : "Not installed" value: Sharing.fileSharing?.installed === true ? "Available" : "Not installed"
} }
@@ -2,12 +2,26 @@
// //
// The quick settings version is a popover: a compact list you glance at. This // The quick settings version is a popover: a compact list you glance at. This
// is the one you sit in front of when a network is not behaving, so each row // is the one you sit in front of when a network is not behaving, so each row
// carries what you would otherwise open a terminal to find out signal, // carries what you would otherwise open a terminal to find out -- signal,
// security, and whether it is a network this machine already knows. // security, and whether it is a network this machine already knows.
// //
// Joining a secured network reveals an inline password field rather than // The connected network opens: addresses, whether it comes back on its own,
// failing silently, which is the one interaction the popover already got right // whether this machine shows the same hardware address to it every time, a QR
// and is worth keeping identical. // code for a guest, and the way out. Those five were the whole reason this page
// still had a door back to GNOME's Wi-Fi panel.
//
// Three ways in, and they are genuinely different networks rather than three
// styles of the same one:
//
// * open or already known -- connect, nothing to type;
// * WPA with a passphrase -- an inline field, revealed on the row you clicked
// rather than in a dialog over a tiled window;
// * 802.1X enterprise -- a form, because a passphrase field cannot join
// eduroam and pretending otherwise is how people ended up in a terminal.
//
// Forgetting is two-stage and says what it costs, per the danger pattern the
// rest of Settings uses: the arming press changes the row's copy, and only the
// second press does anything.
import QtQuick import QtQuick
import Quickshell import Quickshell
@@ -21,25 +35,76 @@ Column {
spacing: 0 spacing: 0
// SSID whose password field is open, and the last failure. // Exactly one of these is non-empty at a time, so the panel never shows two
// ways to join the same network or two networks half-open at once.
property string expandedFor: ""
property string passwordFor: "" property string passwordFor: ""
property string enterpriseFor: ""
property string confirmingForget: ""
property string failedSsid: "" property string failedSsid: ""
property string failedText: "" property string failedText: ""
// The list shows what matters and folds the rest: connected and saved
// networks always render (they lead the sort, so a slice keeps them), and
// strangers fill the remaining slots up to the cap. Everything else waits
// behind the "more networks" row -- an apartment building's worth of
// neighbors' SSIDs is noise, not a list.
property bool showAll: false
readonly property int visibleCap: 6
readonly property var shown: {
const list = Connectivity.networks;
if (root.showAll || list.length <= root.visibleCap)
return list;
const pinned = list.filter(network => network.connected || network.known).length;
return list.slice(0, Math.max(root.visibleCap, pinned));
}
readonly property int hiddenCount: Connectivity.networks.length - root.shown.length
function isEnterprise(network: var): bool {
return Connectivity.securityLabel(network) === "Enterprise";
}
function closeAll(): void {
root.expandedFor = "";
root.passwordFor = "";
root.enterpriseFor = "";
root.confirmingForget = "";
}
// One click on a row means whatever that row's state makes it mean. The
// connected network opens rather than reconnecting to itself.
function activate(network: var): void { function activate(network: var): void {
root.failedSsid = ""; root.failedSsid = "";
if (network.connected) const name = network.name;
if (network.connected) {
const wasOpen = root.expandedFor === name;
root.closeAll();
root.expandedFor = wasOpen ? "" : name;
return; return;
}
if (root.isEnterprise(network)) {
const wasOpen = root.enterpriseFor === name;
root.closeAll();
root.enterpriseFor = wasOpen ? "" : name;
return;
}
if (network.known || !Connectivity.isSecured(network)) { if (network.known || !Connectivity.isSecured(network)) {
root.passwordFor = ""; root.closeAll();
network.connect(); network.connect();
return; return;
} }
root.passwordFor = root.passwordFor === network.name ? "" : network.name;
const wasOpen = root.passwordFor === name;
root.closeAll();
root.passwordFor = wasOpen ? "" : name;
} }
Repeater { Repeater {
model: Connectivity.networks model: root.shown
Column { Column {
id: entry id: entry
@@ -47,6 +112,23 @@ Column {
required property var modelData required property var modelData
required property int index required property int index
readonly property string ssid: entry.modelData.name || ""
readonly property bool open: root.expandedFor === entry.ssid && entry.ssid !== ""
readonly property bool joining: root.passwordFor === entry.ssid && entry.ssid !== ""
readonly property bool enterprising: root.enterpriseFor === entry.ssid && entry.ssid !== ""
readonly property bool confirming: root.confirmingForget === entry.ssid && entry.ssid !== ""
// Addresses for the connected network, once the helper has answered.
// Null means "not read yet", which the details grid renders as such
// rather than as "no address".
readonly property var details: entry.modelData.connected && entry.ssid !== ""
? NetworkTools.detailsFor(entry.ssid) : null
// The saved profile behind this SSID, if it holds a passphrase a QR
// code could carry. Networks with no stored key cannot be shared.
readonly property var shareEntry: WifiShare.shareable.find(
candidate => String(candidate.ssid ?? "") === entry.ssid) ?? null
width: parent.width width: parent.width
SettingRow { SettingRow {
@@ -60,12 +142,20 @@ Column {
else if (entry.modelData.known) else if (entry.modelData.known)
bits.push("Saved"); bits.push("Saved");
bits.push(Connectivity.signalLabel(entry.modelData.signalStrength)); bits.push(Connectivity.signalLabel(entry.modelData.signalStrength));
bits.push(Connectivity.securityLabel(entry.modelData)); bits.push(root.isEnterprise(entry.modelData)
? "Enterprise (802.1X)"
: Connectivity.securityLabel(entry.modelData));
return bits.join(" · "); return bits.join(" · ");
} }
divider: entry.index < Connectivity.networks.length - 1 || root.passwordFor === entry.modelData.name // When the drawer is open its own last row carries the hairline,
// so the header does not draw one immediately above it.
divider: entry.open
? false
: (entry.index < root.shown.length - 1
|| root.hiddenCount > 0 || root.showAll
|| entry.joining || entry.enterprising)
controlWidth: 190 controlWidth: 190
activatable: !entry.modelData.connected activatable: true
onActivated: root.activate(entry.modelData) onActivated: root.activate(entry.modelData)
Row { Row {
@@ -73,38 +163,162 @@ Column {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
spacing: 7 spacing: 7
Text {
anchors.verticalCenter: parent.verticalCenter
visible: entry.modelData.connected
text: "Connected"
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
SettingsButton { SettingsButton {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
visible: entry.modelData.connected visible: entry.modelData.connected
text: "Disconnect" text: "Disconnect"
onClicked: entry.modelData.disconnect() onClicked: {
root.closeAll();
entry.modelData.disconnect();
}
} }
SettingsButton { SettingsButton {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
visible: !entry.modelData.connected visible: !entry.modelData.connected
text: entry.modelData.known ? "Connect" : "Join" text: root.isEnterprise(entry.modelData)
? (entry.enterprising ? "Cancel" : "Join…")
: (entry.modelData.known ? "Connect" : "Join")
onClicked: root.activate(entry.modelData) onClicked: root.activate(entry.modelData)
} }
// Only the connected row opens, so only it gets a caret.
Text {
anchors.verticalCenter: parent.verticalCenter
visible: entry.modelData.connected
text: entry.open ? "▴" : "▾"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
} }
} }
// The password field for this network, when it is the one being // ── The connected network, opened ────────────────────────────────
// joined. Inline rather than a dialog: a dialog over a tiled window
// is a worse place to type than the row you just clicked. Column {
width: parent.width
visible: entry.open
ConnectionDetails {
width: parent.width
details: entry.details
}
// These two describe a saved profile, and until the helper has
// answered this panel does not know what the profile says. A
// switch drawn on a guess is a switch that lies about the state
// it is reporting.
SwitchRow {
width: parent.width
visible: !!entry.details
label: "Connect automatically"
detail: "Rejoin this network whenever it is in range"
checked: entry.details?.autoconnect !== false
enabled: !NetworkTools.busy
onToggled: value => NetworkTools.setAutoconnect(entry.ssid, value)
}
SwitchRow {
width: parent.width
visible: !!entry.details
label: "Randomize MAC address"
detail: entry.details?.macRandomized === true
? "This network sees a made-up hardware address, so it cannot follow this machine between visits. Takes effect on the next reconnect."
: "Show this network a made-up hardware address instead of the adapter's real one. Takes effect on the next reconnect."
checked: entry.details?.macRandomized === true
enabled: !NetworkTools.busy
onToggled: value => NetworkTools.setMacRandom(entry.ssid, value)
}
ActionRow {
width: parent.width
visible: entry.shareEntry !== null
label: "Share this network"
detail: WifiShare.sharing === String(entry.shareEntry?.name ?? "")
? "Anyone who can see this screen can join"
: "Shows a QR code a phone can scan, so nobody has to read the password out"
action: WifiShare.sharing === String(entry.shareEntry?.name ?? "")
? "Hide" : "Show code"
onTriggered: WifiShare.sharing === String(entry.shareEntry?.name ?? "")
? WifiShare.stopSharing()
: WifiShare.share(String(entry.shareEntry?.name ?? ""))
}
// Drawn at its natural size on a white plate: a QR code inverted
// or tinted to match a dark theme is unreliable to scan, and this
// one has exactly one job.
Item {
width: parent.width
visible: entry.shareEntry !== null
&& WifiShare.sharing === String(entry.shareEntry?.name ?? "")
&& WifiShare.imagePath !== ""
implicitHeight: visible ? plate.height + 20 : 0
Rectangle {
id: plate
anchors.horizontalCenter: parent.horizontalCenter
y: 10
width: 208
height: 208
radius: 10
color: "white"
Image {
anchors.centerIn: parent
width: 184
height: 184
smooth: false
fillMode: Image.PreserveAspectFit
cache: false
source: WifiShare.imagePath !== "" ? "file://" + WifiShare.imagePath : ""
}
}
}
SettingRow {
width: parent.width
label: "Forget this network"
detail: entry.confirming
? "This disconnects now and deletes the saved password. Rejoining " + entry.ssid + " means typing it again."
: "Remove the saved profile for " + entry.ssid
controlWidth: 200
divider: entry.index < root.shown.length - 1
|| root.hiddenCount > 0 || root.showAll
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: entry.confirming ? "Keep it" : "Forget…"
enabled: !NetworkTools.busy
onClicked: root.confirmingForget = entry.confirming ? "" : entry.ssid
}
SettingsButton {
visible: entry.confirming
text: "Forget"
tone: "danger"
enabled: !NetworkTools.busy
onClicked: {
root.closeAll();
NetworkTools.forget(entry.ssid);
}
}
}
}
}
// ── A passphrase, for the network being joined ───────────────────
//
// Inline rather than a dialog: a dialog over a tiled window is a
// worse place to type than the row you just clicked.
Item { Item {
width: parent.width width: parent.width
height: root.passwordFor === entry.modelData.name ? 54 : 0 height: entry.joining ? 54 : 0
visible: height > 0 visible: height > 0
clip: true clip: true
@@ -141,9 +355,33 @@ Column {
} }
} }
// ── 802.1X, for the networks a passphrase cannot reach ───────────
// Loaded rather than merely hidden, so closing the form destroys it
// and the password that was typed into it. A hidden form keeps its
// field, which is both a stale value on the way back in and a
// passphrase sitting in a live object for no reason.
Loader {
id: enterpriseLoader
width: parent.width
active: entry.enterprising
visible: enterpriseLoader.active
sourceComponent: EnterpriseJoinForm {
ssid: entry.ssid
busy: NetworkTools.busy
onSubmitted: (eap, identity, secret, caPath) => {
NetworkTools.joinEnterprise(entry.ssid, eap, identity, secret, caPath);
root.enterpriseFor = "";
}
}
}
Text { Text {
width: parent.width width: parent.width
visible: root.failedSsid === entry.modelData.name visible: root.failedSsid === entry.ssid && entry.ssid !== ""
leftPadding: 2 leftPadding: 2
bottomPadding: 8 bottomPadding: 8
text: root.failedText text: root.failedText
@@ -155,14 +393,28 @@ Column {
Connections { Connections {
target: entry.modelData target: entry.modelData
function onConnectionFailed(reason): void { function onConnectionFailed(reason): void {
root.failedSsid = entry.modelData.name; root.failedSsid = entry.ssid;
root.failedText = Connectivity.connectionFailureText(reason); root.failedText = Connectivity.connectionFailureText(reason);
root.passwordFor = entry.modelData.name; if (!root.isEnterprise(entry.modelData))
root.passwordFor = entry.ssid;
} }
} }
} }
} }
SettingRow {
width: parent.width
visible: root.hiddenCount > 0
|| (root.showAll && Connectivity.networks.length > root.visibleCap)
label: root.showAll
? "Show fewer networks"
: root.hiddenCount + (root.hiddenCount === 1 ? " more network" : " more networks")
detail: root.showAll ? "" : "Weaker signals, folded to keep the list short"
activatable: true
onActivated: root.showAll = !root.showAll
divider: false
}
SettingRow { SettingRow {
width: parent.width width: parent.width
visible: Connectivity.networks.length === 0 visible: Connectivity.networks.length === 0
@@ -83,6 +83,8 @@ GradientSliderRow 1.0 GradientSliderRow.qml
OptionPickerRow 1.0 OptionPickerRow.qml OptionPickerRow 1.0 OptionPickerRow.qml
WifiPanel 1.0 WifiPanel.qml WifiPanel 1.0 WifiPanel.qml
BluetoothPanel 1.0 BluetoothPanel.qml BluetoothPanel 1.0 BluetoothPanel.qml
ConnectionDetails 1.0 ConnectionDetails.qml
EnterpriseJoinForm 1.0 EnterpriseJoinForm.qml
PasswordField 1.0 PasswordField.qml PasswordField 1.0 PasswordField.qml
AudioBalance 1.0 AudioBalance.qml AudioBalance 1.0 AudioBalance.qml
SoundDeviceList 1.0 SoundDeviceList.qml SoundDeviceList 1.0 SoundDeviceList.qml
+67 -3
View File
@@ -13,6 +13,7 @@ publish on all interfaces.
Changes go through firewall-cmd, which is polkit-aware, so they prompt. Changes go through firewall-cmd, which is polkit-aware, so they prompt.
panama-firewall snapshot panama-firewall snapshot
panama-firewall zone-info ZONE
panama-firewall add-service NAME | remove-service NAME panama-firewall add-service NAME | remove-service NAME
panama-firewall add-port PORT/PROTO | remove-port PORT/PROTO panama-firewall add-port PORT/PROTO | remove-port PORT/PROTO
panama-firewall set-zone INTERFACE ZONE panama-firewall set-zone INTERFACE ZONE
@@ -305,6 +306,57 @@ def snapshot() -> dict:
} }
# Zone targets, as a sentence rather than firewalld's vocabulary. "default" is
# the one that catches people out: it does not mean "the default zone", it means
# "reject anything no rule allowed", which is the answer someone browsing zones
# is actually looking for.
TARGETS = {
"": "anything no rule allows is rejected",
"default": "anything no rule allows is rejected",
"%%REJECT%%": "anything no rule allows is rejected, with a refusal sent back",
"REJECT": "anything no rule allows is rejected, with a refusal sent back",
"DROP": "anything no rule allows is dropped without an answer",
"ACCEPT": "anything not explicitly blocked is allowed in",
}
def zone_info(name: str) -> dict:
"""One zone, described -- read-only, for browsing before choosing.
Separate from `snapshot` because the zone browser asks about zones this
machine is not using, and a snapshot only ever describes the active ones.
Nothing here changes anything, so it needs no authorization and no confirm.
"""
require(ZONE, name, "That is not a zone.")
known = firewall("--get-zones").split()
if name not in known:
raise BoundaryError("There is no zone by that name.")
detail = zone_detail(name)
if not detail:
raise BoundaryError("That zone could not be read.")
services = detail.get("services", [])
ports = detail.get("ports", [])
parts = []
parts.append(f"{len(services)} service{'' if len(services) == 1 else 's'}")
parts.append(f"{len(ports)} port rule{'' if len(ports) == 1 else 's'}")
summary = ", ".join(parts) + "; " + TARGETS.get(
detail.get("target", ""), "custom handling for anything no rule allows")
return {
"zone": name,
"services": services,
"ports": ports,
"interfaces": detail.get("interfaces", []),
"target": detail.get("target", ""),
"richRules": detail.get("richRules", []),
"isDefault": name == firewall("--get-default-zone"),
"summary": summary,
"error": "",
}
def require(pattern: re.Pattern, value: str, message: str) -> str: def require(pattern: re.Pattern, value: str, message: str) -> str:
if not pattern.fullmatch(value or ""): if not pattern.fullmatch(value or ""):
raise BoundaryError(message) raise BoundaryError(message)
@@ -333,6 +385,18 @@ def main(arguments: list[str]) -> int:
print(json.dumps(snapshot(), separators=(",", ":"))) print(json.dumps(snapshot(), separators=(",", ":")))
return 0 return 0
# Read-only, so it answers with its own shape rather than a snapshot --
# the browser wants one zone described, not the machine's exposure.
if len(arguments) == 2 and arguments[0] == "zone-info":
try:
answer = zone_info(arguments[1])
except BoundaryError as error:
answer = {"zone": arguments[1], "services": [], "ports": [],
"interfaces": [], "target": "", "richRules": [],
"isDefault": False, "summary": "", "error": str(error)}
print(json.dumps(answer, separators=(",", ":")))
return 0
if len(arguments) == 2 and arguments[0] in ("add-service", "remove-service"): if len(arguments) == 2 and arguments[0] in ("add-service", "remove-service"):
name = require(SERVICE, arguments[1], "That is not a service name.") name = require(SERVICE, arguments[1], "That is not a service name.")
verb = "--add-service" if arguments[0] == "add-service" else "--remove-service" verb = "--add-service" if arguments[0] == "add-service" else "--remove-service"
@@ -351,9 +415,9 @@ def main(arguments: list[str]) -> int:
firewall(f"--set-default-zone={zone}", timeout=120) firewall(f"--set-default-zone={zone}", timeout=120)
else: else:
raise BoundaryError( raise BoundaryError(
"Usage: panama-firewall snapshot | add-service NAME | remove-service NAME | " "Usage: panama-firewall snapshot | zone-info ZONE | add-service NAME | "
"add-port PORT/PROTO | remove-port PORT/PROTO | set-zone INTERFACE ZONE | " "remove-service NAME | add-port PORT/PROTO | remove-port PORT/PROTO | "
"set-default-zone ZONE") "set-zone INTERFACE ZONE | set-default-zone ZONE")
except BoundaryError as error: except BoundaryError as error:
try: try:
state = snapshot() state = snapshot()
+866
View File
@@ -0,0 +1,866 @@
#!/usr/bin/env python3
"""The parts of networking Quickshell has no surface for.
services/Connectivity.qml is pinned pure-native: Wi-Fi scanning, joining and
Bluetooth pairing already work over DBus through Quickshell.Networking, and
nothing there may shell out. But NetworkManager knows a great deal that module
never exposes -- a connection's addresses, whether it comes back by itself,
whether its MAC is randomised, VPN profile import, hotspots, enterprise
authentication -- and the system proxy and the radio kill switches are not
NetworkManager's at all. This helper is where all of that lives, so that the
native service stays native.
panama-network details CONNECTION
panama-network forget CONNECTION
panama-network set-autoconnect CONNECTION true|false
panama-network set-mac-random CONNECTION true|false
panama-network import-vpn FILE
panama-network hotspot start SSID | hotspot stop | hotspot status
panama-network join-enterprise SSID PROFILE IDENTITY [CA_CERT] (password on stdin)
panama-network proxy get
panama-network proxy set none
panama-network proxy set manual [HOST PORT]
panama-network proxy set auto [PAC_URL]
panama-network airplane status
panama-network airplane set true|false
SECRETS
Two rules, both enforced in code rather than by care:
* Nothing a page renders can contain a secret. Every profile NetworkManager
describes is read through `listing()`, which drops any property named as a
secret OR merely shaped like one -- so a field a future NetworkManager adds
is excluded before anybody notices it exists, rather than after.
* An enterprise password never appears in argv, which is world-readable
through /proc for the life of the process. It is read from stdin, and only
after the rest of the request has been validated: reading it first would
mean waiting on a password for a request that was always going to be
refused.
HOW THE ENTERPRISE PASSWORD REACHES NetworkManager
Preferred: libnm's GObject-introspection bindings, which build the profile in
memory and hand it to NetworkManager over D-Bus with AddConnection. The secret
is a value in a D-Bus message: never a command line, never a temporary file.
This machine has them (NM 1.56), so this is the live path.
The probe constructs a client rather than only importing the module, because
the question is not "are the bindings installed" but "can the password be
handed over on the bus" -- a machine with the typelib and a NetworkManager that
will not answer must fall through, not fail.
Fallback: a scripted `nmcli connection edit` session driven over stdin. nmcli's
editor takes `set 802-1x.password …` as input rather than as an argument, which
keeps the secret out of ps just the same. Second choice only because it depends
on the editor's prompt behaviour rather than on a stable API.
The hotspot password is different in kind: NetworkManager generates it, and it
is useless unless it is shown. It is returned once, by the verb that created or
looked up the hotspot, and never logged.
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path
# Connection names. Every command here is an argument list, never a shell
# string, so this is not defending against quoting: it is defending against
# argument confusion. A name beginning with '-' would be read by nmcli as an
# option, which is the one shape that turns a validated list back into an
# injection -- so a name must begin with a letter, digit or underscore. No
# slashes either, so a name can never be mistaken for or built into a path.
# The rest is broad but bounded, because real networks are called things like
# "Cafe: Guest" and "Bob's iPhone" and refusing those helps nobody.
NAME = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9 _.:+()@'&#!-]{0,62}$")
# An SSID is at most 32 bytes on the wire. Names that cannot fit are refused
# here rather than truncated silently into a network nobody can find.
SSID = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9 _.:+()@'&#!-]{0,31}$")
HOSTNAME = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9.-]{0,253}[A-Za-z0-9])?$")
PAC_URL = re.compile(r"^(https?|file)://[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]{1,500}$")
# The EAP profiles offered. A closed set, because "type your own EAP string"
# produces a profile that fails to authenticate with no way to tell why.
EAP_PROFILES = {
"peap-mschapv2": {"eap": "peap", "phase2": "mschapv2"},
"ttls-pap": {"eap": "ttls", "phase2": "pap"},
}
# VPN profile formats, by extension. NetworkManager's importer picks the plugin
# by type, so the extension is what decides -- and nothing else is accepted.
VPN_TYPES = {".conf": "wireguard", ".ovpn": "openvpn"}
# Properties that hold a secret, named so the filter is readable rather than
# only regular.
SECRET_PROPERTIES = frozenset({
"802-11-wireless-security.psk",
"802-11-wireless-security.leap-password",
"802-1x.password",
"802-1x.private-key-password",
"802-1x.phase2-private-key-password",
"802-1x.pin",
"wireguard.private-key",
"gsm.password",
"gsm.pin",
"ppp.password",
})
# And the shape of one, because the list above can only ever name the fields
# that existed when it was written. A property whose name reads like a
# credential is dropped before anything downstream can render it -- so a field
# NetworkManager adds in a future release is excluded by default rather than
# leaked until somebody notices.
SECRET_SHAPE = re.compile(
r"(password|passwd|secret|psk|passphrase|private-key|wep-key|leap|\.pin)", re.I)
# The hotspot's own profile. A fixed name so that stopping and restarting reuse
# one profile rather than accumulating "Hotspot 1", "Hotspot 2" forever, and so
# scripts/panama-wifi-qr can be pointed at it by name for the QR code.
HOTSPOT_CONNECTION = "Panama Hotspot"
# The MAC randomisation property, by connection type. nmcli's short aliases,
# because those are what a person reads in a bug report -- and because a profile
# that does not say what type it is still needs an answer, which for anything
# with a MAC worth randomising means Wi-Fi.
CLONED_MAC = {
"802-3-ethernet": "ethernet.cloned-mac-address",
"ethernet": "ethernet.cloned-mac-address",
}
CLONED_MAC_DEFAULT = "wifi.cloned-mac-address"
# Where a profile records the same setting when it is read back, either spelling.
CLONED_MAC_KEYS = ("wifi.cloned-mac-address", "802-11-wireless.cloned-mac-address",
"ethernet.cloned-mac-address", "802-3-ethernet.cloned-mac-address")
class BoundaryError(RuntimeError):
"""A user-visible validation or NetworkManager failure."""
def run(command: list[str], timeout: float = 15.0,
stdin_text: str | None = None) -> subprocess.CompletedProcess:
try:
return subprocess.run(command, capture_output=True, text=True,
timeout=timeout, check=False, input=stdin_text)
except (OSError, subprocess.TimeoutExpired) as error:
raise BoundaryError(f"{command[0]} did not answer.") from error
def tool(name: str, absent: str) -> str:
found = shutil.which(name)
if not found:
raise BoundaryError(absent)
return name
def nmcli(*arguments: str, timeout: float = 15.0,
stdin_text: str | None = None) -> str:
tool("nmcli", "NetworkManager is not available.")
result = run(["nmcli", *arguments], timeout=timeout, stdin_text=stdin_text)
if result.returncode != 0:
raise BoundaryError(refusal(result, "NetworkManager refused that."))
return result.stdout.strip()
def refusal(result: subprocess.CompletedProcess, fallback: str) -> str:
lines = (result.stderr or result.stdout or "").strip().splitlines()
if not lines:
return fallback
text = lines[-1].strip()
lowered = text.lower()
if "not authorized" in lowered or "dismissed" in lowered:
return "That network change was not authorized."
if "no such connection profile" in lowered:
return "That connection no longer exists."
return text[:200]
def require(pattern: re.Pattern, value: str, message: str) -> str:
if not pattern.fullmatch(value or ""):
raise BoundaryError(message)
return value
def require_bool(value: str) -> bool:
if value not in ("true", "false"):
raise BoundaryError("That is not true or false.")
return value == "true"
def require_connection(value: str) -> str:
return require(NAME, value, "That is not a connection name.")
# ---------------------------------------------------------------- reading
def is_secret(key: str) -> bool:
"""Whether a property name is one whose value must not travel."""
bare = re.sub(r"\[\d+\]$", "", key).strip().lower()
return bare in SECRET_PROPERTIES or SECRET_SHAPE.search(bare) is not None
def listing(*arguments: str) -> dict[str, str]:
"""A terse nmcli listing, parsed to property -> value, secrets dropped.
Filtered here rather than at each use. `details` renders whatever it is
handed, so the one function that reads NetworkManager is the one place that
has to be certain a passphrase never gets that far -- and a filter applied
once cannot be forgotten by the next field somebody adds.
`-e no` turns off nmcli's in-field escaping, so a value containing ':' --
every MAC address, for one -- arrives whole and splits at the first colon
exactly where the property name ends.
"""
tool("nmcli", "NetworkManager is not available.")
result = run(["nmcli", "-t", "-e", "no", *arguments])
if result.returncode != 0:
return {}
found: dict[str, str] = {}
for line in result.stdout.splitlines():
key, separator, value = line.partition(":")
if not separator:
continue
key = key.strip()
if not key or is_secret(key):
continue
found[key] = value.strip()
return found
def indexed(found: dict[str, str], base: str) -> list[str]:
"""Every value of a repeated property, in order. IP4.DNS[1], [2], and so on."""
pattern = re.compile(rf"^{re.escape(base)}(\[(\d+)\])?$")
matches = []
for key, value in found.items():
hit = pattern.match(key)
if hit and value:
matches.append((int(hit.group(2) or 0), value))
return [value for _, value in sorted(matches)]
def first(found: dict[str, str], *keys: str) -> str:
for key in keys:
if found.get(key):
return found[key]
return ""
def connection_listing(name: str) -> dict[str, str]:
return listing("connection", "show", name)
def connection_exists(name: str) -> bool:
return bool(connection_listing(name))
def connection_state(name: str, note: str = "") -> dict:
"""Everything the details grid shows, and nothing a page should not have.
This is the shape every per-connection verb answers with, so a mutation and
a plain read are the same object to the page -- the change is visible in the
reply rather than in a refresh that may or may not arrive.
"""
state = {
"connection": name,
"exists": False,
"uuid": "",
"type": "",
"interface": "",
"active": False,
"ip4": "",
"ip6": "",
"gateway": "",
"dns": [],
"mac": "",
"macRandomized": False,
"autoconnect": False,
"note": note,
"error": "",
}
found = connection_listing(name)
if not found:
return state
state["exists"] = True
state["uuid"] = found.get("connection.uuid", "")
state["type"] = found.get("connection.type", "")
state["autoconnect"] = found.get("connection.autoconnect", "") in ("yes", "true")
state["macRandomized"] = first(found, *CLONED_MAC_KEYS).lower() == "random"
state["active"] = found.get("GENERAL.STATE", "") == "activated"
state["ip4"] = (indexed(found, "IP4.ADDRESS") or [""])[0]
state["ip6"] = (indexed(found, "IP6.ADDRESS") or [""])[0]
state["gateway"] = found.get("IP4.GATEWAY", "")
state["dns"] = indexed(found, "IP4.DNS") + indexed(found, "IP6.DNS")
device = (indexed(found, "GENERAL.DEVICES") or [""])[0]
state["interface"] = device
# The address actually on the wire, which is the randomised one when
# randomisation is on -- the profile records only the policy. A profile
# listing does not carry it, so the device is asked; the listing is checked
# first because some builds of nmcli do include it.
state["mac"] = found.get("GENERAL.HWADDR", "")
if not state["mac"] and device:
state["mac"] = listing("-f", "GENERAL.HWADDR", "device", "show",
device).get("GENERAL.HWADDR", "")
return state
# ---------------------------------------------------------------- per-connection
def forget(name: str) -> dict:
require_connection(name)
if not connection_exists(name):
raise BoundaryError("That connection no longer exists.")
nmcli("connection", "delete", name, timeout=60)
return connection_state(name, "Forgotten.")
def set_autoconnect(name: str, enabled: bool) -> dict:
require_connection(name)
nmcli("connection", "modify", name,
"connection.autoconnect", "yes" if enabled else "no", timeout=60)
return connection_state(name)
def set_mac_random(name: str, enabled: bool) -> dict:
require_connection(name)
kind = connection_listing(name).get("connection.type", "")
field = CLONED_MAC.get(kind, CLONED_MAC_DEFAULT)
nmcli("connection", "modify", name, field,
"random" if enabled else "permanent", timeout=60)
# NetworkManager applies a cloned address when the connection comes up, so
# the address on the wire is the old one until it does. Saying so is the
# difference between "this did nothing" and "this takes effect next time".
return connection_state(name, "Reconnect for this to take effect.")
# ---------------------------------------------------------------- VPN import
def connection_uuids() -> set[str]:
"""Every stored profile's UUID, for spotting the one an import added."""
tool("nmcli", "NetworkManager is not available.")
result = run(["nmcli", "-t", "-e", "no", "-f", "UUID", "connection", "show"])
return {line.strip() for line in result.stdout.splitlines() if line.strip()}
IMPORTED = re.compile(r"'([^']+)'\s*\(([0-9a-fA-F-]{36})\)")
def import_vpn(path_text: str) -> dict:
"""A WireGuard or OpenVPN profile, by whichever plugin its extension names.
The extension is what decides, and nothing else: NetworkManager's importer
picks its plugin by type, and guessing from file contents would mean
guessing wrong on a file that is neither.
nmcli names what it made -- "Connection 'x' (uuid) successfully added." --
and the quoted name and parenthesised UUID survive translation even where
the sentence around them does not. When they do not, the UUID list either
side of the import says which profile is new.
"""
path = Path(path_text).expanduser()
kind = VPN_TYPES.get(path.suffix.lower())
if not kind:
raise BoundaryError("A VPN profile must be a .conf or .ovpn file.")
if not path.is_file():
raise BoundaryError("There is no file at that path.")
before = connection_uuids()
output = nmcli("connection", "import", "type", kind, "file", str(path), timeout=60)
name, uuid = "", ""
announced = IMPORTED.search(output)
if announced:
name, uuid = announced.group(1), announced.group(2)
else:
added = connection_uuids() - before
if added:
uuid = added.pop()
name = connection_listing(uuid).get("connection.id", "")
if not name and not uuid:
raise BoundaryError("The profile imported but no new connection appeared.")
return {"name": name, "uuid": uuid, "kind": kind, "error": ""}
# ---------------------------------------------------------------- hotspot
def hotspot_state(password: str = "") -> dict:
state = {
"active": False,
"ssid": "",
"password": password,
"connection": HOTSPOT_CONNECTION,
"band": "",
"interface": "",
"error": "",
}
found = connection_listing(HOTSPOT_CONNECTION)
if not found:
return state
state["ssid"] = first(found, "802-11-wireless.ssid", "wifi.ssid")
state["band"] = first(found, "802-11-wireless.band", "wifi.band")
state["active"] = found.get("GENERAL.STATE", "") == "activated"
state["interface"] = (indexed(found, "GENERAL.DEVICES") or [""])[0]
return state
def wifi_interface() -> str:
"""The Wi-Fi device to share from, or "" to let NetworkManager pick.
Naming it is better when there is more than one adapter; not naming it is
better than refusing to start because this could not work out which was
which. NetworkManager makes the same choice, and says so if it cannot.
"""
result = run(["nmcli", "-t", "-e", "no", "-f", "DEVICE,TYPE", "device"])
for line in result.stdout.splitlines():
device, _, kind = line.partition(":")
if kind.strip() == "wifi" and device.strip():
return device.strip()
return ""
def hotspot_password() -> str:
"""The passphrase NetworkManager generated, read back once to be shown.
`nmcli device wifi show-password` exists for exactly this and prints the
live hotspot's credentials; the stored profile is asked only if that output
is not in the expected shape. Never logged, never written down here.
"""
result = run(["nmcli", "device", "wifi", "show-password"], timeout=20)
if result.returncode != 0:
return ""
for line in result.stdout.splitlines():
key, _, value = line.partition(":")
if key.strip().lower() == "password":
return value.strip()
return ""
def hotspot_start(ssid: str) -> dict:
require(SSID, ssid, "That is not a network name.")
device = wifi_interface()
where = ["ifname", device] if device else []
nmcli("device", "wifi", "hotspot", *where,
"con-name", HOTSPOT_CONNECTION, "ssid", ssid, timeout=45)
return hotspot_state(hotspot_password())
def hotspot_stop() -> dict:
if not connection_exists(HOTSPOT_CONNECTION):
return hotspot_state()
nmcli("connection", "down", HOTSPOT_CONNECTION, timeout=45)
return hotspot_state()
# ---------------------------------------------------------------- enterprise
def native_bindings():
"""A live libnm client through GObject introspection, or None.
The probe constructs the client rather than merely importing the module,
because the question is not "are the bindings installed" but "can the
password be handed over on the bus". A machine with the typelib and a
NetworkManager that will not answer must fall through to the command-line
client, not fail.
"""
try:
import gi
gi.require_version("NM", "1.0")
from gi.repository import GLib, NM
return GLib, NM, NM.Client.new(None)
except Exception: # noqa: BLE001 - any failure here means "use the other path"
return None
def join_enterprise(ssid: str, profile_name: str, identity: str,
ca_cert: str) -> dict:
"""Validate first, then read the password, then join.
The order is deliberate. Reading stdin before the arguments are known to be
good would leave the helper waiting on a password for a request it was
always going to refuse -- which, when nothing is piped in, is a hang rather
than an error message.
"""
require(SSID, ssid, "That is not a network name.")
if profile_name not in EAP_PROFILES:
raise BoundaryError("That is not an authentication method this can use.")
if not identity.strip():
raise BoundaryError("An enterprise network needs a username.")
if len(identity) > 128 or "\n" in identity:
raise BoundaryError("That username cannot be used.")
if ca_cert and not Path(ca_cert).expanduser().is_file():
raise BoundaryError("There is no certificate at that path.")
password = read_password()
if not password:
raise BoundaryError("An enterprise network needs a password.")
bindings = native_bindings()
if bindings is not None:
join_enterprise_native(bindings, ssid, profile_name, identity, ca_cert, password)
else:
join_enterprise_nmcli(ssid, profile_name, identity, ca_cert, password)
state = connection_state(ssid, "Joined.")
state["joined"] = state["exists"]
return state
def join_enterprise_native(bindings, ssid: str, profile_name: str, identity: str,
ca_cert: str, password: str) -> None:
"""Build the profile in memory and hand it over on the bus.
AddConnection carries the password as a value in a D-Bus message: it is
never a command-line argument, so it never appears in /proc, and never a
temporary file, so it never reaches disk unencrypted on its way in.
"""
GLib, NM, client = bindings
method = EAP_PROFILES[profile_name]
connection = NM.SimpleConnection.new()
setting = NM.SettingConnection.new()
setting.set_property(NM.SETTING_CONNECTION_ID, ssid)
setting.set_property(NM.SETTING_CONNECTION_UUID, NM.utils_uuid_generate())
setting.set_property(NM.SETTING_CONNECTION_TYPE, "802-11-wireless")
connection.add_setting(setting)
wireless = NM.SettingWireless.new()
wireless.set_property(NM.SETTING_WIRELESS_SSID,
GLib.Bytes.new(ssid.encode("utf-8")))
wireless.set_property(NM.SETTING_WIRELESS_MODE, "infrastructure")
connection.add_setting(wireless)
security = NM.SettingWirelessSecurity.new()
security.set_property(NM.SETTING_WIRELESS_SECURITY_KEY_MGMT, "wpa-eap")
connection.add_setting(security)
eap = NM.Setting8021x.new()
eap.add_eap_method(method["eap"])
eap.set_property(NM.SETTING_802_1X_PHASE2_AUTH, method["phase2"])
eap.set_property(NM.SETTING_802_1X_IDENTITY, identity)
eap.set_property(NM.SETTING_802_1X_PASSWORD, password)
if ca_cert:
eap.set_ca_cert(str(Path(ca_cert).expanduser()),
NM.Setting8021xCKScheme.PATH, None)
connection.add_setting(eap)
ip4 = NM.SettingIP4Config.new()
ip4.set_property(NM.SETTING_IP_CONFIG_METHOD, "auto")
connection.add_setting(ip4)
ip6 = NM.SettingIP6Config.new()
ip6.set_property(NM.SETTING_IP_CONFIG_METHOD, "auto")
connection.add_setting(ip6)
loop = GLib.MainLoop()
outcome: dict = {}
def added(source, result, _data):
try:
outcome["connection"] = source.add_connection_finish(result)
except GLib.Error as error: # noqa: BLE001 - reported, not raised, on this thread
outcome["error"] = error.message
loop.quit()
client.add_connection_async(connection, True, None, added, None)
# Bounded, because a NetworkManager that never answers must not leave a
# settings page spinning forever.
GLib.timeout_add_seconds(45, lambda: (loop.quit(), False)[1])
loop.run()
if "error" in outcome:
raise BoundaryError(str(outcome["error"])[:200])
if "connection" not in outcome:
raise BoundaryError("NetworkManager did not answer.")
# Added, now bring it up so "Connect" means connected.
activation = run(["nmcli", "-w", "45", "connection", "up", ssid], timeout=60)
if activation.returncode != 0:
raise BoundaryError(refusal(activation, "That network refused the sign-in."))
def join_enterprise_nmcli(ssid: str, profile_name: str, identity: str,
ca_cert: str, password: str) -> None:
"""The fallback for a machine without libnm's bindings.
nmcli's connection editor takes its input as lines on stdin, so the password
arrives the same way it would be typed -- as data, not as an argument. That
is the whole reason this shape is used rather than `nmcli connection add`
with the secret on the command line.
"""
method = EAP_PROFILES[profile_name]
script = [
f"set connection.id {ssid}",
f"set 802-11-wireless.ssid {ssid}",
"set 802-11-wireless-security.key-mgmt wpa-eap",
f"set 802-1x.eap {method['eap']}",
f"set 802-1x.phase2-auth {method['phase2']}",
f"set 802-1x.identity {identity}",
f"set 802-1x.password {password}",
]
if ca_cert:
script.append(f"set 802-1x.ca-cert {Path(ca_cert).expanduser()}")
script += ["save", "quit", ""]
nmcli("connection", "edit", "type", "wifi", "con-name", ssid,
timeout=60, stdin_text="\n".join(script))
activation = run(["nmcli", "-w", "45", "connection", "up", ssid], timeout=60)
if activation.returncode != 0:
raise BoundaryError(refusal(activation, "That network refused the sign-in."))
# ---------------------------------------------------------------- proxy
PROXY_SCHEMA = "org.gnome.system.proxy"
def gsettings_get(schema: str, key: str) -> str:
tool("gsettings", "The desktop settings store is not available.")
result = run(["gsettings", "get", schema, key])
return result.stdout.strip() if result.returncode == 0 else ""
def gsettings_set(schema: str, key: str, value: str) -> None:
tool("gsettings", "The desktop settings store is not available.")
result = run(["gsettings", "set", schema, key, value], timeout=20)
if result.returncode != 0:
raise BoundaryError(refusal(result, "The proxy could not be changed."))
def unquote(raw: str) -> str:
"""gsettings prints strings quoted and everything else bare."""
text = raw.strip()
if len(text) >= 2 and text[0] == text[-1] and text[0] in ("'", '"'):
return text[1:-1]
return text
def proxy_get() -> dict:
mode = unquote(gsettings_get(PROXY_SCHEMA, "mode")) or "none"
port_text = gsettings_get(f"{PROXY_SCHEMA}.http", "port")
return {
"mode": mode,
"host": unquote(gsettings_get(f"{PROXY_SCHEMA}.http", "host")),
"port": int(port_text) if port_text.isdigit() else 0,
"pacUrl": unquote(gsettings_get(PROXY_SCHEMA, "autoconfig-url")),
"error": "",
}
def proxy_set(mode: str, arguments: list[str]) -> dict:
if mode == "none":
if arguments:
raise BoundaryError("Turning the proxy off takes no address.")
gsettings_set(PROXY_SCHEMA, "mode", "none")
elif mode == "manual":
# No address means "switch to the manual proxy already stored", which
# is what choosing Manual from a dropdown means: the fields to fill in
# only appear once the mode is chosen, so demanding them first would be
# a mode nobody could select.
if len(arguments) not in (0, 2):
raise BoundaryError("A manual proxy needs a host and a port.")
if arguments:
host, port = arguments
require(HOSTNAME, host, "That is not a proxy address.")
if not port.isdigit() or not (1 <= int(port) <= 65535):
raise BoundaryError("That is not a port number.")
# http, https and socks together this phase: separate proxies per
# protocol is a real configuration but not one anybody asks a
# settings page for, and three fields that must agree is three ways
# to get it subtly wrong.
for protocol in ("http", "https", "socks"):
gsettings_set(f"{PROXY_SCHEMA}.{protocol}", "host", host)
gsettings_set(f"{PROXY_SCHEMA}.{protocol}", "port", port)
gsettings_set(PROXY_SCHEMA, "mode", "manual")
elif mode == "auto":
if len(arguments) not in (0, 1):
raise BoundaryError("An automatic proxy needs a configuration URL.")
if arguments:
require(PAC_URL, arguments[0], "That is not a proxy configuration URL.")
gsettings_set(PROXY_SCHEMA, "autoconfig-url", arguments[0])
gsettings_set(PROXY_SCHEMA, "mode", "auto")
else:
raise BoundaryError("A proxy is off, manual, or automatic.")
return proxy_get()
# ---------------------------------------------------------------- airplane
def airplane_status() -> dict:
"""Every radio's kill switch, read the way the keybind reads it.
Any radio still unblocked reads as radios-on, matching scripts/panama-osd:
airplane mode is a claim about all of them, so a mixed state is not it.
"""
tool("rfkill", "The radio kill switches are not available.")
radios: list[dict] = []
result = run(["rfkill", "-J"])
parsed = None
if result.returncode == 0:
try:
parsed = json.loads(result.stdout or "{}")
except json.JSONDecodeError:
parsed = None
if parsed is not None:
for entry in parsed.get("rfkilldevices", []):
radios.append({
"type": str(entry.get("type") or ""),
"soft": str(entry.get("soft") or "") == "blocked",
"hard": str(entry.get("hard") or "") == "blocked",
})
else:
# Older util-linux has no --json; its list output is stable enough.
current: dict | None = None
for line in run(["rfkill", "list"]).stdout.splitlines():
heading = re.match(r"^\d+:\s+\S+:\s+(.+)$", line)
if heading:
current = {"type": heading.group(1).strip().lower(),
"soft": False, "hard": False}
radios.append(current)
elif current is not None:
key, _, value = line.strip().partition(":")
if key == "Soft blocked":
current["soft"] = value.strip() == "yes"
elif key == "Hard blocked":
current["hard"] = value.strip() == "yes"
def blocked(kinds: tuple[str, ...]) -> bool:
matched = [r for r in radios if any(k in r["type"] for k in kinds)]
return bool(matched) and all(r["soft"] or r["hard"] for r in matched)
return {
"on": bool(radios) and all(radio["soft"] for radio in radios),
"wifiBlocked": blocked(("wlan", "wireless")),
"bluetoothBlocked": blocked(("bluetooth",)),
"hardBlocked": any(radio["hard"] for radio in radios),
"radios": len(radios),
"error": "",
}
def airplane_set(enabled: bool) -> dict:
tool("rfkill", "The radio kill switches are not available.")
result = run(["rfkill", "block" if enabled else "unblock", "all"], timeout=20)
if result.returncode != 0:
raise BoundaryError(refusal(result, "The radios could not be changed."))
return airplane_status()
# ---------------------------------------------------------------- entry
def read_password() -> str:
"""The enterprise password, from stdin, stripped of its trailing newline.
Only the newline: a password may legitimately begin or end with a space,
and quietly trimming it would produce an authentication failure nobody
could explain.
"""
return sys.stdin.readline().rstrip("\n").rstrip("\r")
# What to answer with when a verb fails: the same shape it would have answered
# with, so a page never has to branch on whether the reply is an error.
FALLBACKS = {
"connection": {"connection": "", "exists": False, "uuid": "", "type": "",
"interface": "", "active": False, "ip4": "", "ip6": "",
"gateway": "", "dns": [], "mac": "", "macRandomized": False,
"autoconnect": False, "note": ""},
"import": {"name": "", "uuid": "", "kind": ""},
"hotspot": {"active": False, "ssid": "", "password": "",
"connection": HOTSPOT_CONNECTION, "band": "", "interface": ""},
"proxy": {"mode": "none", "host": "", "port": 0, "pacUrl": ""},
"airplane": {"on": False, "wifiBlocked": False, "bluetoothBlocked": False,
"hardBlocked": False, "radios": 0},
}
USAGE = ("Usage: panama-network details CONNECTION | forget CONNECTION | "
"set-autoconnect CONNECTION true|false | set-mac-random CONNECTION true|false | "
"import-vpn FILE | hotspot start SSID|stop|status | "
"join-enterprise SSID PROFILE IDENTITY [CA_CERT] | "
"proxy get | proxy set none|manual [HOST PORT]|auto [PAC_URL] | "
"airplane status | airplane set true|false")
def dispatch(arguments: list[str]) -> tuple[str, dict]:
verb = arguments[0] if arguments else ""
rest = arguments[1:]
if verb == "details" and len(rest) == 1:
return "connection", connection_state(require_connection(rest[0]))
if verb == "forget" and len(rest) == 1:
return "connection", forget(rest[0])
if verb == "set-autoconnect" and len(rest) == 2:
return "connection", set_autoconnect(rest[0], require_bool(rest[1]))
if verb == "set-mac-random" and len(rest) == 2:
return "connection", set_mac_random(rest[0], require_bool(rest[1]))
if verb == "import-vpn" and len(rest) == 1:
return "import", import_vpn(rest[0])
if verb == "hotspot" and rest == ["status"]:
return "hotspot", hotspot_state()
if verb == "hotspot" and rest == ["stop"]:
return "hotspot", hotspot_stop()
if verb == "hotspot" and len(rest) == 2 and rest[0] == "start":
return "hotspot", hotspot_start(rest[1])
if verb == "join-enterprise" and len(rest) in (3, 4):
return "connection", join_enterprise(
rest[0], rest[1], rest[2], rest[3] if len(rest) == 4 else "")
if verb == "proxy" and rest == ["get"]:
return "proxy", proxy_get()
if verb == "proxy" and len(rest) >= 2 and rest[0] == "set":
return "proxy", proxy_set(rest[1], rest[2:])
if verb == "airplane" and rest == ["status"]:
return "airplane", airplane_status()
if verb == "airplane" and len(rest) == 2 and rest[0] == "set":
return "airplane", airplane_set(require_bool(rest[1]))
raise BoundaryError(USAGE)
def shape_for(arguments: list[str]) -> str:
verb = arguments[0] if arguments else ""
return {"details": "connection", "forget": "connection",
"set-autoconnect": "connection", "set-mac-random": "connection",
"join-enterprise": "connection", "import-vpn": "import",
"hotspot": "hotspot", "proxy": "proxy",
"airplane": "airplane"}.get(verb, "connection")
def main(arguments: list[str]) -> int:
try:
_, answer = dispatch(arguments)
except BoundaryError as error:
answer = dict(FALLBACKS[shape_for(arguments)])
answer["error"] = str(error)
print(json.dumps(answer, separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+128 -1
View File
@@ -17,6 +17,9 @@ says so rather than pretending.
panama-printers set-default NAME panama-printers set-default NAME
panama-printers pause NAME | resume NAME panama-printers pause NAME | resume NAME
panama-printers cancel JOB_ID panama-printers cancel JOB_ID
panama-printers hold JOB_ID | release JOB_ID
panama-printers get-options NAME
panama-printers set-option NAME KEY VALUE
panama-printers test-page NAME panama-printers test-page NAME
""" """
@@ -41,6 +44,24 @@ QUEUE = re.compile(r"^[A-Za-z0-9_.-]{1,127}$")
# is checkable rather than scattered. # is checkable rather than scattered.
DRIVERLESS_MODEL = "everywhere" DRIVERLESS_MODEL = "everywhere"
# The printer defaults this page will change, and every value it will accept.
#
# Closed on purpose. `lpadmin -o anything=anything` is a passthrough into a
# daemon running as root, and the two settings people actually reach for --
# paper size and double-siding -- are worth exactly two dropdowns. Anything not
# in this table is refused rather than forwarded, so what this page can do to a
# print queue is readable in one place.
OPTION_VOCABULARY: dict[str, tuple[str, ...]] = {
"media": ("Letter", "A4", "Legal"),
"sides": ("one-sided", "two-sided-long-edge", "two-sided-short-edge"),
}
# How each choice looks when the printer names it, rather than when a PPD does.
# IPP spells paper sizes as self-describing keywords ("na_letter_8.5x11in"),
# PPDs as short names ("Letter"), and a driverless queue can report either --
# so a choice is recognised by the token both spellings share.
OPTION_TOKENS = {"Letter": "letter", "A4": "a4", "Legal": "legal"}
class BoundaryError(RuntimeError): class BoundaryError(RuntimeError):
"""A user-visible validation or CUPS failure.""" """A user-visible validation or CUPS failure."""
@@ -235,6 +256,86 @@ def cancel(job_id: str) -> None:
raise BoundaryError("That job could not be cancelled.") from error raise BoundaryError("That job could not be cancelled.") from error
def matches(choice: str, reported: str) -> bool:
"""Whether a value the printer reported is the choice this page offers."""
token = OPTION_TOKENS.get(choice, choice).lower()
return token in str(reported).lower()
def get_options(name: str) -> dict:
"""The two defaults this page can change, and which values are on offer.
Asked of the printer rather than assumed: a queue that cannot do two-sided
should not be offered a two-sided dropdown, and a printer loaded with A4
should not have to be told it is A4 every time. When the printer reports no
opinion, the whole vocabulary is offered -- an empty dropdown is worse than
a choice that might be refused.
"""
require_queue(name)
cups, connection = connect()
try:
attributes = connection.getPrinterAttributes(name)
except Exception as error: # noqa: BLE001
raise BoundaryError("That printer's settings could not be read.") from error
options: dict[str, str] = {}
choices: dict[str, list[str]] = {}
raw: dict[str, str] = {}
for key, vocabulary in OPTION_VOCABULARY.items():
supported = attributes.get(f"{key}-supported") or []
if isinstance(supported, (str, bytes)):
supported = [supported]
supported = [str(entry) for entry in supported]
offered = [choice for choice in vocabulary
if any(matches(choice, entry) for entry in supported)]
choices[key] = offered or list(vocabulary)
current = str(attributes.get(f"{key}-default") or "")
raw[key] = current
options[key] = next(
(choice for choice in vocabulary if current and matches(choice, current)), "")
return {"printer": name, "options": options, "choices": choices,
"reported": raw, "error": ""}
def set_option(name: str, key: str, value: str) -> None:
"""One printer default, from the closed table above and nowhere else."""
require_queue(name)
vocabulary = OPTION_VOCABULARY.get(key)
if vocabulary is None:
raise BoundaryError("That is not a setting this page changes.")
if value not in vocabulary:
raise BoundaryError("That is not a value this setting accepts.")
cups, connection = connect()
try:
connection.addPrinterOptionDefault(name, key, value)
except Exception as error: # noqa: BLE001
message = str(error).lower()
if "not-authorized" in message or "forbidden" in message:
raise BoundaryError("Changing that setting was not authorized.") from error
raise BoundaryError("That setting could not be changed.") from error
def set_held(job_id: str, held: bool) -> None:
"""Hold a job where it is, or let it go.
A held job stays in the queue rather than leaving it, which is the whole
point: cancelling to stop a print and then reprinting is how a fifty-page
document gets printed twice.
"""
if not job_id.isdigit():
raise BoundaryError("That is not a job.")
cups, connection = connect()
try:
connection.setJobHoldUntil(int(job_id), "indefinite" if held else "no-hold")
except Exception as error: # noqa: BLE001
raise BoundaryError(
"That job could not be held." if held
else "That job could not be released.") from error
def test_page(name: str) -> None: def test_page(name: str) -> None:
require_queue(name) require_queue(name)
result = run(["lp", "-d", name, "/usr/share/cups/data/testprint"], timeout=30) result = run(["lp", "-d", name, "/usr/share/cups/data/testprint"], timeout=30)
@@ -251,6 +352,26 @@ def main(arguments: list[str]) -> int:
print(json.dumps(discover(), separators=(",", ":"))) print(json.dumps(discover(), separators=(",", ":")))
return 0 return 0
# Read-only, and about one printer, so it answers with its own shape.
if len(arguments) == 2 and arguments[0] == "get-options":
try:
answer = get_options(arguments[1])
except BoundaryError as error:
answer = {"printer": arguments[1], "options": {}, "choices": {},
"reported": {}, "error": str(error)}
print(json.dumps(answer, separators=(",", ":")))
return 0
# A mutation, so it answers with the fresh snapshot -- carrying the
# printer's re-read options alongside, so the dropdown that made the
# change updates from the reply rather than from a second round trip.
if len(arguments) == 4 and arguments[0] == "set-option":
set_option(arguments[1], arguments[2], arguments[3])
state = snapshot()
state["options"] = get_options(arguments[1])
print(json.dumps(state, separators=(",", ":")))
return 0
if len(arguments) == 3 and arguments[0] == "add": if len(arguments) == 3 and arguments[0] == "add":
add(arguments[1], arguments[2]) add(arguments[1], arguments[2])
elif len(arguments) == 2 and arguments[0] == "remove": elif len(arguments) == 2 and arguments[0] == "remove":
@@ -263,12 +384,18 @@ def main(arguments: list[str]) -> int:
set_paused(arguments[1], False) set_paused(arguments[1], False)
elif len(arguments) == 2 and arguments[0] == "cancel": elif len(arguments) == 2 and arguments[0] == "cancel":
cancel(arguments[1]) cancel(arguments[1])
elif len(arguments) == 2 and arguments[0] == "hold":
set_held(arguments[1], True)
elif len(arguments) == 2 and arguments[0] == "release":
set_held(arguments[1], False)
elif len(arguments) == 2 and arguments[0] == "test-page": elif len(arguments) == 2 and arguments[0] == "test-page":
test_page(arguments[1]) test_page(arguments[1])
else: else:
raise BoundaryError( raise BoundaryError(
"Usage: panama-printers snapshot | discover | add URI NAME | remove NAME | " "Usage: panama-printers snapshot | discover | add URI NAME | remove NAME | "
"set-default NAME | pause NAME | resume NAME | cancel JOB_ID | test-page NAME") "set-default NAME | pause NAME | resume NAME | cancel JOB_ID | "
"hold JOB_ID | release JOB_ID | get-options NAME | "
"set-option NAME KEY VALUE | test-page NAME")
except BoundaryError as error: except BoundaryError as error:
try: try:
state = snapshot() state = snapshot()
@@ -84,6 +84,30 @@ Singleton {
readonly property bool wifiEnabled: Networking.wifiEnabled readonly property bool wifiEnabled: Networking.wifiEnabled
readonly property bool wifiAvailable: Networking.wifiHardwareEnabled readonly property bool wifiAvailable: Networking.wifiHardwareEnabled
// The radio switches, as functions rather than as writes a page makes for
// itself. Both are one-line assignments to the native modules, which is
// exactly the point: a page that reached past this service to set
// Networking.wifiEnabled directly would be one page's worth of the rule
// this file exists to keep -- and the next such write, needing a retry or a
// guard, would have nowhere to live but the page.
//
// Native both ways. Nothing here shells out to nmcli or rfkill; airplane
// mode, which genuinely needs rfkill, lives in NetworkTools.qml instead.
function setWifiEnabled(enabled: bool): void {
if (Networking.wifiEnabled !== enabled)
Networking.wifiEnabled = enabled;
}
function setBluetoothEnabled(enabled: bool): void {
const device = root.adapter;
if (!device || device.enabled === enabled)
return;
device.enabled = enabled;
}
readonly property bool bluetoothEnabled: root.adapter?.enabled ?? false
readonly property bool bluetoothAvailable: !!root.adapter
// Current network, then saved, then by signal -- the order GNOME uses, // Current network, then saved, then by signal -- the order GNOME uses,
// which is the order you actually look for things in. // which is the order you actually look for things in.
readonly property var networks: { readonly property var networks: {
@@ -124,15 +148,37 @@ Singleton {
&& network.security !== WifiSecurityType.Unknown; && network.security !== WifiSecurityType.Unknown;
} }
// Member names come from the installed plugin's own qmltypes --
// Quickshell/Networking/quickshell-network.qmltypes, whose WifiSecurityType
// is exactly: Wpa3SuiteB192, Sae, Wpa2Eap, Wpa2Psk, WpaEap, WpaPsk,
// StaticWep, DynamicWep, Leap, Owe, Open, Unknown -- rather than the
// GNOME-style names this switch used to test (Wep, Wpa, Wpa2, Wpa3,
// Enterprise). None of those five exist, and a `case` against an undefined
// member simply never matches, so every secured network read "Secured" and
// "Enterprise" was unreachable. The enterprise join form keys off exactly
// that string, so the whole 802.1X flow was dead.
//
// The open cases are named here rather than delegated to isSecured(),
// which treats Unknown as unsecured. Calling a network whose security
// nobody could read "Open" is a claim this cannot back up; Unknown falls
// through to "Secured" instead. isSecured() keeps its own meaning -- "does
// joining this need a passphrase" -- and is unchanged.
//
// Owe is Enhanced Open: encrypted, but joined without a passphrase, so to
// someone picking a network it reads as open.
function securityLabel(network: var): string { function securityLabel(network: var): string {
if (!root.isSecured(network))
return "Open";
switch (network.security) { switch (network.security) {
case WifiSecurityType.Wep: return "WEP"; case WifiSecurityType.Open: return "Open";
case WifiSecurityType.Wpa: return "WPA"; case WifiSecurityType.Owe: return "Open";
case WifiSecurityType.Wpa2: return "WPA2"; case WifiSecurityType.StaticWep: return "WEP";
case WifiSecurityType.Wpa3: return "WPA3"; case WifiSecurityType.WpaPsk: return "WPA";
case WifiSecurityType.Enterprise: return "Enterprise"; case WifiSecurityType.Wpa2Psk: return "WPA2";
case WifiSecurityType.Sae: return "WPA3";
case WifiSecurityType.WpaEap: return "Enterprise";
case WifiSecurityType.Wpa2Eap: return "Enterprise";
case WifiSecurityType.Wpa3SuiteB192: return "Enterprise";
case WifiSecurityType.DynamicWep: return "Enterprise";
case WifiSecurityType.Leap: return "Enterprise";
} }
return "Secured"; return "Secured";
} }
@@ -152,10 +198,14 @@ Singleton {
return "No signal"; return "No signal";
} }
// NoSecrets, not "Authentication" -- the qmltypes members are NoSecrets,
// Unknown, WifiAuthTimeout, WifiClientDisconnected, WifiClientFailed,
// WifiNetworkLost. A case against an undefined member never matches, so a
// wrong password used to fall through to the generic text.
function connectionFailureText(reason: var): string { function connectionFailureText(reason: var): string {
switch (reason) { switch (reason) {
case ConnectionFailReason.WifiAuthTimeout: case ConnectionFailReason.WifiAuthTimeout:
case ConnectionFailReason.Authentication: case ConnectionFailReason.NoSecrets:
return "Wrong password"; return "Wrong password";
case ConnectionFailReason.WifiNetworkLost: case ConnectionFailReason.WifiNetworkLost:
return "Network out of range"; return "Network out of range";
@@ -57,6 +57,12 @@ Singleton {
return root.exposed.filter(entry => root.allowedByRange(entry)); return root.exposed.filter(entry => root.allowedByRange(entry));
} }
// zone name -> the helper's zone-info shape, for the zone browser. Cached
// because browsing means asking about the same handful of zones as a chip
// row repaints, and each answer is two firewall-cmd calls.
property var zoneDetails: ({})
property var pendingZones: []
function refresh(): void { function refresh(): void {
if (query.running) if (query.running)
return; return;
@@ -64,6 +70,52 @@ Singleton {
query.running = true; query.running = true;
} }
// What a zone allows, for reading before choosing one. Returns the cached
// answer, or null while the first one is on its way -- and asks for it, so
// a chip that binds to this fills itself in.
//
// Read-only, so it needs no authorization and never prompts: this is the
// difference between looking at a zone and moving an interface into it.
function zoneInfo(zoneName: string): var {
if (zoneName === "")
return null;
if (root.zoneDetails[zoneName] !== undefined)
return root.zoneDetails[zoneName];
root.requestZoneInfo(zoneName);
return null;
}
function requestZoneInfo(zoneName: string): void {
if (zoneName === "" || root.pendingZones.indexOf(zoneName) >= 0)
return;
root.pendingZones = root.pendingZones.concat([zoneName]);
root.drainZones();
}
function drainZones(): void {
if (zoneQuery.running || root.pendingZones.length === 0)
return;
zoneQuery.subject = root.pendingZones[0];
zoneQuery.command = [root.helperPath, "zone-info", zoneQuery.subject];
zoneQuery.running = true;
}
function absorbZone(zoneName: string, text: string): void {
try {
const parsed = JSON.parse(text);
// Reassigned rather than mutated: QML does not notice a property
// change made inside a var object.
const next = Object.assign({}, root.zoneDetails);
next[zoneName] = parsed;
root.zoneDetails = next;
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
} catch (error) {
root.lastError = "Could not read what that zone allows.";
console.warn("Firewall: could not parse zone-info output:", error);
}
}
function absorb(text: string): void { function absorb(text: string): void {
try { try {
const parsed = JSON.parse(text); const parsed = JSON.parse(text);
@@ -90,6 +142,10 @@ Singleton {
if (mutation.running) if (mutation.running)
return; return;
root.lastError = ""; root.lastError = "";
// Every mutation here can change what a zone allows or which zone an
// interface is in, so the browser's cached descriptions are dropped
// rather than left to describe the firewall as it used to be.
root.zoneDetails = ({});
mutation.command = [root.helperPath].concat(arguments); mutation.command = [root.helperPath].concat(arguments);
mutation.running = true; mutation.running = true;
} }
@@ -120,4 +176,31 @@ Singleton {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
} }
} }
// Kept out of `busy` on purpose: reading what a zone allows changes nothing,
// so it must not disable the buttons that do.
Process {
id: zoneQuery
property string subject: ""
stdout: StdioCollector {
onStreamFinished: root.absorbZone(zoneQuery.subject, this.text)
}
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
onExited: {
root.pendingZones = root.pendingZones.filter(name => name !== zoneQuery.subject);
zoneDrain.restart();
}
}
// One tick later, because `running` has not gone false inside onExited and
// the queue would stall on its own guard.
Timer {
id: zoneDrain
interval: 0
onTriggered: root.drainZones()
}
} }
@@ -0,0 +1,392 @@
pragma Singleton
// The networking Quickshell has no surface for, so that Connectivity.qml can
// stay the pure-native thing it is pinned to be.
//
// Scanning, joining and pairing already work over DBus in Connectivity.qml, and
// nothing there may shell out. Everything NetworkManager knows but does not
// expose -- a connection's addresses, autoconnect, MAC randomisation, VPN
// import, hotspots, enterprise sign-in -- plus the system proxy and the radio
// kill switches, which are not NetworkManager's at all, live here and go
// through scripts/panama-network.
//
// The split is the point: one file that may never grow a Process, and one that
// is nothing but.
//
// Details are cached per connection rather than fetched on every paint. A
// details grid asks for the same connection every time it repaints, and each
// answer is five nmcli invocations.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.env("PANAMA_NETWORK_HELPER")
|| Quickshell.shellDir + "/scripts/panama-network"
// Set by the page while it is on screen. Networking state changes whether
// or not anyone is looking; re-reading it when nobody is costs nmcli
// invocations for an answer that will be stale again by the time it matters.
property bool active: false
// connection name -> the helper's connection shape. See detailsFor().
property var details: ({})
property var hotspot: ({})
property string proxyMode: "none"
property string proxyHost: ""
// A string, not a number, because the field that shows it is a text field:
// "no port set" and "port 0" are different answers, and only one of them is
// true of a proxy nobody has configured.
property string proxyPort: ""
property string proxyPac: ""
property bool airplaneOn: false
property bool airplaneHardBlocked: false
// The connection the last import produced, so the page can say which
// profile appeared rather than "done".
property string lastImport: ""
property string lastError: ""
// Guards read the Process objects directly; a derived binding is stale
// inside the handler that changes it. See DefaultApps.qml.
readonly property bool busy: mutation.running || enterprise.running || detailsQuery.running
// Connections whose details have been asked for, in order, so a burst of
// requests becomes one query at a time rather than one Process each.
property var pendingDetails: []
// Held only between "join" and the moment the helper's stdin is open.
property string pendingPassword: ""
readonly property bool hotspotActive: root.hotspot?.active === true
readonly property string hotspotSsid: String(root.hotspot?.ssid ?? "")
// Returned once by the verb that started or found the hotspot, for display
// beside a QR code. Never stored beyond the reply that carried it, and
// never logged.
readonly property string hotspotPassword: String(root.hotspot?.password ?? "")
readonly property string proxySummary: {
if (root.proxyMode === "manual")
return root.proxyHost !== "" ? root.proxyHost + ":" + root.proxyPort : "Manual";
if (root.proxyMode === "auto")
return root.proxyPac !== "" ? root.proxyPac : "Automatic";
return "Off";
}
// What a page binds to. Returns the cached answer, or null while the first
// one is on its way -- and asks for it, so a grid that binds to this fills
// itself in without the page having to sequence a fetch.
function detailsFor(connection: string): var {
if (connection === "")
return null;
if (root.details[connection] !== undefined)
return root.details[connection];
root.requestDetails(connection);
return null;
}
function requestDetails(connection: string): void {
if (connection === "")
return;
if (root.pendingDetails.indexOf(connection) >= 0)
return;
root.pendingDetails = root.pendingDetails.concat([connection]);
root.drainDetails();
}
// Forces a re-read of a connection already in the cache -- what the page
// calls when the active connection changes, because addresses and the MAC
// in use both change with it.
function refreshDetails(connection: string): void {
root.requestDetails(connection);
}
function drainDetails(): void {
if (detailsQuery.running || root.pendingDetails.length === 0)
return;
detailsQuery.subject = root.pendingDetails[0];
detailsQuery.command = [root.helperPath, "details", detailsQuery.subject];
detailsQuery.running = true;
}
// One connection's answer into the cache. Reassigned rather than mutated:
// QML does not notice a property change inside a var object.
function absorbDetails(connection: string, text: string): void {
try {
const parsed = JSON.parse(text);
const next = Object.assign({}, root.details);
next[connection] = parsed;
root.details = next;
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
} catch (error) {
root.lastError = "Could not read the network helper's answer.";
console.warn("NetworkTools: could not parse details:", error);
}
}
function absorbHotspot(text: string): void {
try {
const parsed = JSON.parse(text);
root.hotspot = parsed;
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
} catch (error) {
root.lastError = "Could not read the hotspot's state.";
}
}
function absorbProxy(text: string): void {
try {
const parsed = JSON.parse(text);
root.proxyMode = String(parsed.mode ?? "none");
root.proxyHost = String(parsed.host ?? "");
root.proxyPort = Number(parsed.port ?? 0) > 0 ? String(parsed.port) : "";
root.proxyPac = String(parsed.pacUrl ?? "");
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
} catch (error) {
root.lastError = "Could not read the proxy setting.";
}
}
function absorbAirplane(text: string): void {
try {
const parsed = JSON.parse(text);
root.airplaneOn = parsed.on === true;
root.airplaneHardBlocked = parsed.hardBlocked === true;
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
} catch (error) {
root.lastError = "Could not read the radio state.";
}
}
function absorbImport(text: string): void {
try {
const parsed = JSON.parse(text);
root.lastImport = String(parsed.name ?? "");
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
} catch (error) {
root.lastError = "Could not read the import result.";
}
}
// Which absorber a reply belongs to. The helper answers each verb with that
// verb's shape, so the caller records what it asked for rather than the
// reader guessing from the keys.
function absorb(shape: string, subject: string, text: string): void {
switch (shape) {
case "connection": root.absorbDetails(subject, text); break;
case "hotspot": root.absorbHotspot(text); break;
case "proxy": root.absorbProxy(text); break;
case "airplane": root.absorbAirplane(text); break;
case "import": root.absorbImport(text); break;
}
}
function run(shape: string, subject: string, argv: var): void {
if (mutation.running)
return;
root.lastError = "";
mutation.shape = shape;
mutation.subject = subject;
mutation.command = [root.helperPath].concat(argv);
mutation.running = true;
}
// ---- per-connection
function forget(connection: string): void {
root.run("connection", connection, ["forget", connection]);
}
function setAutoconnect(connection: string, enabled: bool): void {
root.run("connection", connection,
["set-autoconnect", connection, enabled ? "true" : "false"]);
}
function setMacRandom(connection: string, enabled: bool): void {
root.run("connection", connection,
["set-mac-random", connection, enabled ? "true" : "false"]);
}
// ---- VPN
function importVpn(path: string): void {
root.lastImport = "";
root.run("import", "", ["import-vpn", path]);
}
// ---- hotspot
function refreshHotspot(): void { root.run("hotspot", "", ["hotspot", "status"]); }
function startHotspot(ssid: string): void { root.run("hotspot", "", ["hotspot", "start", ssid]); }
function stopHotspot(): void { root.run("hotspot", "", ["hotspot", "stop"]); }
// ---- proxy
function refreshProxy(): void { root.run("proxy", "", ["proxy", "get"]); }
// The dropdown: switch which kind of proxy applies, leaving whatever
// address is already stored alone. The fields for a manual or automatic
// proxy only appear once its mode is chosen, so a mode that demanded them
// up front would be a mode nobody could pick.
function setProxyMode(mode: string): void {
root.run("proxy", "", ["proxy", "set", mode]);
}
function setProxyOff(): void { root.setProxyMode("none"); }
// Host and port together, because a proxy is only usable as a pair.
function setProxyManual(host: string, port: string): void {
root.run("proxy", "", ["proxy", "set", "manual", String(host), String(port)]);
}
function setProxyPac(pacUrl: string): void {
root.run("proxy", "", ["proxy", "set", "auto", pacUrl]);
}
// ---- radios
function refreshAirplane(): void { root.run("airplane", "", ["airplane", "status"]); }
function setAirplane(enabled: bool): void {
root.run("airplane", "", ["airplane", "set", enabled ? "true" : "false"]);
}
function toggleAirplane(): void { root.setAirplane(!root.airplaneOn); }
// ---- enterprise Wi-Fi
// The password goes down the helper's stdin and is never an argument.
// argv is world-readable through /proc for the life of the process, so a
// password passed that way is published to every process on the machine --
// which is why this has a Process of its own rather than reusing the one
// above: only this one ever opens stdin.
function joinEnterprise(ssid: string, profile: string, identity: string,
password: string, caCert: string): void {
if (enterprise.running)
return;
root.lastError = "";
root.pendingPassword = password;
enterprise.subject = ssid;
enterprise.command = String(caCert ?? "") !== ""
? [root.helperPath, "join-enterprise", ssid, profile, identity, caCert]
: [root.helperPath, "join-enterprise", ssid, profile, identity];
enterprise.stdinEnabled = true;
enterprise.running = true;
}
// Everything that is not per-connection, in one call: what a page asks for
// when it opens.
function refresh(): void {
root.refreshProxy();
root.refreshAirplaneSoon();
root.refreshHotspotSoon();
for (const connection in root.details)
root.requestDetails(connection);
}
// The mutation Process is one at a time, so the opening reads are spread
// over it rather than dropped by its running guard.
function refreshAirplaneSoon(): void { airplaneSoon.restart(); }
function refreshHotspotSoon(): void { hotspotSoon.restart(); }
onActiveChanged: if (root.active) root.refresh()
Timer {
id: airplaneSoon
interval: 120
onTriggered: {
if (mutation.running)
airplaneSoon.restart();
else
root.refreshAirplane();
}
}
Timer {
id: hotspotSoon
interval: 260
onTriggered: {
if (mutation.running)
hotspotSoon.restart();
else
root.refreshHotspot();
}
}
// The next queued details read, one tick after the last one exits. Draining
// from inside onExited would look at a `running` that has not gone false
// yet, and the queue would stall on its own guard.
Timer {
id: detailsDrain
interval: 0
onTriggered: root.drainDetails()
}
Process {
id: detailsQuery
property string subject: ""
stdout: StdioCollector {
onStreamFinished: root.absorbDetails(detailsQuery.subject, this.text)
}
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
onExited: {
root.pendingDetails = root.pendingDetails.filter(name => name !== detailsQuery.subject);
detailsDrain.restart();
}
}
Process {
id: mutation
// What the reply is, recorded when the command is built. The helper
// answers each verb with that verb's own shape.
property string shape: "connection"
property string subject: ""
// Mutations answer with the fresh state, so the page updates from the
// change itself rather than asking again afterwards.
stdout: StdioCollector {
onStreamFinished: root.absorb(mutation.shape, mutation.subject, this.text)
}
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
Process {
id: enterprise
property string subject: ""
onStarted: {
enterprise.write(root.pendingPassword + "\n");
// Held for as long as it takes to hand over, and no longer.
root.pendingPassword = "";
// Closing stdin is what lets the helper's read return; without it
// the join waits forever for a line that is already sent.
enterprise.stdinEnabled = false;
}
stdout: StdioCollector {
onStreamFinished: root.absorbDetails(enterprise.subject, this.text)
}
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
onExited: root.pendingPassword = ""
}
}
+105
View File
@@ -74,6 +74,12 @@ Singleton {
return count; return count;
} }
// printer name -> the helper's get-options shape. Cached because a pair of
// dropdowns asks for the same printer on every repaint, and each answer is
// a round trip to CUPS.
property var options: ({})
property var pendingOptions: []
function refresh(): void { function refresh(): void {
if (query.running) if (query.running)
return; return;
@@ -81,6 +87,48 @@ Singleton {
query.running = true; query.running = true;
} }
// Paper size and two-sided, as the printer reports them. Returns the cached
// answer, or null while the first one is on its way -- and asks for it, so
// a dropdown that binds to this fills itself in.
function optionsFor(name: string): var {
if (name === "")
return null;
if (root.options[name] !== undefined)
return root.options[name];
root.requestOptions(name);
return null;
}
function requestOptions(name: string): void {
if (name === "" || root.pendingOptions.indexOf(name) >= 0)
return;
root.pendingOptions = root.pendingOptions.concat([name]);
root.drainOptions();
}
function refreshOptions(name: string): void { root.requestOptions(name); }
function drainOptions(): void {
if (optionsQuery.running || root.pendingOptions.length === 0)
return;
optionsQuery.subject = root.pendingOptions[0];
optionsQuery.command = [root.helperPath, "get-options", optionsQuery.subject];
optionsQuery.running = true;
}
// Reassigned rather than mutated: QML does not notice a property change
// made inside a var object.
function absorbOptions(parsed: var): void {
const name = String(parsed?.printer ?? "");
if (name === "")
return;
const next = Object.assign({}, root.options);
next[name] = parsed;
root.options = next;
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
}
function search(): void { function search(): void {
if (root.searching) if (root.searching)
return; return;
@@ -96,6 +144,11 @@ Singleton {
root.jobs = Array.isArray(parsed.jobs) ? parsed.jobs : []; root.jobs = Array.isArray(parsed.jobs) ? parsed.jobs : [];
root.service = parsed.service ?? ({}); root.service = parsed.service ?? ({});
root.lastError = String(parsed.error ?? ""); root.lastError = String(parsed.error ?? "");
// set-option answers with the snapshot AND the printer it touched,
// re-read -- so the dropdown that made the change updates from the
// reply rather than from a second round trip.
if (parsed.options)
root.absorbOptions(parsed.options);
} catch (error) { } catch (error) {
root.lastError = "Could not read the printing service's answer."; root.lastError = "Could not read the printing service's answer.";
console.warn("Printers: could not parse helper output:", error); console.warn("Printers: could not parse helper output:", error);
@@ -119,6 +172,23 @@ Singleton {
function cancel(jobId: int): void { root.run(["cancel", String(jobId)]); } function cancel(jobId: int): void { root.run(["cancel", String(jobId)]); }
function testPage(name: string): void { root.run(["test-page", name]); } function testPage(name: string): void { root.run(["test-page", name]); }
// Holding keeps the job in the queue; cancelling throws it away. The
// difference matters because "stop this print" and "reprint fifty pages"
// are not meant to be the same button.
function hold(jobId: int): void { root.run(["hold", String(jobId)]); }
function release(jobId: int): void { root.run(["release", String(jobId)]); }
// One printer default, from the helper's closed vocabulary: media is
// Letter, A4 or Legal; sides is one-sided or one of the two two-sided
// bindings. Anything else the helper refuses -- there is no passthrough to
// lpadmin here.
function setOption(name: string, key: string, value: string): void {
root.run(["set-option", name, key, value]);
}
// Whether a job is held, from the state the helper reports.
function isHeld(job: var): bool { return String(job?.state ?? "") === "held"; }
// A queue name CUPS will accept, derived from what the printer calls itself. // A queue name CUPS will accept, derived from what the printer calls itself.
function suggestedName(label: string): string { function suggestedName(label: string): string {
const cleaned = String(label).replace(/[^A-Za-z0-9_.-]+/g, "_").replace(/^_+|_+$/g, ""); const cleaned = String(label).replace(/[^A-Za-z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "");
@@ -143,6 +213,41 @@ Singleton {
} }
} }
// Kept out of `busy`: reading a printer's defaults changes nothing, so it
// must not disable the controls that do.
Process {
id: optionsQuery
property string subject: ""
stdout: StdioCollector {
onStreamFinished: {
try {
root.absorbOptions(JSON.parse(this.text));
} catch (error) {
root.lastError = "Could not read that printer's settings.";
console.warn("Printers: could not parse get-options output:", error);
}
}
}
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
onExited: {
root.pendingOptions = root.pendingOptions.filter(
name => name !== optionsQuery.subject);
optionsDrain.restart();
}
}
// One tick later, because `running` has not gone false inside onExited and
// the queue would stall on its own guard.
Timer {
id: optionsDrain
interval: 0
onTriggered: root.drainOptions()
}
Process { Process {
id: discovery id: discovery
stdout: StdioCollector { stdout: StdioCollector {
@@ -75,9 +75,22 @@ Singleton {
{ label: "Help", detail: "The manual", page: "manual" }, { label: "Help", detail: "The manual", page: "manual" },
{ label: "Timezone", detail: "Set the system timezone", page: "datetime" }, { label: "Timezone", detail: "Set the system timezone", page: "datetime" },
{ label: "Network time", detail: "Synchronize the clock with a time server", page: "datetime" }, { label: "Network time", detail: "Synchronize the clock with a time server", page: "datetime" },
{ label: "Wi-Fi", detail: "Managed by GNOME Settings", page: "connectivity" }, // Connections. These said "Managed by GNOME Settings" for as long as
{ label: "Bluetooth", detail: "Managed by GNOME Settings", page: "connectivity" }, // that was true; it stopped being true when the page grew VPN, hotspot,
{ label: "Printers", detail: "Managed by GNOME Settings", page: "connectivity" }, // proxy and per-connection details, and a search result that hands the
// user to another application for something this page now does is worse
// than no result at all. The Printers entry here was also a second copy
// of the one that routes to the Printers page.
{ label: "Wi-Fi", detail: "Join a network, see the one you are on, and share it", page: "connectivity" },
{ label: "Bluetooth", detail: "Pair and connect devices", page: "connectivity" },
{ label: "VPN", detail: "Turn a tunnel on, and see which one is up", page: "connectivity" },
{ label: "Import a VPN", detail: "Add a WireGuard or OpenVPN profile from a file", page: "connectivity" },
{ label: "Hotspot", detail: "Share this machine's connection over Wi-Fi", page: "connectivity" },
{ label: "Airplane mode", detail: "Turn every radio off at once", page: "connectivity" },
{ label: "Network proxy", detail: "Send traffic through a proxy, or a PAC file", page: "connectivity" },
{ label: "IP address", detail: "The address, gateway, DNS servers, and hardware address of a connection", page: "connectivity" },
{ label: "Forget a Wi-Fi network", detail: "Remove a saved network so it stops connecting on its own", page: "connectivity" },
{ label: "Enterprise Wi-Fi", detail: "Join a network that asks for an identity and a password", page: "connectivity" },
{ label: "Default applications", detail: "Browser, mail, files", page: "applications" }, { label: "Default applications", detail: "Browser, mail, files", page: "applications" },
{ label: "User account", detail: "Your name, picture, and password", page: "users" }, { label: "User account", detail: "Your name, picture, and password", page: "users" },
{ label: "Profile picture", detail: "The avatar shown on the lock screen and in the Control Center", page: "users" }, { label: "Profile picture", detail: "The avatar shown on the lock screen and in the Control Center", page: "users" },
@@ -596,3 +596,126 @@ schema itself:
F9` against the **real compositor** with an isolated `XDG_CONFIG_HOME`. That F9` against the **real compositor** with an isolated `XDG_CONFIG_HOME`. That
was true before this phase and is unchanged, but it is the one contract in was true before this phase and is unchanged, but it is the one contract in
this wave that writes to the running keymap, so it wants a quiet moment. this wave that writes to the running keymap, so it wants a quiet moment.
## Phase 9 (Network & Sharing) — append below
Spec: `2026-08-24-network-sharing-redesign.md`. Connections became a full
network manager (per-connection details, forget, autoconnect, MAC
randomization, VPN import, hotspot, enterprise Wi-Fi, airplane mode, system
proxy), Firewall grew its add side and a zone browser, Printers gained per-
printer defaults and job hold/release, and Sharing got copy and structure work.
The last two GNOME punt rows in the app died with the "Owned by Fedora" card.
**Nothing here was run against a live harness.** Three agents edited the tree
concurrently. What *was* verified is listed per contract below.
### New contracts (1)
`quickshell/network-tools-contract`. The README count line moves **169 → 170**;
`setup/readme-contract` was run and passes ("170 contracts, as documented"),
counted the same way `panama test` collects the suite.
### Verified
- **`quickshell/network-tools-contract` — RUN END TO END, PASS.** It is safe to
run because it never reaches the machine: `env -i` with a stub directory first
on `PATH`, stubs for nmcli/gsettings/rfkill plus every D-Bus client, a
PyGObject stand-in on `PYTHONPATH` whose `require_version` always raises, and
both D-Bus bus addresses pointed at sockets that do not exist. It asserts the
first two of those before running the helper at all, and refuses to continue
if `nmcli`, `gsettings` or `rfkill` resolves anywhere but the stub directory.
Checked with a debug copy that it is doing real work rather than passing
vacuously: the stub's terse listing drives the shipped parse all the way to
`{"ip4":"192.168.7.42/24","gateway":"192.168.7.1","dns":["192.168.7.1",
"1.1.1.1"],"mac":"AA:BB:CC:DD:EE:FF","macRandomized":true,…}` while the three
secret properties in the same listing are absent from it.
- The libnm/D-Bus branch of `join-enterprise` is deliberately **not**
exercised — running it would add a real connection profile. It is covered
statically instead, by an AST walk that fails if any command list in the
helper carries a password-shaped identifier. The nmcli fallback is what the
dynamic half drives, and there the password is asserted present on the
editor's **stdin** and absent from **argv**, stdout, stderr and disk.
- **`quickshell/connectivity-contract` — static half run, and it found a bug.**
See "Still open" below. The new enum check reads the member list out of the
installed `quickshell-network.qmltypes` rather than a hand-kept list, so it
cannot drift the way the two bugs it catches did.
- **`quickshell/firewall-contract` — static half run, PASS** (everything up to
the first live `firewall-cmd` read).
- **`quickshell/printers-contract` — static half run, PASS.**
- **`quickshell/sharing-contract` — static half run, PASS.**
- **`quickshell/gnome-handoff-contract` — RUN END TO END, PASS** (source-only):
"ok (12 handoffs checked against 39 pages)" with `network` and `wifi` now in
OWNED.
- **`quickshell/health-ui-contract` — static half run, PASS.**
- **`PANAMA_SETTINGS_STATIC_ONLY=1 settings-pages-contract` — PASS.**
- **`setup/readme-contract` — RUN END TO END, PASS.**
- `bash -n` on every changed contract; every embedded `python3` heredoc compiled
separately with `py_compile`.
### Still open before the run
- **`Connectivity.qml:204` names an enum member that does not exist.**
`ConnectionFailReason.Authentication` is not a member; the real ones are
`NoSecrets`, `Unknown`, `WifiAuthTimeout`, `WifiClientDisconnected`,
`WifiClientFailed`, `WifiNetworkLost`. QML resolves it to `undefined`, the
switch arm never matches, and a wrong password is reported with the
fallthrough text. `NoSecrets` is almost certainly the intended member.
`connectivity-contract`'s new enum check fails on exactly this line and passes
on a copy with it corrected, so the check is confirmed working and the fix is
a one-word edit in a service file this phase's contract owner does not own.
**This is the one item blocking a clean run.**
- **The enum check is scoped to `Connectivity.qml`.** The same class of bug —
a plausible enum member that does not exist, resolving to `undefined` in
silence — can live anywhere that imports `Quickshell.Networking`, including
`modules/quicksettings/WifiList.qml` and the bar's status cluster. Widening
the sweep to every QML file that imports the module is a small change and the
obvious next one. Owner: whoever holds `connectivity-contract` next.
- **`network-tools-contract` cannot see the libnm branch.** That is a deliberate
trade, but it means the branch that actually runs on this machine (NM 1.56,
bindings present) is only ever checked by reading. A machine without PyGObject
would exercise the fallback for real; nothing here has one.
- **Two contracts still pin prose.** `firewall-contract` requires the add flow
to say "permanent" and "ask for your password", and `sharing-contract`
requires "never passes through Panama", "install it and this becomes a switch"
and "does not install software". The spec names all five phrases, so they are
pinned deliberately, but they are the needles a copy edit will trip.
- **`printers-contract` pins the option vocabulary exactly** — `media` ∈
{Letter, A4, Legal}, `sides` ∈ {one-sided, two-sided-long-edge,
two-sided-short-edge}. Adding a third settable option is meant to be a
deliberate act that updates this contract, but it will read as a surprise the
first time somebody tries.
- **`connectivity-contract`'s live half is unchanged** and still starts a
harness beside the running session, so it wants the same quiet moment it
always did. `PANAMA_CONNECTIVITY_STATIC_ONLY=1` runs the new source-only half
alone.
- Run order for this phase: the source-only contracts first
(`gnome-handoff-contract`, `PANAMA_NETWORK_STATIC_ONLY=1
network-tools-contract`, `PANAMA_CONNECTIVITY_STATIC_ONLY=1
connectivity-contract`, `PANAMA_SETTINGS_STATIC_ONLY=1
settings-pages-contract`, `setup/readme-contract`), then
`network-tools-contract` in full — it is hermetic, so it can run at any time —
then the read-only system contracts (`firewall-contract`,
`printers-contract`, `sharing-contract`), then the harness contracts
(`connectivity-contract`, `health-ui-contract`, `settings-search-contract`),
and `settings-pages-contract` last, as before.
### Docs updated in the same wave
- `services/SettingsSearch.qml` — the three "Managed by GNOME Settings"
connectivity entries are gone, and with them the duplicate **Printers** entry
that routed to `connectivity` rather than to the Printers page. Ten entries
replace them, all routing to `connectivity`: **Wi-Fi**, **Bluetooth**, **VPN**,
**Import a VPN**, **Hotspot**, **Airplane mode**, **Network proxy**, **IP
address**, **Forget a Wi-Fi network**, **Enterprise Wi-Fi**. Wi-Fi and
Bluetooth were rewritten rather than deleted: the spec lists eight additions,
none of which contains the word "Bluetooth", so deleting the lying entry
outright would have made a switch the page has unreachable by search.
- Checked against `settings-search-contract`'s pinned query list: none of its 21
ranked queries, nor the nine leaf-routing queries, matches any new or changed
entry, so no pinned top result moves. `connectivity` is a leaf in
`SettingsRoutes`, so the routing sweep holds.
- No settings docs or launcher commands were regenerated: this phase added no
schema keys, so there is nothing for `panama-settings-docs` or
`panama-settings-commands` to pick up. Verified by reading the diff — every
new setting here is system state (NetworkManager, firewalld, CUPS, gsettings),
not a Panama preference.
@@ -0,0 +1,138 @@
# Network & Sharing redesign — the last punt dies
Approved mock: `home-mocks/network.html` (scratchpad, :8642). Spec wins over mock on conflict.
## Goals
1. **Connections becomes a full network manager**: per-connection details (IP/DNS/gateway/MAC),
forget + autoconnect + MAC randomization, native VPN card with import, hotspot, airplane
mode, enterprise Wi-Fi join, system proxy. The "Owned by Fedora" card and both GNOME punt
rows are removed.
2. **Firewall gains its add side**: allow service/port flow, per-connection zone dropdowns,
default zone, zone browser — all existing helper verbs plus one new read-only one.
3. **Printers**: per-printer defaults (paper size, two-sided) and job hold/release.
4. **Sharing**: copy/structure polish only (error banner into a card, honest Samba row, RDP
credential flow copy without naming the terminal in user-facing text).
5. Search stops lying ("Managed by GNOME Settings" entries die; duplicate Printers entry
deduped; new entries for VPN, hotspot, airplane, proxy, IP address).
Non-goals: Samba share management (not installed here — the row explains what installing
unlocks), PPD/vendor drivers (contract-banned), WWAN (no hardware), per-connection static IP
editing (view-only details this phase).
## New helper: `scripts/panama-network` (pinned verbs)
Python, the panama-vpn/panama-sharing discipline: validated inputs, JSON out, bounded
timeouts, mutations return fresh state. Input validation: connection/SSID names against a
conservative charset, file paths must exist and end in .conf/.ovpn for import.
- `details <connection>``{ ip4, gateway, dns: [], mac, macRandomized }`**never secrets**.
- `forget <connection>``nmcli connection delete`.
- `set-autoconnect <connection> <bool>`.
- `set-mac-random <connection> <bool>``wifi.cloned-mac-address random|permanent`; the JSON
notes a reconnect is needed to take effect.
- `import-vpn <file>``nmcli connection import type wireguard|openvpn file …` (type by
extension); returns the imported connection's name.
- `hotspot start <ssid>` / `hotspot stop` / `hotspot status``nmcli device wifi hotspot`;
the generated password is read back via `nmcli device wifi show-password` and returned once
for the UI to display alongside a QR (reuse the panama-wifi-qr pipeline if trivial, else
text-only this phase).
- `join-enterprise` — SSID + eap profile (peap-mschapv2 | ttls-pap) + identity on argv,
**password on stdin, never argv**. Mechanism: probe for python NM gi bindings
(`gi.require_version('NM','1.0')`) and use D-Bus AddConnection when present; else fall back
to a scripted `nmcli connection edit` session over stdin (which keeps the secret out of ps).
Agent A picks after probing and documents the choice in the script header.
- `proxy get` / `proxy set <mode> [host port | pac-url]` — gsettings `org.gnome.system.proxy`
(mode none|manual|auto; manual sets http+https+socks host/port together this phase).
- `airplane status` / `airplane set <bool>` — rfkill, matching the existing keybind path.
## Services (A)
- **`services/NetworkTools.qml`** (new singleton) wraps panama-network: cached `detailsFor
(connection)` (refreshed on page open + active-connection change), `forget`, `setAutoconnect`,
`setMacRandom`, `importVpn(path)`, hotspot state/start/stop, `proxyMode`+setters,
`airplaneOn`+toggle, `joinEnterprise(...)` (password handed through a Process stdin write),
`busy`/`lastError`. Test seam: `PANAMA_NETWORK_HELPER` env override for the helper path.
- **`services/Connectivity.qml`** stays pure-native (contract-pinned no-shell-out): add
`setWifiEnabled(bool)` and `setBluetoothEnabled(bool)` wrappers (native writes) so the page
toggles stop bypassing the service; add `objectName`-friendly derived state if needed.
- **`services/Firewall.qml`** + `scripts/panama-firewall`: new read-only verb
`zone-info <zone>` → services/ports/summary for the zone browser; service exposes
`zoneInfo(zone)` with a small cache. Existing verbs untouched.
- **`services/Printers.qml`** + `scripts/panama-printers`: new verbs
`get-options <printer>` (lpoptions: media, sides — curated keys only), `set-option
<printer> <key> <value>` (validated against a closed key/value vocabulary:
media=Letter|A4|Legal, sides=one-sided|two-sided-long-edge|two-sided-short-edge),
`hold <job>` / `release <job>` (python3-cups). Mutations return fresh snapshots.
- `services/Vpn.qml` untouched (import lands through NetworkTools; the list/toggle stays as-is,
now also consumed by the page).
## UI (B)
**ConnectivityPage.qml** rebuilt: Wired card (connection row expands to a details KV grid);
Wi-Fi card (toggle via `Connectivity.setWifiEnabled`, connected network expands to details +
autoconnect + MAC-randomize + QR share + Forget with a two-stage confirm; other networks keep
Join/inline password; enterprise networks get an inline join form — auth dropdown, identity,
password field, optional CA file path, Connect; hotspot row at the bottom); **VPN card**
(list from `Vpn.qml` with per-VPN toggles, empty state + Import row → file path entry or
zenity-free inline TextField this phase); Bluetooth card (toggle via service wrapper);
**Radios & proxy card** (airplane toggle, proxy dropdown expanding to manual host/port or PAC
URL fields). The "Owned by Fedora" card is deleted. Page gains `objectName: "connectivity"`.
KV details grid is a new small component (`ConnectionDetails.qml`) with mono values.
**FirewallPage.qml**: existing exposure/remove flows untouched; "Allow something new" row
expanding to a kind dropdown (Named service / Port) + validated TextField + Allow button +
the "permanent rule, the system will ask for your password" caption; Zones card gains
per-connection zone dropdowns (`setZone`, with a confirm that names the interface), default
zone dropdown (`setDefaultZone`), and a zone-browser chip row driving `zoneInfo` into a detail
line. Only render `zones[0]`'s rules card per current behavior, but the zones card now lists
every active zone.
**SharingPage.qml**: error banner moves into the This-machine card; Remote-login card
compresses port/password-mode/connect-with into row details; RDP credentials copy becomes
"Set in a terminal so the password never passes through Panama" (kitty stays the mechanism);
Samba row explains "install it and this becomes a switch".
**PrintersPage.qml**: expanded printer gains Paper size + Two-sided dropdowns (from
`get-options`, written via `set-option`); queue rows gain Hold/Release next to Cancel; the
two duplicate "Search the network" rows merge into the single empty-state card per mock.
## Search & docs (C)
Delete the three "Managed by GNOME Settings" connectivity entries and the duplicate Printers
entry. Add (page connectivity): VPN, Import a VPN, Hotspot, Airplane mode, Network proxy,
IP address, Forget a Wi-Fi network, Enterprise Wi-Fi. Keep existing printers/firewall/sharing
entries. Docs regen: none needed (no schema keys) — verify.
## Contracts (C — write, never run)
- `connectivity-contract`: still native-only — verify the no-shell-out pin survives (all nmcli
lives in NetworkTools/panama-network); add needles for the service-wrapper toggles.
- New `network-tools-contract`: stubbed-nmcli fixtures (the vpn-contract pattern): details
excludes secrets, forget/autoconnect argv shapes, import type-by-extension, enterprise
password arrives via stdin and NEVER argv (assert the argv builder), proxy gsettings calls,
airplane rfkill.
- `firewall-contract`: extend for the add flow (additions are non-destructive, no confirm
required; zone changes ARE consequence-bearing — pin that changing a connection's zone names
the interface in its confirm), `zone-info` read-only.
- `printers-contract`: closed option vocabulary pinned (no arbitrary lpadmin -o passthrough),
hold/release verbs, driverless pins untouched.
- `gnome-handoff-contract`: the `network` exclusion reason ("Panama has no VPN or
per-connection routing") is now false — move `network` and `wifi` into OWNED so no page may
punt to them, and verify nothing does.
- `sharing-contract`, `settings-pages-contract`: needles reconciled.
- Backlog: Phase 9 section.
## Agent ownership (parallel)
- **A**: `scripts/panama-network` (new), `scripts/panama-firewall`, `scripts/panama-printers`,
`services/NetworkTools.qml` (new), `services/Connectivity.qml`, `services/Firewall.qml`,
`services/Printers.qml`.
- **B**: the four pages + new components (+ qmldir).
- **C**: `services/SettingsSearch.qml`, contracts above, backlog, README count line only if
count changes.
B programs against the pinned verbs/APIs; A must not change them without updating this spec.
Live-desktop rules apply to everyone: no test runs, no harness boots, valid QML/Python at every
save, and **no live mutations** — no nmcli writes, no firewall-cmd writes, no lpadmin, no
rfkill, no gsettings writes; read-only probes only.
+114
View File
@@ -20,12 +20,126 @@ set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/connectivity-harness.qml" harness="$repo_dir/config/dot/quickshell/connectivity-harness.qml"
service="$repo_dir/config/dot/quickshell/services/Connectivity.qml"
page="$repo_dir/config/dot/quickshell/modules/settings/ConnectivityPage.qml"
fail() { fail() {
printf 'connectivity contract: %s\n' "$1" >&2 printf 'connectivity contract: %s\n' "$1" >&2
exit 1 exit 1
} }
# ── Connectivity stays native ────────────────────────────────────────────────
#
# This service reads NetworkManager and BlueZ through Quickshell's own bindings,
# never by shelling out. That is not a style preference: a subprocess per read
# turns a property binding into a fork on every repaint, and it loses the change
# signals the whole page is built on -- the page would go back to polling and
# would go stale between polls.
#
# When Connections grew a helper (`panama-network`, driven by NetworkTools), the
# obvious shortcut was to let this service reach for it too. It must not. Every
# nmcli invocation belongs on the far side of that helper; what stays here is
# the native state and the wrappers over it.
for path in "$service" "$page"; do
[[ -r "$path" ]] || fail "missing $path"
done
service_code="$(grep -vE '^\s*//' "$service")"
! grep -qE '\bnmcli\b|\bProcess\b|execDetached|exec\(' <<<"$service_code" \
|| fail 'Connectivity.qml shells out; NetworkManager reads here are native, and nmcli belongs in panama-network'
# The page used to write NetworkManager state straight from a switch
# (`Networking.wifiEnabled = value`, `adapter.enabled = value`), which put two
# owners on one piece of state and left the service unable to react to its own
# change. The wrappers exist so the page has exactly one way in.
grep -q 'function setWifiEnabled' <<<"$service_code" \
|| fail 'the service has no Wi-Fi wrapper, so the page has to write NetworkManager itself'
grep -q 'function setBluetoothEnabled' <<<"$service_code" \
|| fail 'the service has no Bluetooth wrapper, so the page has to write the adapter itself'
page_code="$(grep -vE '^\s*//' "$page")"
grep -q 'Connectivity.setWifiEnabled' <<<"$page_code" \
|| fail 'the Wi-Fi switch does not go through the service'
grep -q 'Connectivity.setBluetoothEnabled' <<<"$page_code" \
|| fail 'the Bluetooth switch does not go through the service'
! grep -qE 'Networking\.wifiEnabled\s*=' <<<"$page_code" \
|| fail 'the page still writes Networking.wifiEnabled directly, bypassing the service'
! grep -qE 'adapter\.enabled\s*=' <<<"$page_code" \
|| fail 'the page still writes the Bluetooth adapter directly, bypassing the service'
# The card that sent people to GNOME for Wi-Fi and networking is gone, and with
# it the claim that Fedora owns this page. gnome-handoff-contract pins the rule;
# this pins the specific rows, because they are what the card was made of.
! grep -q 'Owned by Fedora' <<<"$page_code" \
|| fail 'the "Owned by Fedora" card is back on a page that now manages the network itself'
! grep -qE 'openGnomePanel\("(wifi|network)"' <<<"$page_code" \
|| fail 'Connections still hands the user to GNOME for something it now does'
# ── Every enum member the service names must exist ───────────────────────────
#
# This is the first bug in this file's header, generalized. `NetworkDeviceType
# .Wifi` does not exist, so the lookup returned null and the page said "No Wi-Fi
# adapter" on a machine whose Wi-Fi was connected. The same thing happened again
# in securityLabel: `WifiSecurityType.Wep`, `.Wpa`, `.Wpa2`, `.Wpa3` and
# `.Enterprise` are not members either -- the real ones are WpaPsk, Wpa2Psk,
# Sae, StaticWep, WpaEap and so on -- so every switch arm compared against
# `undefined`, nothing ever matched, and every secured network was labeled with
# the fallthrough. Plausible names, no error, wrong answer.
#
# QML resolves an unknown enum member to undefined and says nothing, so no test
# that runs the code can notice. This reads the member list out of the installed
# Quickshell's own type description, so it stays true across upgrades rather
# than becoming a second hand-kept list that can drift the same way.
networking_types=""
for candidate in /usr/lib64/qt6/qml/Quickshell/Networking/quickshell-network.qmltypes \
/usr/lib/qt6/qml/Quickshell/Networking/quickshell-network.qmltypes; do
[[ -r "$candidate" ]] && { networking_types="$candidate"; break; }
done
if [[ -z "$networking_types" ]]; then
printf 'connectivity contract: NOTE (no Quickshell.Networking qmltypes found; enum members unchecked)\n'
else
python3 - "$service" "$networking_types" <<'PY' || fail 'the service names enum members that do not exist, which QML resolves to undefined without complaining'
import re
import sys
service_path, types_path = sys.argv[1:3]
types_text = open(types_path, encoding="utf-8").read()
# Each exported enum singleton: the name QML sees, and the members it has.
enums = {}
for block in re.findall(r"Component \{.*?\n \}", types_text, re.S):
export = re.search(r'exports: \["Quickshell\.Networking/([A-Za-z0-9_]+) ', block)
values = re.search(r"values: \[(.*?)\]", block, re.S)
if export and values:
enums[export.group(1)] = set(re.findall(r'"([A-Za-z0-9_]+)"', values.group(1)))
if not enums:
print(f"no enums could be read from {types_path}", file=sys.stderr)
raise SystemExit(1)
source = "\n".join(line for line in open(service_path, encoding="utf-8")
if not line.lstrip().startswith("//"))
bad = []
for enum_name, members in enums.items():
for member in set(re.findall(rf"\b{re.escape(enum_name)}\.([A-Za-z0-9_]+)\b", source)):
if member not in members:
bad.append(f"{enum_name}.{member} (real members: {', '.join(sorted(members))})")
# A switch on a security type that names no member of the enum at all is the
# same failure wearing a different hat, so the reference must be there too.
if "securityLabel" in source and not re.search(r"\bWifiSecurityType\.", source):
bad.append("securityLabel decides security without naming a WifiSecurityType member")
if bad:
print("\n".join(bad), file=sys.stderr)
raise SystemExit(1)
PY
fi
if [[ "${PANAMA_CONNECTIVITY_STATIC_ONLY:-0}" == "1" ]]; then
printf 'connectivity contract: PASS (static)\n'
exit 0
fi
command -v nmcli >/dev/null || fail 'nmcli is needed to check the service against reality' command -v nmcli >/dev/null || fail 'nmcli is needed to check the service against reality'
run() { qs -p "$harness" "$@"; } run() { qs -p "$harness" "$@"; }
+97
View File
@@ -113,6 +113,86 @@ grep -q 'richRules' <<<"$page_code" || fail 'rich rules are not shown'
grep -qiE 'addRichRule|removeRichRule|--add-rich-rule' "$helper" "$page" \ grep -qiE 'addRichRule|removeRichRule|--add-rich-rule' "$helper" "$page" \
&& fail 'the page edits rich rules, which are a syntax rather than a setting' && fail 'the page edits rich rules, which are a syntax rather than a setting'
# ── 6. Opening something is not the same act as closing it ──────────────────
#
# The page gained its add side long after its remove side, and the temptation
# was to give both the same confirm-then-act shape for symmetry. That would be
# wrong, and wrong in the direction that matters: a confirmation dialog is how
# this page says "this has a consequence you cannot see from here". Allowing a
# port has exactly one consequence, and it is the sentence the user just read on
# the button. Spending a confirm on it teaches people to click through the ones
# that mean something.
#
# So: additions go straight through, removals and zone changes do not.
grep -q 'Firewall.addService\|addService(' <<<"$page_code" \
|| fail 'the page cannot allow a named service, so the firewall is still read-only from here'
grep -q 'Firewall.addPort\|addPort(' <<<"$page_code" \
|| fail 'the page cannot allow a port'
grep -qE 'confirming(Add|Allow|Service|Port)\b' <<<"$page_code" \
&& fail 'allowing something asks for a confirmation; that ceremony belongs to the actions that cut people off'
# What it must say instead, because both facts are invisible from the button:
# the rule outlives a reboot, and firewalld will raise a polkit prompt.
grep -qi 'permanent' <<<"$page_code" \
|| fail 'the add flow never says the rule is permanent'
grep -qi 'ask for your password' <<<"$page_code" \
|| fail 'the add flow never warns that the system will ask for a password'
# A zone change IS consequence-bearing, and its consequence is specific: it
# changes which rules apply to one named interface, and every other interface
# keeps the zone it had. A confirm that says "change zone?" tells the user
# nothing they did not already know, so this pins that the interface is named.
grep -q 'setZone' <<<"$page_code" \
|| fail 'the page cannot change a connection zone'
grep -q 'setDefaultZone' <<<"$page_code" \
|| fail 'the page cannot change the default zone'
grep -qE '(confirming|pending)(Zone|Interface)' <<<"$page_code" \
|| fail 'a connection zone can be changed without confirming, and it decides which rules apply to that link'
python3 - "$page" <<'PY' || fail 'the zone-change confirmation does not name the interface it applies to'
import re
import sys
lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
anchors = [i for i, line in enumerate(lines)
if re.search(r"(confirming|pending)(Zone|Interface)", line)]
if not anchors:
raise SystemExit(1)
# Any one of the two-stage anchors may be the one carrying the prose; the
# declaration of the state is usually not.
for anchor in anchors:
window = "\n".join(lines[max(0, anchor - 20):anchor + 60])
named = re.search(r"(interface|iface)", window, re.I)
interpolated = re.search(r"\$\{|\" \+ |\+ \"", window)
if named and interpolated:
raise SystemExit(0)
raise SystemExit(1)
PY
# ── 7. The zone browser reads and does not write ────────────────────────────
#
# `zone-info` exists so somebody can look at what a zone would do before moving
# an interface into it. A read that can write is not a browser, it is a foot-gun
# with a magnifying glass on it.
grep -q 'zone-info' "$helper" \
|| fail 'the helper cannot describe a zone, so the zone browser has nothing to show'
grep -q 'zoneInfo' "$service" \
|| fail 'the service does not expose zone descriptions'
python3 - "$helper" <<'PY' || fail 'the zone-info path can change the firewall'
import re
import sys
source = open(sys.argv[1], encoding="utf-8").read()
match = re.search(r"\ndef zone_info\b.*?(?=\ndef |\Z)", source, re.S)
if not match:
raise SystemExit(1)
body = match.group(0)
# --info-zone and --list-* are reads. Anything that adds, removes, changes or
# makes permanent is not.
if re.search(r"--(add|remove|change|set|permanent|reload)", body):
print(body[:400], file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
command -v jq >/dev/null 2>&1 || { printf 'firewall contract: SKIP (no jq)\n'; exit 0; } 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' state="$("$helper" snapshot 2>/dev/null)" || fail 'snapshot failed'
@@ -142,8 +222,25 @@ if [[ "$(jq -r '.available' <<<"$state")" == "true" ]]; then
|| fail 'a loopback-only listener is reported as reachable' || fail 'a loopback-only listener is reported as reachable'
fi fi
# ── zone-info describes a zone and leaves it exactly as it found it ─────────
if [[ "$(jq -r '.available' <<<"$state")" == "true" ]]; then
zone_name="$(jq -r '.zones[0].name // ""' <<<"$state")"
if [[ -n "$zone_name" ]]; then
info="$("$helper" zone-info "$zone_name" 2>/dev/null)" \
|| fail "zone-info failed for the zone this machine is actually in ($zone_name)"
jq -e '(.services | type == "array") and (.ports | type == "array") and has("summary")' \
<<<"$info" >/dev/null \
|| fail "zone-info does not describe services, ports and a summary: $info"
after="$("$helper" snapshot 2>/dev/null)" || fail 'the snapshot after zone-info failed'
[[ "$(jq -cS '.zones' <<<"$after")" == "$(jq -cS '.zones' <<<"$state")" ]] \
|| fail 'reading a zone changed the firewall, which is the one thing a browser must not do'
fi
fi
# ── Refusals ──────────────────────────────────────────────────────────────── # ── Refusals ────────────────────────────────────────────────────────────────
refusal() { "$helper" "$@" 2>/dev/null | jq -r '.error // ""'; } refusal() { "$helper" "$@" 2>/dev/null | jq -r '.error // ""'; }
[[ -n "$(refusal zone-info 'public; reboot')" ]] || fail 'a bad zone name was accepted by zone-info'
[[ -n "$(refusal zone-info '')" ]] || fail 'an empty zone name was accepted by zone-info'
for bad in "ssh; rm -rf /" "../escape" "" "UPPER CASE"; do for bad in "ssh; rm -rf /" "../escape" "" "UPPER CASE"; do
[[ -n "$(refusal add-service "$bad")" ]] || fail "a bad service name was accepted: $bad" [[ -n "$(refusal add-service "$bad")" ]] || fail "a bad service name was accepted: $bad"
done done
+16 -4
View File
@@ -33,11 +33,23 @@ fail() {
[[ -r "$routes" ]] || fail "missing $routes" [[ -r "$routes" ]] || fail "missing $routes"
# GNOME panel names that correspond to a Panama page. Only entries whose panel # GNOME panel names that correspond to a Panama page. Only entries whose panel
# genuinely duplicates a Panama page belong here: "network" stays off it because # genuinely duplicates a Panama page belong here.
# Panama has no VPN or per-connection routing, and "online-accounts" is listed #
# because Panama has an Online Accounts page -- but adding an account still has # "network" and "wifi" were deliberately kept off this list, on the reason that
# to go through GOA's own dialog, so that one exception is named explicitly. # Panama had no VPN and no per-connection routing, so GNOME's panel really did
# do more. That stopped being true: Connections now carries per-connection
# details, forget, autoconnect, MAC randomization, a VPN list with import, a
# hotspot, enterprise Wi-Fi, airplane mode and the system proxy. The two rows
# that pointed at GNOME were the last thing on that page telling the user to go
# somewhere else for something it does, so both panels moved here and the rows
# went with them.
#
# "online-accounts" is listed because Panama has an Online Accounts page -- but
# adding an account still has to go through GOA's own dialog, so that one
# exception is named explicitly below.
declare -A OWNED=( declare -A OWNED=(
[network]=connectivity
[wifi]=connectivity
[printers]=printers [printers]=printers
[online-accounts]=accounts [online-accounts]=accounts
[sharing]=sharing [sharing]=sharing
+9 -1
View File
@@ -61,8 +61,16 @@ rg -Fq 'implicitHeight: 62' "$settings_dir/HealthCheckRow.qml" \
|| fail 'health rows are below the approved 62px target' || fail 'health rows are below the approved 62px target'
rg -Fq 'Health.refresh()' "$settings_dir/HealthPage.qml" \ rg -Fq 'Health.refresh()' "$settings_dir/HealthPage.qml" \
|| fail 'opening System Health does not request a fresh scan' || fail 'opening System Health does not request a fresh scan'
rg -Fq 'SystemSettings.openGnomePanel("network")' "$settings_dir/HealthPage.qml" \ # "Open GNOME Settings" opens the application, not a panel -- but
# gnome-control-center will not start without naming one, so it names the
# landing page. It used to name "network", which stopped being honest when
# Connections absorbed VPN, hotspot, proxy and per-connection details: Panama
# owns that panel now, and gnome-handoff-contract fails any page pointing at an
# owned one. "system" is a panel Panama does not have.
rg -Fq 'SystemSettings.openGnomePanel("system")' "$settings_dir/HealthPage.qml" \
|| fail 'Fedora ownership boundary does not open GNOME Settings' || fail 'Fedora ownership boundary does not open GNOME Settings'
rg -Fq 'SystemSettings.openGnomePanel("network")' "$settings_dir/HealthPage.qml" \
&& fail 'System Health lands GNOME Settings on its network panel, which Panama now owns'
# Users and Sharing are Panama pages now. A handoff here would send someone to # Users and Sharing are Panama pages now. A handoff here would send someone to
# GNOME for a panel this app owns, which is the opposite of the point -- so the # GNOME for a panel this app owns, which is the opposite of the point -- so the
# assertion is inverted rather than deleted. # assertion is inverted rather than deleted.
+614
View File
@@ -0,0 +1,614 @@
#!/usr/bin/env bash
# Everything Connections learned to do, against a NetworkManager that is not
# real.
#
# This helper is the first one in Panama that handles a secret the user types.
# Three of its verbs sit next to a password: joining an enterprise network takes
# one, starting a hotspot generates one, and reading a connection's details sits
# on top of a store full of them. Each of those is a different way for a
# credential to escape:
#
# * on argv, where /proc publishes it to every process on this machine for as
# long as the command runs -- the same failure the Sharing page already
# refuses to have (sharing-contract, "the remote desktop password never
# passes through Panama");
# * in a log line, which outlives the command;
# * in the details JSON, which the page renders and which is the one place a
# stored PSK would look like it belonged.
#
# So the pins here are mostly about what must NOT appear, and they are checked
# from the data rather than from the source: the stub NetworkManager will hand
# over a passphrase to anything that asks for one, and the contract fails if any
# of them reaches the JSON, the argv log, or a file the helper wrote.
#
# The rest is the shape of the commands. `import-vpn` picking its plugin from
# the file extension, and the connection-name charset, are both places where a
# wrong guess produces a nonsense nmcli invocation rather than an error, so both
# are exercised rather than read.
#
# SAFETY. This runs the real helper, so it must be impossible for it to reach
# the real NetworkManager. Four things make that true, and the contract verifies
# the first two before running anything:
#
# 1. every system binary the helper names is resolved through PATH (asserted
# statically -- an absolute /usr/bin/nmcli would walk straight past the
# stubs), and PATH's first entry is the stub directory;
# 2. nmcli, gsettings, rfkill and every D-Bus client are stubbed, each
# recording its arguments and its stdin instead of doing anything;
# 3. `import gi` resolves to a stand-in on PYTHONPATH whose require_version
# always raises, so join-enterprise cannot take its libnm/D-Bus branch and
# falls back to nmcli, where the recording can see what it did;
# 4. both D-Bus bus addresses point at sockets that do not exist, so anything
# that got past (3) still could not connect.
#
# That means the native libnm branch is not exercised here. It is covered
# statically instead, by the check that no command list anywhere in the helper
# carries the password -- the failure that branch could have.
#
# Set PANAMA_NETWORK_STATIC_ONLY=1 to run only the source-reading half, which
# touches nothing at all.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-network"
service="$repo_dir/config/dot/quickshell/services/NetworkTools.qml"
fail() {
printf 'network tools contract: %s\n' "$1" >&2
exit 1
}
for path in "$helper" "$service"; do
[[ -r "$path" ]] || fail "missing $path"
done
[[ -x "$helper" ]] || fail 'panama-network is not executable'
# Sentinels. Every one of these is a value the fake NetworkManager will hand
# over, and none of them may come back out.
readonly WIFI_PSK='psk-must-never-leave-9c1f'
readonly VPN_SECRET='vpn-secret-must-never-leave-7b20'
readonly ENTERPRISE_PW='enterprise-pw-must-never-leave-4e88'
readonly HOTSPOT_PW='hotspot-pw-must-never-leave-3a55'
# ── Static: the helper cannot walk past the stubs ────────────────────────────
#
# Checked first because the dynamic half's safety rests on it. A helper that
# spelled its NetworkManager client `/usr/bin/nmcli` would ignore the stub
# directory entirely and reconfigure the machine running the test.
absolute="$(grep -nE '"/(usr/)?s?bin/[a-z-]+"' "$helper")"
[[ -z "$absolute" ]] \
|| fail "the helper names a binary by absolute path, so PATH stubs cannot contain it: $absolute"
# ── Static: reading a connection cannot read a secret ────────────────────────
#
# By name, in one table, refused in the single function every property read goes
# through -- so a field added later cannot quietly become a leak, which a
# hand-written allowlist of safe fields would eventually permit.
grep -q 'SECRET_PROPERTIES' "$helper" \
|| fail 'the secret-holding properties are not named anywhere, so nothing can refuse them'
grep -q 'SECRET_SHAPE' "$helper" \
|| fail 'only a fixed list guards the secrets; a property NetworkManager adds later would leak until somebody noticed'
python3 - "$helper" <<'PY' || fail 'the property reader does not drop the secret properties'
import ast
import sys
source = open(sys.argv[1], encoding="utf-8").read()
tree = ast.parse(source)
secrets = set()
for node in tree.body:
target = ""
if isinstance(node, ast.AnnAssign):
target = getattr(node.target, "id", "")
elif isinstance(node, ast.Assign):
target = getattr(node.targets[0], "id", "")
if target != "SECRET_PROPERTIES":
continue
value = node.value
# frozenset({...}) / set({...}) is a call wrapped around the literal.
if isinstance(value, ast.Call) and value.args:
value = value.args[0]
try:
secrets = set(ast.literal_eval(value))
except (ValueError, TypeError):
secrets = set()
# The properties that actually hold a passphrase must be in it, or the table is
# decorative.
required = {"802-11-wireless-security.psk", "802-1x.password"}
if not required <= secrets:
print(f"missing from the refusal table: {sorted(required - secrets)}", file=sys.stderr)
raise SystemExit(1)
# And the filter has to be applied where NetworkManager is read, not at each
# use: one place that can be certain, rather than every caller remembering.
reader = next((node for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef) and node.name == "listing"), None)
if reader is None:
print("no single function parses nmcli output", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0 if "is_secret" in ast.dump(reader) else 1)
PY
grep -q -- '--show-secrets' "$helper" \
&& fail 'the helper asks NetworkManager to print secrets; the details view has no use for them'
# ── Static: the enterprise password is read, not passed ──────────────────────
#
# The mechanism is a choice (libnm's GObject bindings when they are installed, a
# scripted `nmcli connection edit` otherwise), but neither may build a command
# that carries the password. This is the only check that reaches the libnm
# branch, because the dynamic half deliberately disables it.
grep -q 'sys.stdin' "$helper" \
|| fail 'the enterprise password is never read from stdin'
python3 - "$helper" <<'PY' || fail 'a command list in the helper carries the enterprise password'
import ast
import re
import sys
source = open(sys.argv[1], encoding="utf-8").read()
# Names, not values: the value only exists at runtime. Any list literal that
# holds a system command and a password-shaped identifier is argv exposure.
SECRETISH = re.compile(r"(password|passwd|secret|psk|passphrase)", re.I)
COMMANDS = {"nmcli", "gsettings", "rfkill", "gdbus", "busctl", "dbus-send"}
bad = []
for node in ast.walk(ast.parse(source)):
if not isinstance(node, (ast.List, ast.Tuple)):
continue
literals = {element.value for element in node.elts
if isinstance(element, ast.Constant) and isinstance(element.value, str)}
if not literals & COMMANDS:
continue
for element in node.elts:
if isinstance(element, ast.Name) and SECRETISH.search(element.id):
bad.append(f"line {node.lineno}: {element.id}")
elif isinstance(element, ast.Attribute) and SECRETISH.search(element.attr):
bad.append(f"line {node.lineno}: .{element.attr}")
elif isinstance(element, ast.JoinedStr):
for part in ast.walk(element):
if isinstance(part, ast.Name) and SECRETISH.search(part.id):
bad.append(f"line {node.lineno}: f-string {part.id}")
if bad:
print("; ".join(bad), file=sys.stderr)
raise SystemExit(1)
PY
# ── Static: the service hands the password down the same way ─────────────────
grep -q 'PANAMA_NETWORK_HELPER' "$service" \
|| fail 'the service has no helper-path seam, so nothing can point it at a stub'
grep -q 'function joinEnterprise' "$service" \
|| fail 'the service cannot join an enterprise network'
python3 - "$service" <<'PY' || fail 'the service puts the enterprise password on the command line'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
start = text.find("function joinEnterprise")
if start < 0:
raise SystemExit(1)
depth = 0
end = start
for index in range(text.find("{", start), len(text)):
if text[index] == "{":
depth += 1
elif text[index] == "}":
depth -= 1
if depth == 0:
end = index
break
body = text[start:end + 1]
# Every argument list the function builds, checked for a password-shaped name.
lists = re.findall(r"\[[^\[\]]*\]", body)
if not lists:
print("joinEnterprise builds no argument list at all", file=sys.stderr)
raise SystemExit(1)
for argv in lists:
if re.search(r"(password|secret|passphrase|psk)", argv, re.I):
print(argv.strip()[:200], file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
grep -qE '\.write\(|stdinEnabled' "$service" \
|| fail 'the service never writes to the helper process stdin, so the password has no way in'
if [[ "${PANAMA_NETWORK_STATIC_ONLY:-0}" == "1" ]]; then
printf 'network tools contract: PASS (static)\n'
exit 0
fi
command -v jq >/dev/null 2>&1 || { printf 'network tools contract: SKIP (no jq)\n'; exit 0; }
command -v python3 >/dev/null 2>&1 || { printf 'network tools contract: SKIP (no python3)\n'; exit 0; }
# ── The fake machine ─────────────────────────────────────────────────────────
work="$(mktemp -d /tmp/panama-network-contract.XXXXXX)"
stub_dir="$work/bin"
state_dir="$work/state"
home_dir="$work/home"
config_home="$work/config"
state_home="$work/xdg-state"
run_dir="$work/run"
pystub="$work/pystub"
mkdir -p "$stub_dir" "$state_dir" "$home_dir" "$config_home" "$state_home" "$run_dir" "$pystub/gi"
: >"$state_dir/argv"
: >"$state_dir/stdin"
printf '11111111-0000-0000-0000-000000000001\n22222222-0000-0000-0000-000000000002\n' \
>"$state_dir/uuids"
trap 'rm -rf "$work"' EXIT
# The stand-in for PyGObject. libnm's own branch hands the password to
# NetworkManager over D-Bus, which is correct and untestable from here -- it
# would configure the real machine. Forcing its absence puts the fallback under
# test instead, and that is the branch where a password could become an
# argument.
cat >"$pystub/gi/__init__.py" <<'GISTUB'
"""Stand-in for PyGObject, so panama-network takes its nmcli fallback."""
def require_version(namespace, version):
raise ValueError(f"Namespace {namespace} not available")
GISTUB
# nmcli, recorded rather than performed. `-g FIELD` is answered from a table
# that INCLUDES the secret properties: a helper that asked for a passphrase
# would be given one, which is what makes "no secret reached the JSON" a real
# result rather than a tautology.
cat >"$stub_dir/nmcli" <<STUB
#!/usr/bin/env bash
state="$state_dir"
printf 'nmcli %s\n' "\$*" >>"\$state/argv"
joined="\$*"
# The detail listing INCLUDES the secret properties. A helper that handed its
# output straight to the page would leak them, which is what makes "no secret
# reached the JSON" a result rather than a tautology.
detail() {
printf 'connection.id:Home Wi-Fi\n'
printf 'connection.uuid:22222222-0000-0000-0000-000000000002\n'
printf 'connection.type:802-11-wireless\n'
printf 'connection.autoconnect:yes\n'
printf '802-11-wireless.cloned-mac-address:random\n'
printf '802-11-wireless.ssid:panama-hotspot\n'
printf '802-11-wireless.band:bg\n'
printf 'GENERAL.STATE:activated\n'
printf 'GENERAL.DEVICES:wlp4s0\n'
printf 'GENERAL.HWADDR:AA:BB:CC:DD:EE:FF\n'
printf 'IP4.ADDRESS[1]:192.168.7.42/24\n'
printf 'IP6.ADDRESS[1]:fd00::42/64\n'
printf 'IP4.GATEWAY:192.168.7.1\n'
printf 'IP4.DNS[1]:192.168.7.1\n'
printf 'IP4.DNS[2]:1.1.1.1\n'
printf '802-11-wireless-security.psk:$WIFI_PSK\n'
printf '802-1x.password:$ENTERPRISE_PW\n'
printf 'vpn.secrets.password:$VPN_SECRET\n'
}
case "\$joined" in
*"connection edit"*)
# The only invocation that is fed anything, and it is drained under a
# timeout: every other one inherits whatever stdin the test runner had,
# and reading that would hang the suite.
timeout 5 cat >>"\$state/stdin" 2>/dev/null || true
exit 0 ;;
*"connection import"*)
printf '33333333-4444-5555-6666-777777777777\n' >>"\$state/uuids"
printf "Connection 'imported-profile' (33333333-4444-5555-6666-777777777777) successfully added.\n"
exit 0 ;;
*"device wifi show-password"*)
printf 'SSID: panama-hotspot\n'
printf 'Security: WPA2\n'
printf 'Password: $HOTSPOT_PW\n'
exit 0 ;;
*"device wifi hotspot"*)
printf "Device 'wlp4s0' successfully activated.\n"
exit 0 ;;
*"-f UUID connection show"*)
cat "\$state/uuids"
exit 0 ;;
*"-f DEVICE,TYPE device"*)
printf 'wlp4s0:wifi\nenp5s0:ethernet\nlo:loopback\n'
exit 0 ;;
*"device show"*)
printf 'GENERAL.HWADDR:AA:BB:CC:DD:EE:FF\n'
exit 0 ;;
*"connection show "*)
detail
exit 0 ;;
esac
exit 0
STUB
cat >"$stub_dir/gsettings" <<STUB
#!/usr/bin/env bash
printf 'gsettings %s\n' "\$*" >>"$state_dir/argv"
case "\$*" in
"get org.gnome.system.proxy mode") printf "'none'\n" ;;
*" port") printf '0\n' ;;
get*) printf "''\n" ;;
esac
exit 0
STUB
cat >"$stub_dir/rfkill" <<STUB
#!/usr/bin/env bash
printf 'rfkill %s\n' "\$*" >>"$state_dir/argv"
case "\$*" in
*-J*|*--json*)
printf '{"rfkilldevices":[{"id":0,"type":"wlan","device":"phy0","soft":"unblocked","hard":"unblocked"},{"id":1,"type":"bluetooth","device":"hci0","soft":"unblocked","hard":"unblocked"}]}\n' ;;
list*|"")
printf '0: phy0: Wireless LAN\n\tSoft blocked: no\n\tHard blocked: no\n'
printf '1: hci0: Bluetooth\n\tSoft blocked: no\n\tHard blocked: no\n' ;;
esac
exit 0
STUB
# Any other route out of this process is closed rather than left open.
for blocked in gdbus busctl dbus-send nm-connection-editor nmtui pkexec; do
cat >"$stub_dir/$blocked" <<STUB
#!/usr/bin/env bash
printf '$blocked %s\n' "\$*" >>"$state_dir/argv"
printf 'network tools contract: the helper reached for $blocked\n' >&2
exit 1
STUB
done
chmod +x "$stub_dir"/*
runh() {
env -i \
PATH="$stub_dir:/usr/bin:/bin" \
PYTHONPATH="$pystub" \
HOME="$home_dir" \
XDG_CONFIG_HOME="$config_home" \
XDG_STATE_HOME="$state_home" \
XDG_RUNTIME_DIR="$run_dir" \
DBUS_SESSION_BUS_ADDRESS="unix:path=$run_dir/absent-session-bus" \
DBUS_SYSTEM_BUS_ADDRESS="unix:path=$run_dir/absent-system-bus" \
LANG=C LC_ALL=C \
"$helper" "$@"
}
# The safety claim, verified rather than assumed.
for binary in nmcli gsettings rfkill; do
resolved="$(env -i PATH="$stub_dir:/usr/bin:/bin" bash -c "command -v $binary")"
[[ "$resolved" == "$stub_dir/$binary" ]] \
|| fail "$binary resolves to '$resolved', not the stub; refusing to run against the real one"
done
error_of() { runh "$@" 2>/dev/null | jq -r '.error // ""'; }
log() { cat "$state_dir/argv"; }
leak_in_scratch() {
grep -rlF "$1" "$home_dir" "$config_home" "$state_home" "$run_dir" 2>/dev/null | head -1
}
# ── details: the whole point is what is missing ──────────────────────────────
: >"$state_dir/argv"
details="$(runh details 'Home Wi-Fi' 2>/dev/null)" \
|| fail 'details failed against the stub NetworkManager'
jq -e 'has("ip4") and has("gateway") and has("dns") and has("mac") and has("macRandomized")' \
<<<"$details" >/dev/null || fail "details is missing part of its shape: $details"
jq -e '.dns | type == "array"' <<<"$details" >/dev/null \
|| fail 'dns is not a list, so a second nameserver has nowhere to go'
jq -e '(.ip4 | length) > 0 and (.gateway | length) > 0 and (.mac | length) > 0' <<<"$details" >/dev/null \
|| fail "details parsed nothing out of the connection listing: $details"
jq -e '.macRandomized == true' <<<"$details" >/dev/null \
|| fail 'a connection whose cloned MAC is "random" is not reported as randomized'
for secret in "$WIFI_PSK" "$ENTERPRISE_PW" "$VPN_SECRET"; do
grep -Fq "$secret" <<<"$details" \
&& fail 'a stored secret reached the details JSON, which the page renders'
done
# The absence above only means something if the secrets were on offer. They
# were: the very listing that produced the address, the gateway and the MAC
# carries all three, asked of the fake NetworkManager directly. So that block
# says the filter ran, not that there was nothing to filter.
served="$(PATH="$stub_dir:$PATH" nmcli -t -e no connection show 'Home Wi-Fi')"
for secret in "$WIFI_PSK" "$ENTERPRISE_PW" "$VPN_SECRET"; do
grep -Fq "$secret" <<<"$served" \
|| fail 'the fake NetworkManager offered no secret, so the details filter was never actually tested'
done
# Not just the values: a key shaped like a credential is where a future field
# would land without anybody noticing.
offenders="$(jq -r '[paths | map(tostring) | join(".")]
| map(select(test("(password|secret|psk|passphrase)$";"i"))) | join(", ")' <<<"$details")"
[[ -z "$offenders" ]] || fail "details carries credential-shaped fields: $offenders"
# ── forget and the two modifiers ─────────────────────────────────────────────
: >"$state_dir/argv"
runh forget 'Home Wi-Fi' >/dev/null 2>&1
grep -Fq 'connection delete Home Wi-Fi' "$state_dir/argv" \
|| fail "forget did not delete the connection profile: $(log)"
: >"$state_dir/argv"
runh set-autoconnect 'Home Wi-Fi' true >/dev/null 2>&1
grep -Fq 'connection modify' "$state_dir/argv" \
|| fail "set-autoconnect did not modify the profile: $(log)"
grep -Eq 'connection\.autoconnect +(yes|true)' "$state_dir/argv" \
|| fail "set-autoconnect did not set connection.autoconnect: $(log)"
: >"$state_dir/argv"
runh set-autoconnect 'Home Wi-Fi' false >/dev/null 2>&1
grep -Eq 'connection\.autoconnect +(no|false)' "$state_dir/argv" \
|| fail "turning autoconnect off did not reach nmcli as a negative: $(log)"
: >"$state_dir/argv"
mac_on="$(runh set-mac-random 'Home Wi-Fi' true 2>/dev/null)"
grep -Eq 'cloned-mac-address +random' "$state_dir/argv" \
|| fail "randomizing the MAC did not set the cloned address: $(log)"
# The setting does nothing until the connection comes back up, and a switch
# that appears to have taken effect when it has not is the whole bug.
grep -qi 'reconnect' <<<"$mac_on" \
|| fail "set-mac-random does not say a reconnect is needed: $mac_on"
: >"$state_dir/argv"
runh set-mac-random 'Home Wi-Fi' false >/dev/null 2>&1
grep -Eq 'cloned-mac-address +permanent' "$state_dir/argv" \
|| fail "turning randomization off did not restore the permanent address: $(log)"
# ── import-vpn picks its plugin from the extension ───────────────────────────
printf '[Interface]\n' >"$work/tunnel.conf"
printf 'client\n' >"$work/tunnel.ovpn"
printf 'not a tunnel\n' >"$work/tunnel.txt"
: >"$state_dir/argv"
imported="$(runh import-vpn "$work/tunnel.conf" 2>/dev/null)"
grep -Fq 'connection import type wireguard' "$state_dir/argv" \
|| fail "a .conf file was not imported as WireGuard: $(log)"
jq -e '((.name // "") | length > 0) and ((.uuid // "") | length > 0)' <<<"$imported" >/dev/null \
|| fail "import-vpn does not name the connection it made: $imported"
jq -e '.kind == "wireguard"' <<<"$imported" >/dev/null \
|| fail "a .conf import is not reported as WireGuard: $imported"
: >"$state_dir/argv"
ovpn="$(runh import-vpn "$work/tunnel.ovpn" 2>/dev/null)"
grep -Fq 'connection import type openvpn' "$state_dir/argv" \
|| fail "a .ovpn file was not imported as OpenVPN: $(log)"
jq -e '.kind == "openvpn"' <<<"$ovpn" >/dev/null \
|| fail "a .ovpn import is not reported as OpenVPN: $ovpn"
: >"$state_dir/argv"
[[ -n "$(error_of import-vpn "$work/tunnel.txt")" ]] \
|| fail 'a file that is neither .conf nor .ovpn was accepted for import'
grep -Fq 'connection import' "$state_dir/argv" \
&& fail 'an unimportable file still reached nmcli'
[[ -n "$(error_of import-vpn "$work/does-not-exist.conf")" ]] \
|| fail 'a path that does not exist was accepted for import'
# ── hotspot: the password comes back once and is written nowhere ─────────────
: >"$state_dir/argv"
hotspot="$(runh hotspot start panama-hotspot 2>/dev/null)"
grep -Fq 'device wifi hotspot' "$state_dir/argv" \
|| fail "starting a hotspot did not reach nmcli: $(log)"
grep -Fq 'device wifi show-password' "$state_dir/argv" \
|| fail 'the generated hotspot password is never read back, so the UI cannot show it'
grep -Fq "$HOTSPOT_PW" <<<"$hotspot" \
|| fail "the hotspot password is not returned to the caller: $hotspot"
# It may pass through the return value exactly once. It may not be an argument,
# and it may not be left behind on disk.
grep -Fq "$HOTSPOT_PW" "$state_dir/argv" \
&& fail 'the hotspot password was passed to a command, where /proc publishes it'
leaked="$(leak_in_scratch "$HOTSPOT_PW")"
[[ -z "$leaked" ]] || fail "the hotspot password was written to $leaked"
runh hotspot status >/dev/null 2>&1 || fail 'hotspot status failed'
: >"$state_dir/argv"
runh hotspot stop >/dev/null 2>&1
[[ -s "$state_dir/argv" ]] || fail 'stopping the hotspot did nothing at all'
# ── join-enterprise: the password arrives on stdin and never on argv ─────────
#
# The pin the whole file is built around. libnm is unavailable here by
# construction, so this is the nmcli fallback: the editor takes `set
# 802-1x.password …` as a line of input, which keeps the secret out of ps.
: >"$state_dir/argv"
: >"$state_dir/stdin"
enterprise_out="$(printf '%s\n' "$ENTERPRISE_PW" \
| runh join-enterprise 'Campus Secure' peap-mschapv2 '[email protected]' 2>"$work/enterprise.err")"
grep -Fq "$ENTERPRISE_PW" "$state_dir/argv" \
&& fail 'the enterprise password was passed as a command argument'
grep -Fq "$ENTERPRISE_PW" <<<"$enterprise_out" \
&& fail 'the enterprise password is echoed back in the helper output'
grep -Fq "$ENTERPRISE_PW" "$work/enterprise.err" \
&& fail 'the enterprise password was written to stderr'
leaked="$(leak_in_scratch "$ENTERPRISE_PW")"
[[ -z "$leaked" ]] || fail "the enterprise password was written to $leaked"
# And it did reach NetworkManager, or the verb is a no-op wearing a JSON hat.
grep -Fq 'connection edit' "$state_dir/argv" \
|| fail "join-enterprise never opened the connection editor: $(log)"
grep -Fq "$ENTERPRISE_PW" "$state_dir/stdin" \
|| fail 'the enterprise password never reached nmcli at all, on stdin or otherwise'
# The EAP method is a closed set, not free text handed to nmcli. Nothing is
# piped in on purpose: the arguments are checked before stdin is read, so a
# request that was always going to be refused must not sit waiting for a
# password first.
: >"$state_dir/argv"
bad_method="$(runh join-enterprise 'Campus Secure' ldap-md5 'gib' </dev/null 2>/dev/null \
| jq -r '.error // ""')"
[[ -n "$bad_method" ]] || fail 'an unknown EAP method was accepted'
grep -Fq 'connection edit' "$state_dir/argv" \
&& fail 'an unknown EAP method still reached nmcli'
[[ -n "$(runh join-enterprise 'Campus Secure' peap-mschapv2 'gib' </dev/null 2>/dev/null \
| jq -r '.error // ""')" ]] \
|| fail 'an enterprise join with no password was accepted'
# ── proxy and airplane are the settings they claim to be ─────────────────────
: >"$state_dir/argv"
proxy="$(runh proxy get 2>/dev/null)"
grep -Fq 'org.gnome.system.proxy' "$state_dir/argv" \
|| fail "proxy get does not read the GNOME proxy settings: $(log)"
jq -e 'has("mode")' <<<"$proxy" >/dev/null || fail "proxy get reports no mode: $proxy"
: >"$state_dir/argv"
runh proxy set manual 192.168.7.9 3128 >/dev/null 2>&1
grep -Fq 'set org.gnome.system.proxy mode' "$state_dir/argv" \
|| fail "setting a manual proxy did not set the mode: $(log)"
# All three or none: a proxy applied to http alone silently leaks the rest.
for scheme in http https socks; do
grep -Fq "org.gnome.system.proxy.$scheme host" "$state_dir/argv" \
|| fail "the manual proxy was not applied to $scheme, so that traffic ignores it"
done
[[ -n "$(error_of proxy set sideways)" ]] || fail 'an unknown proxy mode was accepted'
[[ -n "$(error_of proxy set manual 'host; reboot' 3128)" ]] \
|| fail 'a proxy host with a shell metacharacter was accepted'
[[ -n "$(error_of proxy set manual 192.168.7.9 99999)" ]] \
|| fail 'an impossible proxy port was accepted'
[[ -n "$(error_of proxy set auto 'javascript:alert(1)')" ]] \
|| fail 'a PAC URL that is not a URL was accepted'
: >"$state_dir/argv"
airplane="$(runh airplane status 2>/dev/null)"
grep -Fq 'rfkill' "$state_dir/argv" || fail "airplane status does not read rfkill: $(log)"
jq -e 'has("on")' <<<"$airplane" >/dev/null || fail "airplane status reports no state: $airplane"
: >"$state_dir/argv"
runh airplane set true >/dev/null 2>&1
grep -Eq 'rfkill +block' "$state_dir/argv" \
|| fail "turning airplane mode on did not block the radios: $(log)"
: >"$state_dir/argv"
runh airplane set false >/dev/null 2>&1
grep -Eq 'rfkill +unblock' "$state_dir/argv" \
|| fail "turning airplane mode off did not unblock the radios: $(log)"
[[ -n "$(error_of airplane set maybe)" ]] || fail 'a non-boolean was accepted for airplane mode'
# ── Names are validated here, not by nmcli ───────────────────────────────────
#
# The reason matters: without validation these still fail, because nmcli refuses
# them too -- so a check that only asks "did something error" passes with the
# validation deleted. Each must also leave the recording untouched, which is
# what says the refusal happened before anything was run.
long_name="$(printf 'a%.0s' {1..300})"
for bad in 'Home; rm -rf /' '-x-not-a-name' '' "$long_name" 'new
line'; do
: >"$state_dir/argv"
[[ -n "$(error_of details "$bad")" ]] \
|| fail "a bad connection name was accepted: ${bad@Q}"
[[ ! -s "$state_dir/argv" ]] \
|| fail "a bad connection name reached nmcli before being refused: ${bad@Q}"
[[ -n "$(error_of forget "$bad")" ]] \
|| fail "forget accepted a bad connection name: ${bad@Q}"
done
for bad in 'ssid; reboot' '-x-not-a-name' '' "$(printf 'a%.0s' {1..33})"; do
: >"$state_dir/argv"
[[ -n "$(error_of hotspot start "$bad")" ]] \
|| fail "a bad hotspot SSID was accepted: ${bad@Q}"
[[ ! -s "$state_dir/argv" ]] \
|| fail "a bad hotspot SSID reached nmcli before being refused: ${bad@Q}"
done
[[ -n "$(error_of set-autoconnect 'Home Wi-Fi' perhaps)" ]] \
|| fail 'a non-boolean was accepted for autoconnect'
[[ -n "$(error_of bogus-verb)" ]] || fail 'an unknown command was accepted'
# ── Nothing anywhere left a secret behind ───────────────────────────────────
for secret in "$WIFI_PSK" "$VPN_SECRET" "$ENTERPRISE_PW" "$HOTSPOT_PW"; do
leaked="$(leak_in_scratch "$secret")"
[[ -z "$leaked" ]] || fail "a secret was left behind in $leaked"
done
printf 'network tools contract: PASS (details, forget, autoconnect, MAC, import, hotspot, enterprise, proxy, airplane)\n'
+128
View File
@@ -62,6 +62,90 @@ for scheme in file pipe; do
&& fail "the $scheme scheme is allowed, and it does not lead to a printer" && fail "the $scheme scheme is allowed, and it does not lead to a printer"
done done
# ── Printer options are a short closed list, not a passthrough ──────────────
#
# `lpadmin -o` is the same door the driver ban closed, reopened from the side.
# Anything can be set through it -- including ppd-name, and including options
# that make a printer accept jobs and print nothing -- so the page offers two
# settings, and the helper knows both of them by name and by value. A caller's
# string is never spliced into `-o`; it is looked up, and refused when absent.
grep -qE '^OPTION_[A-Z_]+\s*[:=]' "$helper" \
|| fail 'the settable options are not declared in one place, so "closed vocabulary" cannot be checked'
python3 - "$helper" <<'PY' || fail 'the option vocabulary is not the closed media/sides set this page promises'
import ast
import sys
source = open(sys.argv[1], encoding="utf-8").read()
vocabulary = {}
for node in ast.parse(source).body:
if isinstance(node, ast.AnnAssign):
name = getattr(node.target, "id", "")
value = node.value
elif isinstance(node, ast.Assign):
name = getattr(node.targets[0], "id", "")
value = node.value
else:
continue
if not name.startswith("OPTION_") or value is None:
continue
try:
literal = ast.literal_eval(value)
except ValueError:
continue
# A key-to-choices table, not the spelling table beside it: only the dict
# whose values are collections of choices describes what may be set.
if isinstance(literal, dict) and literal and all(
isinstance(choices, (list, tuple, set)) for choices in literal.values()):
vocabulary.update(literal)
if set(vocabulary) != {"media", "sides"}:
print(f"settable keys are {sorted(vocabulary)}, expected ['media', 'sides']", file=sys.stderr)
raise SystemExit(1)
if set(vocabulary["media"]) != {"Letter", "A4", "Legal"}:
print(f"media values are {sorted(vocabulary['media'])}", file=sys.stderr)
raise SystemExit(1)
if set(vocabulary["sides"]) != {"one-sided", "two-sided-long-edge", "two-sided-short-edge"}:
print(f"sides values are {sorted(vocabulary['sides'])}", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# And set-option must reach for that vocabulary rather than trusting its
# arguments. The values are validated in one place or they are validated
# nowhere.
python3 - "$helper" <<'OPTIONS' || fail 'set-option does not check its key and value against the closed vocabulary'
import re
import sys
source = open(sys.argv[1], encoding="utf-8").read()
match = re.search(r"\ndef set_option\b.*?(?=\ndef |\Z)", source, re.S)
if not match:
raise SystemExit(1)
raise SystemExit(0 if re.search(r"OPTION_[A-Z_]+", match.group(0)) else 1)
OPTIONS
grep -q 'get-options' "$helper" || fail 'the helper cannot read the options a printer is set to'
grep -q 'set-option' "$helper" || fail 'the helper cannot set a printer option'
# ── A queued job can be held and let go again ───────────────────────────────
#
# Cancel was the only verb, so the only way to stop a job going out on the
# wrong paper was to throw it away and print it again.
for verb in hold release; do
grep -q "\"$verb\"" "$helper" || fail "the helper has no $verb verb"
grep -qi "$verb" <<<"$page_code" || fail "the queue offers no $verb"
done
grep -qi 'paper size\|media' <<<"$page_code" \
|| fail 'the expanded printer offers no paper size'
grep -qi 'two-sided\|sides' <<<"$page_code" \
|| fail 'the expanded printer offers no two-sided setting'
# The dropdowns must show what the printer is set to, not a guess.
grep -q 'getOptions\|optionsFor\|options(' "$service" \
|| fail 'the service never reads printer options, so the dropdowns would be showing defaults'
# The empty state is one card. It was two rows saying the same thing, which read
# as two different things you could try.
[[ "$(grep -c 'Search the network' <<<"$page_code")" -le 1 ]] \
|| fail 'the "Search the network" row appears more than once'
command -v jq >/dev/null 2>&1 || { printf 'printers contract: SKIP (no jq)\n'; exit 0; } command -v jq >/dev/null 2>&1 || { printf 'printers contract: SKIP (no jq)\n'; exit 0; }
refusal() { "$helper" "$@" 2>/dev/null | jq -r '.error // ""'; } refusal() { "$helper" "$@" 2>/dev/null | jq -r '.error // ""'; }
@@ -80,6 +164,26 @@ for bad in "../escape" "has space" "a#b" ""; do
|| fail "the helper accepted \"$bad\" as a printer name" || fail "the helper accepted \"$bad\" as a printer name"
done done
[[ -n "$(refusal cancel notanumber)" ]] || fail 'the helper accepted a job id that is not a number' [[ -n "$(refusal cancel notanumber)" ]] || fail 'the helper accepted a job id that is not a number'
for verb in hold release; do
[[ -n "$(refusal "$verb" notanumber)" ]] \
|| fail "the helper accepted a job id that is not a number for $verb"
done
# Options: a key outside the vocabulary, and a value outside its own key's list.
# Both are refused before anything reaches lpadmin, so running this touches no
# real printer.
# The reason again, not merely an error: the printer name is only checked for
# shape here, so these reach the vocabulary and are refused by it rather than by
# CUPS -- which means nothing is sent to a real printer either.
[[ "$(refusal set-option office-laser ppd-name everywhere)" == "That is not a setting this page changes." ]] \
|| fail 'an option outside the vocabulary was not refused by the vocabulary, which reopens the driver door from the side'
[[ "$(refusal set-option office-laser media Tabloid)" == "That is not a value this setting accepts." ]] \
|| fail 'a paper size outside the offered list was not refused by the vocabulary'
[[ "$(refusal set-option office-laser sides sideways)" == "That is not a value this setting accepts." ]] \
|| fail 'a two-sided value outside the offered list was not refused by the vocabulary'
[[ -n "$(refusal set-option '../escape' media Letter)" ]] \
|| fail 'set-option accepted a bad printer name'
[[ -n "$(refusal get-options 'has space')" ]] \
|| fail 'get-options accepted a bad printer name'
[[ -n "$(refusal bogus-command)" ]] || fail 'an unknown command was accepted' [[ -n "$(refusal bogus-command)" ]] || fail 'an unknown command was accepted'
# ── The snapshot describes the machine ────────────────────────────────────── # ── The snapshot describes the machine ──────────────────────────────────────
@@ -94,6 +198,30 @@ jq -e '[.printers[] | has("name") and has("state") and has("isDefault")] | all'
[[ "$(jq '[.printers[] | select(.isDefault)] | length' <<<"$snapshot")" -le 1 ]] \ [[ "$(jq '[.printers[] | select(.isDefault)] | length' <<<"$snapshot")" -le 1 ]] \
|| fail 'more than one printer is reported as the default' || fail 'more than one printer is reported as the default'
# ── Reading a printer's options is a read ───────────────────────────────────
# Only the keys the page can write are reported: a dropdown that lists an
# option nothing can set is a control that does nothing.
first_printer="$(jq -r '.printers[0].name // ""' <<<"$snapshot")"
if [[ -n "$first_printer" ]]; then
options="$("$helper" get-options "$first_printer" 2>/dev/null)" \
|| fail "get-options failed for $first_printer"
jq -e '(.options | has("media") and has("sides")) and (.choices | has("media") and has("sides"))' \
<<<"$options" >/dev/null \
|| fail "get-options does not report the two settings the page offers, with their choices: $options"
extra="$(jq -r '[(.options | keys[]), (.choices | keys[])]
| unique | map(select(. != "media" and . != "sides")) | join(", ")' <<<"$options")"
[[ -z "$extra" ]] \
|| fail "get-options offers keys the page cannot set: $extra"
# Every offered choice has to be one set-option would accept, or the
# dropdown lists something that is refused the moment it is chosen.
bad_choice="$(jq -r '
(.choices.media // []) - ["Letter","A4","Legal"]
+ ((.choices.sides // []) - ["one-sided","two-sided-long-edge","two-sided-short-edge"])
| join(", ")' <<<"$options")"
[[ -z "$bad_choice" ]] \
|| fail "get-options offers values outside the vocabulary: $bad_choice"
fi
# ── Removal is confirmed ──────────────────────────────────────────────────── # ── Removal is confirmed ────────────────────────────────────────────────────
grep -q 'confirmingRemoval' "$page" \ grep -q 'confirmingRemoval' "$page" \
|| fail 'the page removes a printer without a confirmation step' || fail 'the page removes a printer without a confirmation step'
+31 -1
View File
@@ -9,7 +9,11 @@ fail() {
exit 1 exit 1
} }
pages=(Home MyHome Phone Displays Connectivity Bar Dock ControlCenter Tiling Workspaces Sync Sound Shortcuts Mouse Dictation Notifications Focus ScreenIntelligence Health About) # Firewall, Printers and Sharing joined this list when the Network & Sharing
# rebuild touched all four Connections-category pages at once: three of them had
# never been checked for the page scaffold at all, and a rebuild is exactly when
# a hand-rolled Flickable comes back.
pages=(Home MyHome Phone Displays Connectivity Firewall Printers Sharing Bar Dock ControlCenter Tiling Workspaces Sync Sound Shortcuts Mouse Dictation Notifications Focus ScreenIntelligence Health About)
for page in "${pages[@]}"; do for page in "${pages[@]}"; do
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml" page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
[[ -f "$page_file" ]] || fail "${page}Page.qml is missing" [[ -f "$page_file" ]] || fail "${page}Page.qml is missing"
@@ -29,6 +33,32 @@ for page in "${pages[@]}"; do
|| fail "${page}Page.qml still copies the page Flickable scaffold" || fail "${page}Page.qml still copies the page Flickable scaffold"
done done
settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
# A page's objectName is how the running shell is asked to point at it, and how
# a contract finds it without walking the visual tree. The four pages under
# Connections all carry the id their route uses.
while IFS='|' read -r page_file object_name; do
rg -Fq "objectName: \"$object_name\"" "$settings_dir/$page_file" \
|| fail "$page_file does not carry objectName \"$object_name\""
done <<'OBJECTS'
ConnectivityPage.qml|connectivity
FirewallPage.qml|firewall
PrintersPage.qml|printers
SharingPage.qml|sharing
OBJECTS
# A component file that exists but is not exported from qmldir is not a missing
# import error at startup -- it is an unresolved type at the moment the row is
# first rendered, which is to say when somebody expands a connection.
qmldir="$settings_dir/qmldir"
for component in ConnectionDetails EnterpriseJoinForm; do
[[ -f "$settings_dir/$component.qml" ]] \
|| fail "$component.qml is missing"
rg -Fq "$component 1.0 $component.qml" "$qmldir" \
|| fail "$component.qml is not exported from qmldir, so the type resolves to nothing when it is first used"
done
require_row() { require_row() {
local file="$1" local file="$1"
local row_type="$2" local row_type="$2"
+46
View File
@@ -89,6 +89,52 @@ grep -q 'kitty' "$service" \
grep -q 'clear-rdp-credentials' "$helper" \ grep -q 'clear-rdp-credentials' "$helper" \
|| fail 'stored credentials cannot be cleared' || fail 'stored credentials cannot be cleared'
# ── The page says the true thing in the right place ─────────────────────────
#
# Three copy rules, each of which was a real failure before it was a rule.
#
# The failure banner floated above every card as a full-width red bar, so a
# grdctl error that concerned one row repainted the whole page as broken. It
# belongs inside the card whose action failed -- the machine card, which is
# where refresh and the hostname live.
page_code="$(grep -vE '^\s*//' "$page")"
python3 - "$page" <<'PY' || fail 'the failure message is not inside the first card, so one row failing reads as the page failing'
import re
import sys
lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
start = next((i for i, line in enumerate(lines) if re.search(r"\bSettingsCard\s*\{", line)), None)
if start is None:
raise SystemExit(1)
depth = 0
end = None
for index in range(start, len(lines)):
depth += lines[index].count("{") - lines[index].count("}")
if depth <= 0:
end = index
break
if end is None:
raise SystemExit(1)
card = "\n".join(lines[start:end + 1])
raise SystemExit(0 if "Sharing.lastError" in card else 1)
PY
# The terminal is the mechanism, not the explanation. "Opens kitty" tells
# somebody the name of a program they did not ask about and still leaves them
# wondering why a settings page cannot take a password; the reason it cannot is
# the sentence worth printing.
! grep -qi 'kitty' <<<"$page_code" \
|| fail 'the page names the terminal application in text the user reads; that belongs to the service'
grep -q 'never passes through Panama' <<<"$page_code" \
|| fail 'the credentials row does not say why the password is set elsewhere'
# A row for software that is not here has to be worth reading. "Not installed"
# on its own is a dead end; what installing it would give you is not.
grep -q 'install it and this becomes a switch' <<<"$page_code" \
|| fail 'the file sharing row does not say what installing Samba would unlock'
grep -q 'does not install software' <<<"$page_code" \
|| fail 'the file sharing row does not say that Settings will not install it for you'
# ── The snapshot reflects the machine ─────────────────────────────────────── # ── The snapshot reflects the machine ───────────────────────────────────────
command -v jq >/dev/null 2>&1 || { printf 'sharing contract: SKIP (no jq)\n'; exit 0; } command -v jq >/dev/null 2>&1 || { printf 'sharing contract: SKIP (no jq)\n'; exit 0; }
snapshot="$("$helper" snapshot 2>/dev/null)" || fail 'snapshot failed' snapshot="$("$helper" snapshot 2>/dev/null)" || fail 'snapshot failed'