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:
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Executable
+866
@@ -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:]))
|
||||||
@@ -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 = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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" "$@"; }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Executable
+614
@@ -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'
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user