From f3a082219a2ab37f7790d7b7dd86ec3744ee8982 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sun, 8 Nov 2026 00:49:40 -0500 Subject: [PATCH] Experimental network management GUI added. This may be broken. --- I38.md | 42 +++ README.md | 77 +++++ i38.sh | 3 + scripts/network.py | 417 +++++++++++++++++++++++++++ scripts/network_backend.py | 571 +++++++++++++++++++++++++++++++++++++ 5 files changed, 1110 insertions(+) create mode 100644 scripts/network.py create mode 100644 scripts/network_backend.py diff --git a/I38.md b/I38.md index 32bf342..7c48cca 100644 --- a/I38.md +++ b/I38.md @@ -161,6 +161,7 @@ Panel Mode automatically returns you to Default Mode after a selection is made o `s`: Show system information.
`r`: Open the reminder panel, if remind is installed.
`n`: Launch the notes application.
+`Shift` + `n`: Manage wired and Wi-Fi connections.
`b`: Open Bluetooth, if blueman was installed when the i3 config was generated.
`@MODKEY@` + `b`: Speak a quick battery status.
`Shift` + `b`: Show detailed battery information.
@@ -173,6 +174,47 @@ Panel Mode automatically returns you to Default Mode after a selection is made o The system information panel, `s` in Panel Mode, displays system statistics such as CPU usage, memory usage, disk space, and network status. +### Network Connections + +Press `Shift` + `n` in Panel Mode to open I38 Network. Use Up and Down to select +a connection, then Tab to Connect, Disconnect, Refresh, Details, or Close. +Selecting a row does not change connections. Rows include the connection name, +adapter, state, and Wi-Fi security when available. Type a name prefix +to narrow the list; Escape clears the search, then closes the window. Scans +preserve the selected connection and do not reorder existing rows. + +The panel shows nearby Wi-Fi networks and saved wired and Wi-Fi profiles. +Connect supports existing profiles and new open, WPA Personal, WPA3 Personal, +and enhanced-open Wi-Fi networks. New enterprise, WEP, hidden-network, VPN, and +advanced IP/DNS configuration are outside this panel's scope. Details provides +reviewable signal strength, connection, and address information. Signal changes +do not rewrite list rows, avoiding repeated announcements. Internet connectivity is shown +separately from the connection state; a connection alone does not prove Internet +access, and captive portals may require signing in through a browser. + +Wi-Fi passwords use a normal, labeled GTK password field with Show password and +Remember password controls. NetworkManager owns saved profiles and passwords; +I38 has no password store. Remembered passwords are saved with the system-wide +connection, subject to NetworkManager's access controls. Unchecking Remember +password on a new network saves its profile without the password and disables +automatic connection for that profile. On an existing profile, unchecking it +uses the supplied password for that attempt without changing saved credentials. +Saving a replacement password requires permission to modify that profile. + +Connection changes require the current login session to belong to Stormux's +`network` group and NetworkManager to authorize the requested operation. After +an administrator changes group membership, log out and back in. The GUI runs +as your normal user. A polkit handler scoped to its own process declines +administrator password challenges, including late challenges after a policy +change. If this protection cannot be registered, changes remain disabled. +Other applications' authentication agents are unaffected. Missing permissions +leave connection information available and display an explanation. + +Closing the panel leaves established connections running. Disconnect may allow +NetworkManager to activate another saved connection; disable Wi-Fi to turn off +the wireless radio. This does not disable wired networking. A hardware wireless +block must be released using the device's hardware control. + ### Battery Information The battery report has two modes. Press `@MODKEY@` + `b` in Panel Mode for a concise spoken announcement of charge level and charging state. Press `Shift` + `b` in Panel Mode for the detailed report. The detailed report is shown in a reviewable text dialog instead of spoken aloud, so the full details can be navigated with the arrow keys. diff --git a/README.md b/README.md index 699129a..b310b83 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Optional features use these packages when installed: - libnotify: For sending notifications - magic-wormhole: [optional] for file sharing with magic-wormhole GUI - notification-daemon: To handle notifications +- networkmanager: [optional] for the Network panel, including libnm's GObject introspection data. - pamixer: for the mute-unmute script - pcmanfm: [optional] Graphical file manager. - playerctl: music controls @@ -110,6 +111,82 @@ Personal (if configured), and Bypass modes; check existing mode sounds and Cthulhu's native mode speech. Also check battery status, OCR, and USB alerts if enabled. Automated checks do not prove audible output or physical key behavior. +## Network Panel (Optional) + +Press Control+Alt+Tab, then Shift+N to open the accessible wired/Wi-Fi panel. +It requires NetworkManager/libnm 1.30 or newer and its `NM-1.0` introspection +data, plus I38's existing Python/PyGObject/GTK 3 dependencies. On Stormux, +connection management requires membership in `network` in the current session +and an appropriate polkit policy. The GUI never elevates or requests an +administrator password. If permissions or its process-specific protection +against authentication prompts are unavailable, it remains usable for viewing +connection information. No group membership or policy is changed by the panel. + +Try the source directly before updating your installation: + +```bash +python3 -B scripts/network.py +``` + +Use the arrows to select a connection; Tab to Connect, Disconnect, Refresh, +Details, or Close. Selection alone does not connect or disconnect. Type a +case-insensitive name prefix to filter. Escape clears the filter, then closes +the window. Nearby access points with the same SSID and security are combined +per adapter, while distinct saved profiles remain selectable. Existing rows +retain their order and selection when scans change. Signal strength is available +in Details; fluctuations do not rewrite the focused row. + +New personal Wi-Fi networks use an accessible GTK password dialog. Remember +password saves through NetworkManager with the system-wide profile, under its +access controls. Without remembering, a new profile is saved without its +password and with autoconnect disabled. Existing profiles can request another +password, including after a wrong-password failure. Saving a replacement +requires modification permission; otherwise it is used only for that attempt. +I38 stores no passwords itself. Enterprise/hidden-network setup, VPN setup, and +advanced IP/DNS editing are not included. Closing the window leaves established +connections running. See the Network Connections section of the I38 guide. + +For the installed binding, review the diff and regenerate your I38 configuration, +then reload i3. `./i38.sh -u` copies scripts but does not add Shift+N to existing +bindings. Regeneration uses your saved preferences; review them first. + +Automated checks (no host connections are changed): + +```bash +dbus-run-session -- env I38_PRIVATE_NETWORK_TEST=1 python3 -B -m unittest discover -s tests -p test_network.py -v +xvfb-run -a dbus-run-session -- env -u AT_SPI_BUS_ADDRESS I38_PRIVATE_NETWORK_GUI_TEST=1 python3 -B tests/native_network_gui.py -v +``` + +For a native check against the running NetworkManager and polkit services, add +`I38_NETWORK_LIVE_READONLY_TEST=1` to the second command's environment. It reads +connection information, registers the panel's temporary agents, and checks that +a polkit challenge for this process is declined when the local policy offers +one. It does not activate, disconnect, scan, or modify connections or policy. + +Before submission, review and test the changes as required by this project's +AI-assisted contributions policy. For live testing with Cthulhu and Orca: + +1. Open the source panel. Check initial selection, complete row announcements, + prefix search, Tab/Shift+Tab, status review, Details, and Escape. Check Braille + if available. Refresh must preserve focus and selection without repeated + signal-strength announcements. +2. Join a test Wi-Fi network. Check password masking, Show password, Remember + password, cancellation, an incorrect password, a correct retry, and reopening + the panel. Verify remembered credentials reconnect and an unremembered new + profile requires its password again. No administrator dialog should appear. +3. Check an existing wired connection and Wi-Fi, including both active together, + disconnect/reconnect, Wi-Fi disabled/enabled, and hardware wireless blocking + if available. Confirm normal networking still works after the panel closes. +4. If a restricted test account is available, check that missing `network` + membership or denied authorization leaves information readable and changes + disabled. A policy change while a password dialog is open must be rechecked. +5. After configuration regeneration, check Panel Mode Shift+N, existing lowercase + `n` for Notes, F1 help, and the other panel bindings. Opening the network panel + twice should focus its existing window. + +Private GTK and D-Bus checks do not establish audible speech, Braille, physical +key behavior, or successful live connection/password persistence. + ## USB Connection Alerts (Optional) Setup offers USB connection sounds and spoken device names separately from window diff --git a/i38.sh b/i38.sh index 067a39b..2d74b9d 100755 --- a/i38.sh +++ b/i38.sh @@ -1443,6 +1443,9 @@ fi) # Simple notes system bound to n bindsym n exec --no-startup-id ${i3Path}/scripts/notes.py, mode "default" + # Network connections bound to Shift+n + bindsym Shift+n exec --no-startup-id python3 ${i3Path}/scripts/network.py, mode "default" + # Password manager bound to m bindsym m exec --no-startup-id ${i3Path}/scripts/passmanager.py, mode "default" diff --git a/scripts/network.py b/scripts/network.py new file mode 100644 index 0000000..ecf4c0b --- /dev/null +++ b/scripts/network.py @@ -0,0 +1,417 @@ +#!/usr/bin/env python3 +"""Accessible, ordinary-user NetworkManager panel for I38. + +Copyright (C) 2026 I38 contributors +SPDX-License-Identifier: GPL-3.0-or-later +""" + +import sys + +import gi + +gi.require_version("Atk", "1.0") +gi.require_version("Gdk", "3.0") +gi.require_version("Gtk", "3.0") +from gi.repository import Atk, Gdk, Gio, Gtk + +try: + from network_backend import (MODIFY_SYSTEM, NETWORK_CONTROL, NM, NetworkBackend, + security_type, valid_password) +except (ImportError, ValueError): + NetworkBackend = None + + +def labeled_entry(labelText, entry): + label = Gtk.Label.new_with_mnemonic(labelText) + label.set_xalign(0) + label.set_mnemonic_widget(entry) + return label + + +class PasswordDialog(Gtk.Dialog): + def __init__(self, parent, name, keyMgmt, canRemember): + super().__init__(title="Wi-Fi password", transient_for=parent, modal=True) + self.keyMgmt = keyMgmt + self.add_button("_Cancel", Gtk.ResponseType.CANCEL) + self.connectButton = self.add_button("_Connect", Gtk.ResponseType.OK) + self.set_default_response(Gtk.ResponseType.OK) + box = self.get_content_area() + box.set_spacing(10) + box.set_border_width(12) + heading = Gtk.Label(label="Enter the Wi-Fi password for {}.".format(name)) + heading.set_line_wrap(True) + box.add(heading) + self.entry = Gtk.Entry() + self.entry.set_visibility(False) + self.entry.set_input_purpose(Gtk.InputPurpose.PASSWORD) + self.entry.set_activates_default(True) + box.add(labeled_entry("_Password:", self.entry)) + box.add(self.entry) + show = Gtk.CheckButton.new_with_mnemonic("_Show password") + show.connect("toggled", lambda button: self.entry.set_visibility(button.get_active())) + box.add(show) + self.remember = Gtk.CheckButton.new_with_mnemonic("_Remember password") + self.remember.set_active(canRemember) + self.remember.set_sensitive(canRemember) + self.remember.get_accessible().set_description( + "NetworkManager saves the password with this connection for system users." + if canRemember else "Saving this password is not authorized. It will only be used for this attempt.") + box.add(self.remember) + self.validation = Gtk.Label(label="") + self.validation.set_xalign(0) + box.add(self.validation) + self.entry.connect("changed", self.on_changed) + self.on_changed(self.entry) + self.connect("key-press-event", self.on_key) + self.show_all() + self.entry.grab_focus() + + def on_changed(self, entry): + valid = valid_password(entry.get_text(), self.keyMgmt) + self.connectButton.set_sensitive(valid) + self.validation.set_text("" if valid else ( + "Use 8–63 characters or 64 hexadecimal digits." if self.keyMgmt == "wpa-psk" + else "Enter a password of 1–63 UTF-8 bytes.")) + + def on_key(self, widget, event): + if event.keyval == Gdk.KEY_Escape: + self.response(Gtk.ResponseType.CANCEL) + return True + return False + + +class NetworkWindow(Gtk.ApplicationWindow): + def __init__(self, application, backendFactory=None): + super().__init__(application=application, title="I38 Network") + self.set_default_size(850, 500) + self.backend = None + self.passwordDialog = None + self.resultDialog = None + self.lastBusy = False + self.updating = False + self.rowsByKey = {} + self.connect("destroy", self.on_destroy) + self.connect("key-press-event", self.on_key) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + box.set_border_width(12) + self.add(box) + + self.summary = Gtk.Label(label="Loading NetworkManager…") + self.summary.set_xalign(0) + self.summary.set_line_wrap(True) + box.pack_start(self.summary, False, False, 0) + self.wireless = Gtk.CheckButton.new_with_mnemonic("_Enable Wi-Fi") + self.wireless.connect("toggled", self.on_wireless) + box.pack_start(self.wireless, False, False, 0) + + self.search = Gtk.SearchEntry() + self.search.get_accessible().set_name("Find connection by name prefix") + box.pack_start(labeled_entry("_Find connection:", self.search), False, False, 0) + box.pack_start(self.search, False, False, 0) + # A single text column keeps a row's complete state in one accessible cell. + self.store = Gtk.ListStore(object, str, str) + self.filtered = self.store.filter_new() + self.filtered.set_visible_func(self.row_visible) + self.tree = Gtk.TreeView(model=self.filtered) + self.tree.set_headers_visible(False) + self.tree.set_enable_search(False) + self.tree.get_accessible().set_name("Network connections") + self.tree.get_accessible().set_description( + "Use Up and Down to select a connection, then Tab to Connect, Disconnect, or Details. Selection does not change the network.") + self.tree.append_column(Gtk.TreeViewColumn("Connection", Gtk.CellRendererText(), text=1)) + self.tree.get_selection().connect("changed", self.on_selection) + self.tree.connect("key-press-event", self.on_list_key) + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + scroll.add(self.tree) + box.pack_start(scroll, True, True, 0) + self.search.connect("search-changed", self.on_search) + + self.permissionText = Gtk.Label(label="Checking network permissions…") + self.permissionText.set_xalign(0) + self.permissionText.set_line_wrap(True) + self.permissionText.set_selectable(True) + box.pack_start(self.permissionText, False, False, 0) + self.statusEntry = Gtk.Entry() + self.statusEntry.set_editable(False) + self.statusEntry.set_text("Loading connections…") + self.statusEntry.get_accessible().set_role(Atk.Role.STATUSBAR) + box.pack_start(labeled_entry("_Status:", self.statusEntry), False, False, 0) + box.pack_start(self.statusEntry, False, False, 0) + buttons = Gtk.Box(spacing=8) + box.pack_start(buttons, False, False, 0) + self.connectButton = self.add_button(buttons, "_Connect", self.on_connect) + self.disconnectButton = self.add_button(buttons, "_Disconnect", self.on_disconnect) + self.refreshButton = self.add_button(buttons, "_Refresh", lambda button: self.backend.scan()) + self.detailsButton = self.add_button(buttons, "De_tails", self.on_details) + self.add_button(buttons, "Cl_ose", lambda button: self.close()) + self.show_all() + if backendFactory is None: + backendFactory = NetworkBackend + if backendFactory: + self.backend = backendFactory(self.refresh, self.on_status, self.request_password) + self.refresh() + else: + self.on_status("NetworkManager's Python bindings are unavailable. Install NetworkManager/libnm 1.30 or newer and its GObject introspection data.") + self.permissionText.set_text("Connection management is unavailable.") + for button in (self.connectButton, self.disconnectButton, self.refreshButton, self.wireless): + button.set_sensitive(False) + self.tree.grab_focus() + + def add_button(self, box, text, handler): + button = Gtk.Button.new_with_mnemonic(text) + button.connect("clicked", handler) + box.add(button) + return button + + def selected_row(self): + model, treeIter = self.tree.get_selection().get_selected() + return self.rowsByKey.get(model[treeIter][0]) if treeIter is not None else None + + def row_visible(self, model, treeIter, data): + return model[treeIter][2].casefold().startswith(self.search.get_text().casefold()) + + def on_search(self, entry): + self.filtered.refilter() + if self.selected_row() is None and len(self.filtered): + self.tree.set_cursor(Gtk.TreePath.new_first()) + self.on_selection() + + def on_list_key(self, widget, event): + if event.state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.MOD1_MASK): + return False + character = Gdk.keyval_to_unicode(event.keyval) + if character and chr(character).isprintable(): + self.search.set_text(self.search.get_text() + chr(character)) + self.search.set_position(-1) + return True + if event.keyval == Gdk.KEY_BackSpace: + self.search.set_text(self.search.get_text()[:-1]) + return True + return False + + def on_key(self, widget, event): + if event.keyval == Gdk.KEY_Escape: + if self.search.get_text(): + self.search.set_text("") + self.tree.grab_focus() + else: + self.close() + return True + return False + + def refresh(self): + if self.backend is None: + return + oldRow = self.selected_row() + oldKey = oldRow.key if oldRow else None + rows = self.backend.rows() + self.rowsByKey = {row.key: row for row in rows} + desiredKeys = set(self.rowsByKey) + # Update in place: scans and signal changes never rebuild the focused list. + treeIter = self.store.get_iter_first() + existing = set() + while treeIter is not None: + key = self.store[treeIter][0] + if key not in desiredKeys: + if not self.store.remove(treeIter): + break + continue + existing.add(key) + row = self.rowsByKey[key] + text = self.row_text(row) + if self.store[treeIter][1] != text: + self.store[treeIter][1] = text + if self.store[treeIter][2] != row.name: + self.store[treeIter][2] = row.name + treeIter = self.store.iter_next(treeIter) + for row in rows: + if row.key not in existing: + self.store.append((row.key, self.row_text(row), row.name)) + selected = self.selected_row() + if selected is None: + firstPath = None + for modelRow in self.filtered: + row = self.rowsByKey[modelRow[0]] + if firstPath is None: + firstPath = modelRow.path + if row.key == oldKey or (oldKey is None and row.active): + firstPath = modelRow.path + break + if firstPath is not None: + self.tree.set_cursor(firstPath) + client = self.backend.client + self.updating = True + self.wireless.set_active(bool(client and client.wireless_get_enabled())) + self.updating = False + if not client or not client.get_nm_running(): + self.summary.set_text("NetworkManager is unavailable.") + else: + if self.statusEntry.get_text() == "Loading connections…": + self.statusEntry.set_text("Select a connection, then choose Connect or Disconnect.") + connectivity = client.get_connectivity() + descriptions = {NM.ConnectivityState.FULL: "Internet connectivity confirmed", + NM.ConnectivityState.PORTAL: "Network sign-in may be required", + NM.ConnectivityState.LIMITED: "Limited connectivity", + NM.ConnectivityState.NONE: "No Internet connectivity", + NM.ConnectivityState.UNKNOWN: "Internet connectivity not checked"} + activeNames = [row.name for row in rows if row.state == "Connected"] + self.summary.set_text("{}. {}. {} connections listed.".format( + "Connected: " + ", ".join(activeNames) if activeNames else "No active wired or Wi-Fi connection", + descriptions.get(connectivity, "Internet connectivity unknown"), len(rows))) + self.on_selection() + self.refreshButton.set_sensitive(bool(client and not self.backend.busy and + not self.backend.permission_problem([NM.ClientPermission.WIFI_SCAN]))) + self.wireless.set_sensitive(bool(client and not self.backend.busy and client.wireless_hardware_get_enabled() and + not self.backend.permission_problem([NM.ClientPermission.ENABLE_DISABLE_WIFI]))) + self.lastBusy = self.backend.busy + if not self.backend.secretRequest and self.passwordDialog is not None and self.backend.busy: + self.passwordDialog.response(Gtk.ResponseType.CANCEL) + + def row_text(self, row, includeSignal=False): + parts = [row.name, row.kind, row.device.get_iface(), row.state] + if includeSignal and row.signal: + parts.append("signal " + row.signal) + if row.security: + parts.append(row.security) + if row.profile: + parts.append("saved") + return ", ".join(parts) + + def on_selection(self, *args): + if not self.backend: + return + row = self.selected_row() + permissions = [NETWORK_CONTROL] + ([MODIFY_SYSTEM] if row and not row.profile else []) + problem = self.backend.permission_problem(permissions) + self.permissionText.set_text(problem or "Connection management is authorized. Administrator passwords are never requested.") + available = bool(row and row.state not in ("Unavailable", "Unmanaged", "Cable unplugged", "Hardware blocked", "Wi-Fi disabled")) + supported = bool(row and (row.profile or security_type(row.ap)[1] is not None)) + self.connectButton.set_sensitive(bool(available and supported and not row.active and + not problem and self.backend.agentReady and not self.backend.busy)) + self.disconnectButton.set_sensitive(bool(row and row.active and not self.backend.busy and + not self.backend.permission_problem([NETWORK_CONTROL]))) + self.detailsButton.set_sensitive(row is not None) + + def on_status(self, message): + self.statusEntry.set_text(message) + self.statusEntry.set_tooltip_text(message) + if self.backend and self.lastBusy and not self.backend.busy: + self.lastBusy = False + if self.passwordDialog: + self.passwordDialog.response(Gtk.ResponseType.CANCEL) + self.show_message("Network operation", message) + + def show_message(self, title, message): + if self.resultDialog: + self.resultDialog.destroy() + dialog = Gtk.MessageDialog(transient_for=self, modal=True, message_type=Gtk.MessageType.INFO, + buttons=Gtk.ButtonsType.CLOSE, text=title) + dialog.format_secondary_text(message) + dialog.connect("response", lambda widget, response: widget.destroy()) + dialog.connect("destroy", self.on_result_closed) + self.resultDialog = dialog + dialog.show_all() + + def on_result_closed(self, dialog): + if self.resultDialog == dialog: + self.resultDialog = None + + def request_password(self, name, keyMgmt, canRemember, callback): + if self.passwordDialog: + self.passwordDialog.response(Gtk.ResponseType.CANCEL) + dialog = PasswordDialog(self, name, keyMgmt, canRemember) + self.passwordDialog = dialog + + def responded(widget, response): + password = widget.entry.get_text() if response == Gtk.ResponseType.OK else None + remember = widget.remember.get_active() + widget.entry.set_text("") + self.passwordDialog = None + widget.destroy() + callback(password, remember) + + dialog.connect("response", responded) + + def on_connect(self, button): + row = self.selected_row() + if not row: + return + permissions = [NETWORK_CONTROL] + ([] if row.profile else [MODIFY_SYSTEM]) + if not self.backend.authorize(permissions): + return + if row.profile or security_type(row.ap)[1] not in ("wpa-psk", "sae"): + self.backend.connect_row(row) + return + + def answered(password, remember): + if password is not None: + self.backend.connect_row(row, password, remember) + + self.request_password(row.name, security_type(row.ap)[1], True, answered) + + def on_disconnect(self, button): + row = self.selected_row() + if row: + self.backend.disconnect_row(row) + + def on_wireless(self, button): + if not self.updating and self.backend: + self.backend.set_wireless(button.get_active()) + self.refresh() + + def on_details(self, button): + row = self.selected_row() + if not row: + return + lines = [self.row_text(row, includeSignal=True)] + if row.profile: + lines.append("Profile UUID: " + row.profile.get_uuid()) + lines.append("Connect automatically: " + ("Yes" if row.profile.get_setting_connection().get_autoconnect() else "No")) + if row.ap and security_type(row.ap)[1] is None: + lines.append("Creating profiles for this security type is not supported. Existing profiles can be activated.") + if row.active: + for version, config in (("IPv4", row.device.get_ip4_config()), ("IPv6", row.device.get_ip6_config())): + if config: + lines.extend("{} address: {}/{}".format(version, address.get_address(), address.get_prefix()) for address in config.get_addresses()) + if config.get_gateway(): + lines.append("{} gateway: {}".format(version, config.get_gateway())) + lines.append("{} DNS: {}".format(version, ", ".join(config.get_nameservers()))) + dialog = Gtk.Dialog(title="Connection details", transient_for=self, modal=True) + dialog.add_button("_Close", Gtk.ResponseType.CLOSE) + view = Gtk.TextView() + view.set_editable(False) + view.set_accepts_tab(False) + view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + view.get_accessible().set_name("Connection details") + view.get_buffer().set_text("\n".join(lines)) + scroll = Gtk.ScrolledWindow() + scroll.add(view) + dialog.get_content_area().add(scroll) + dialog.set_default_size(600, 350) + dialog.connect("response", lambda widget, response: widget.destroy()) + dialog.show_all() + view.grab_focus() + + def on_destroy(self, widget): + if self.backend: + self.backend.close() + if self.passwordDialog: + self.passwordDialog.response(Gtk.ResponseType.CANCEL) + if self.resultDialog: + self.resultDialog.destroy() + + +class NetworkApplication(Gtk.Application): + def __init__(self): + super().__init__(application_id="org.stormux.I38.Network", flags=Gio.ApplicationFlags.FLAGS_NONE) + + def do_activate(self): + window = self.get_active_window() + if window is None: + window = NetworkWindow(self) + window.present() + + +if __name__ == "__main__": + sys.exit(NetworkApplication().run(sys.argv)) diff --git a/scripts/network_backend.py b/scripts/network_backend.py new file mode 100644 index 0000000..45f01b9 --- /dev/null +++ b/scripts/network_backend.py @@ -0,0 +1,571 @@ +#!/usr/bin/env python3 +"""NetworkManager operations for I38; no elevated GUI or private password store. + +Copyright (C) 2026 I38 contributors +SPDX-License-Identifier: GPL-3.0-or-later +""" + +import grp +import locale +import os +from collections import namedtuple +from pathlib import Path +import uuid + +import gi + +gi.require_version("NM", "1.0") +from gi.repository import Gio, GLib, GObject, NM + +if (NM.MAJOR_VERSION, NM.MINOR_VERSION) < (1, 30): + raise ValueError("I38 Network requires libnm 1.30 or newer.") + + +NM_NAME = "org.freedesktop.NetworkManager" +NM_PATH = "/org/freedesktop/NetworkManager" +POLKIT_NAME = "org.freedesktop.PolicyKit1" +POLKIT_PATH = "/org/freedesktop/PolicyKit1/Authority" +AUTH_PATH = "/org/stormux/I38/NetworkAuthentication" +SECRET_PATH = NM_PATH + "/SecretAgent" +SECRET_INTERFACE = NM_NAME + ".SecretAgent" +SECURITY_SETTING = "802-11-wireless-security" +NETWORK_CONTROL = NM.ClientPermission.NETWORK_CONTROL +MODIFY_SYSTEM = NM.ClientPermission.SETTINGS_MODIFY_SYSTEM +MODIFY_OWN = NM.ClientPermission.SETTINGS_MODIFY_OWN +ApFlags = getattr(NM, "80211ApFlags") +ApSecurityFlags = getattr(NM, "80211ApSecurityFlags") + +NetworkRow = namedtuple("NetworkRow", "key name kind state signal security device profile ap active") + +AUTH_XML = """ + + + + + +""" + +SECRET_XML = """ + + + + + + + + + + +""" + + +def network_group_member(): + """Check the groups of this session, not newly edited account records.""" + try: + groupId = grp.getgrnam("network").gr_gid + except KeyError: + return False + return groupId in set(os.getgroups()) | {os.getgid()} + + +def security_type(ap): + flags = ap.get_wpa_flags() | ap.get_rsn_flags() + if flags & (ApSecurityFlags.KEY_MGMT_802_1X | ApSecurityFlags.KEY_MGMT_EAP_SUITE_B_192): + return "Enterprise", None + if flags & ApSecurityFlags.KEY_MGMT_PSK: + return "WPA Personal", "wpa-psk" + if flags & ApSecurityFlags.KEY_MGMT_SAE: + return "WPA3 Personal", "sae" + if flags & (ApSecurityFlags.KEY_MGMT_OWE | ApSecurityFlags.KEY_MGMT_OWE_TM): + return "Enhanced open", "owe" + if ap.get_flags() & ApFlags.PRIVACY: + return "WEP (unsupported)", None + return "Open", "" + + +def valid_password(password, keyMgmt): + if keyMgmt == "wpa-psk": + return NM.utils_wpa_psk_valid(password) + return keyMgmt == "sae" and 1 <= len(password.encode("utf-8")) <= 63 + + +def new_wifi_connection(row, password, remember): + connection = NM.SimpleConnection.new() + setting = NM.SettingConnection.new() + setting.props.id = row.name + setting.props.uuid = str(uuid.uuid4()) + setting.props.type = "802-11-wireless" + setting.props.autoconnect = remember + connection.add_setting(setting) + wireless = NM.SettingWireless.new() + wireless.props.ssid = row.ap.get_ssid() + wireless.props.mode = "infrastructure" + connection.add_setting(wireless) + keyMgmt = security_type(row.ap)[1] + if keyMgmt is None: + raise ValueError("This Wi-Fi security type is not supported by I38 Network.") + if keyMgmt: + security = NM.SettingWirelessSecurity.new() + security.props.key_mgmt = keyMgmt + if keyMgmt in ("wpa-psk", "sae"): + if not valid_password(password, keyMgmt): + raise ValueError("The Wi-Fi password has an invalid length or format.") + security.props.psk = password + security.props.psk_flags = NM.SettingSecretFlags.NONE if remember else NM.SettingSecretFlags.NOT_SAVED + connection.add_setting(security) + connection.normalize() + return connection + + +class PromptGuard: + """Decline polkit challenges for this process, without replacing the session agent. + + NetworkManager permits interactive authorization internally. Preflight checks + cannot prevent a late challenge after a policy change. A unix-process agent + takes precedence over the session agent and closes that race. + """ + + def __init__(self, bus, changed, status): + self.bus = bus + self.changed = changed + self.status = status + self.ready = False + self.error = "" + self.owner = None + self.closed = False + self.objectId = bus.register_object( + AUTH_PATH, Gio.DBusNodeInfo.new_for_xml(AUTH_XML).interfaces[0], self.handle_call, None, None) + self.watchId = Gio.bus_watch_name_on_connection( + bus, POLKIT_NAME, Gio.BusNameWatcherFlags.NONE, self.on_appeared, self.on_vanished) + + def on_appeared(self, bus, name, owner): + self.owner = owner + startTime = int(Path("/proc/self/stat").read_text().rsplit(")", 1)[1].split()[19]) + self.subject = ("unix-process", {"pid": GLib.Variant("u", os.getpid()), + "start-time": GLib.Variant("t", startTime), + "uid": GLib.Variant("i", os.getuid())}) + bus.call(owner, POLKIT_PATH, POLKIT_NAME + ".Authority", "RegisterAuthenticationAgent", + GLib.Variant("((sa{sv})ss)", (self.subject, locale.setlocale(locale.LC_MESSAGES), AUTH_PATH)), + None, Gio.DBusCallFlags.NONE, 10000, None, self.on_registered, owner) + + def on_registered(self, bus, result, owner): + try: + bus.call_finish(result) + self.ready = not self.closed and self.owner == owner + except GLib.Error as error: + self.ready = False + self.error = error.message + self.status("Cannot protect against administrator password prompts. Connection changes are disabled.") + self.changed() + + def on_vanished(self, bus, name): + self.owner = None + self.ready = False + self.changed() + + def handle_call(self, bus, sender, path, interface, method, parameters, invocation): + if sender != self.owner: + invocation.return_dbus_error(POLKIT_NAME + ".Error.NotAuthorized", "Unexpected sender") + elif method == "BeginAuthentication": + invocation.return_dbus_error(POLKIT_NAME + ".Error.Cancelled", "I38 does not request administrator passwords") + self.status("System policy requires administrator authorization. This operation was declined; no password dialog was opened.") + else: + invocation.return_value(GLib.Variant("()", ())) + + def close(self): + self.closed = True + self.ready = False + Gio.bus_unwatch_name(self.watchId) + if self.owner and hasattr(self, "subject"): + self.bus.call(self.owner, POLKIT_PATH, POLKIT_NAME + ".Authority", "UnregisterAuthenticationAgent", + GLib.Variant("((sa{sv})s)", (self.subject, AUTH_PATH)), None, + Gio.DBusCallFlags.NONE, 5000, None, None, None) + self.bus.unregister_object(self.objectId) + + +class NetworkBackend: + def __init__(self, changed, status, password): + self.changed = changed + self.status = status + self.password = password + self.client = None + self.guard = None + self.agentReady = False + self.closed = False + self.busy = False + self.pending = None + self.secretRequest = None + self.signalHandlers = [] + self.cancellable = Gio.Cancellable() + self.refreshSource = 0 + self.member = network_group_member() + NM.Client.new_async(self.cancellable, self.on_client_ready, None) + + def on_client_ready(self, source, result, data): + try: + self.client = NM.Client.new_finish(result) + except GLib.Error: + self.status("Cannot contact NetworkManager. Check that the NetworkManager service is running.") + return + if self.closed: + return + self.bus = self.client.get_dbus_connection() + self.guard = PromptGuard(self.bus, self.changed, self.status) + self.secretObjectId = self.bus.register_object( + SECRET_PATH, Gio.DBusNodeInfo.new_for_xml(SECRET_XML).interfaces[0], self.handle_secret, None, None) + self.client.connect("notify", self.on_notify) + self.register_agent() + self.changed() + + def register_agent(self): + self.agentReady = False + owner = self.client.get_dbus_name_owner() + if not owner: + return + self.bus.call(owner, NM_PATH + "/AgentManager", NM_NAME + ".AgentManager", "RegisterWithCapabilities", + GLib.Variant("(su)", ("org.stormux.I38.Network", 0)), None, + Gio.DBusCallFlags.NONE, 10000, self.cancellable, self.on_agent_registered, owner) + + def on_agent_registered(self, bus, result, owner): + try: + bus.call_finish(result) + self.agentReady = not self.closed and self.client.get_dbus_name_owner() == owner + except GLib.Error: + self.status("Cannot register I38's Wi-Fi password dialog. Connecting is disabled.") + self.changed() + + def on_notify(self, obj, prop): + if self.closed: + return + if prop.name == "dbus-name-owner": + self.finish_operation("NetworkManager restarted. Review the connection state before trying again.") + self.register_agent() + self.schedule_refresh() + + def schedule_refresh(self, *args): + if not self.closed and not self.refreshSource: + self.refreshSource = GLib.timeout_add(250, self.flush_refresh) + + def flush_refresh(self): + self.refreshSource = 0 + self.changed() + return GLib.SOURCE_REMOVE + + def permission_problem(self, permissions): + if not self.client or not self.client.get_nm_running(): + return "NetworkManager is not running." + if not self.member: + return ("Managing connections requires membership in the network group on Stormux. " + "Ask your administrator to add your account, then log out and back in.") + if not self.guard or not self.guard.ready: + if self.guard and self.guard.error: + return "Connection changes are disabled: " + self.guard.error + return "Connection changes are disabled until protection against administrator prompts is ready." + if self.client.get_permissions_state() != NM.Ternary.TRUE: + return "Waiting for NetworkManager permissions." + for permission in permissions: + result = self.client.get_permission_result(permission) + if result != NM.ClientPermissionResult.YES: + action = permission.value_nick.replace("-", " ") + if result == NM.ClientPermissionResult.AUTH: + return "System policy requires administrator authorization for {}. Ask your administrator to check the network group policy.".format(action) + if result == NM.ClientPermissionResult.UNKNOWN: + return "NetworkManager has not provided permission for {}.".format(action) + return "System policy denies {} for this account.".format(action) + return "" + + def authorize(self, permissions): + problem = self.permission_problem(permissions) + if problem: + self.status(problem) + return False + return not self.closed + + def modification_permission(self, profile): + setting = profile.get_setting_connection() + return MODIFY_OWN if setting.get_num_permissions() else MODIFY_SYSTEM + + def rows(self): + for obj, handler in self.signalHandlers: + GObject.signal_handler_disconnect(obj, handler) + self.signalHandlers = [] + if not self.client or not self.client.get_nm_running(): + return [] + rows = [] + for device in self.client.get_devices(): + if device.get_device_type() not in (NM.DeviceType.WIFI, NM.DeviceType.ETHERNET): + continue + self.watch(device) + # filter_connections() has a documented GI ownership bug. Validate + # individually so Python never unrefs NetworkManager's profiles. + profiles = [p for p in self.client.get_connections() if device.connection_valid(p)] + for profile in profiles: + self.watch(profile) + active = device.get_active_connection() + activeProfile = active.get_connection() if active else None + if active: + self.watch(active) + if device.get_device_type() == NM.DeviceType.WIFI: + groups = {} + activeAp = device.get_active_access_point() + for ap in device.get_access_points(): + self.watch(ap) + ssid = ap.get_ssid() + if not ssid or not ssid.get_data(): + continue + groupKey = (bytes(ssid.get_data()), security_type(ap)[1], security_type(ap)[0]) + previous = groups.get(groupKey) + if previous is None or ap == activeAp or (previous != activeAp and ap.get_strength() > previous.get_strength()): + groups[groupKey] = ap + represented = set() + for groupKey, ap in groups.items(): + matches = [p for p in profiles if ap.connection_valid(p)] + if activeProfile in matches: + matches.remove(activeProfile) + matches.insert(0, activeProfile) + # Keep distinct saved profiles selectable even for identical SSIDs. + for profile in matches or [None]: + if profile: + represented.add(profile.get_uuid()) + connected = active if profile and activeProfile == profile else None + rows.append(self.make_row(device, profile, ap, connected)) + for profile in profiles: + if profile.get_uuid() not in represented: + rows.append(self.make_row(device, profile, None, active if profile == activeProfile else None)) + else: + for profile in profiles: + rows.append(self.make_row(device, profile, None, active if profile == activeProfile else None)) + return sorted(rows, key=lambda row: (row.name.casefold(), row.kind, row.key)) + + def watch(self, obj): + self.signalHandlers.append((obj, obj.connect("notify", self.schedule_refresh))) + + def make_row(self, device, profile, ap, active): + wifi = device.get_device_type() == NM.DeviceType.WIFI + name = profile.get_id() if profile else NM.utils_ssid_to_utf8(ap.get_ssid().get_data()) + key = (device.get_path(), profile.get_uuid() if profile else bytes(ap.get_ssid().get_data()).hex() + ":" + security_type(ap)[0]) + state = "Connected" if active and active.get_state() == NM.ActiveConnectionState.ACTIVATED else "Disconnected" + if active and active.get_state() == NM.ActiveConnectionState.ACTIVATING: + state = "Connecting" + elif active and active.get_state() == NM.ActiveConnectionState.DEACTIVATING: + state = "Disconnecting" + elif wifi and not ap and not active: + state = "Unavailable" + elif not device.get_managed(): + state = "Unmanaged" + elif wifi and not self.client.wireless_hardware_get_enabled(): + state = "Hardware blocked" + elif wifi and not self.client.wireless_get_enabled(): + state = "Wi-Fi disabled" + elif not wifi and not device.get_carrier(): + state = "Cable unplugged" + security = security_type(ap)[0] if ap else "" + return NetworkRow(key, name, "Wi-Fi" if wifi else "Ethernet", state, + "{}%".format(ap.get_strength()) if ap else "", security, + device, profile, ap, active) + + def connect_row(self, row, password="", remember=True): + permissions = [NETWORK_CONTROL] + ([] if row.profile else [MODIFY_SYSTEM]) + if self.busy or not self.authorize(permissions): + return + if not self.agentReady: + self.status("The Wi-Fi password dialog is not ready. Try again shortly.") + return + try: + connection = row.profile or new_wifi_connection(row, password, remember) + except (ValueError, GLib.Error) as error: + self.status(str(error)) + return + self.busy = True + self.pending = {"uuid": connection.get_uuid(), "profile": row.profile, "device": row.device, + "active": None, "name": row.name} + self.status("Connecting to {}…".format(row.name)) + if row.profile: + self.client.activate_connection_async(connection, row.device, row.ap.get_path() if row.ap else None, + self.cancellable, self.on_activated, False) + else: + self.client.add_and_activate_connection_async(connection, row.device, row.ap.get_path(), + self.cancellable, self.on_activated, True) + self.changed() + + def on_activated(self, client, result, isNew): + try: + active = client.add_and_activate_connection_finish(result) if isNew else client.activate_connection_finish(result) + except GLib.Error: + self.finish_operation("Connection could not be started. Check permissions, availability, and the Wi-Fi password.") + return + if not self.pending or self.closed: + return + self.pending["active"] = active + active.connect("state-changed", self.on_activation_state) + self.activationTimeout = GLib.timeout_add_seconds(120, self.on_activation_timeout) + self.check_activation(active) + + def on_activation_state(self, active, state, reason): + self.check_activation(active, reason) + + def check_activation(self, active, reason=0): + if not self.pending or self.pending["active"] != active: + return + state = active.get_state() + if state == NM.ActiveConnectionState.ACTIVATED: + self.finish_operation("Connected to {}.".format(self.pending["name"])) + elif state == NM.ActiveConnectionState.DEACTIVATED: + detail = reason.value_nick.replace("-", " ") if hasattr(reason, "value_nick") else "unknown reason" + self.finish_operation("Connection failed or was cancelled: {}. Check the password and network availability.".format(detail)) + + def on_activation_timeout(self): + self.activationTimeout = 0 + self.finish_operation("Connection is taking longer than expected. NetworkManager may still be trying; review its current state.") + return GLib.SOURCE_REMOVE + + def finish_operation(self, message): + if getattr(self, "activationTimeout", 0): + GLib.source_remove(self.activationTimeout) + self.activationTimeout = 0 + self.cancel_secret() + self.pending = None + self.busy = False + if not self.closed: + self.status(message) + self.changed() + + def handle_secret(self, bus, sender, path, interface, method, parameters, invocation): + if sender != self.client.get_dbus_name_owner(): + invocation.return_dbus_error(SECRET_INTERFACE + ".PermissionDenied", "Unexpected sender") + return + args = parameters.unpack() + if method == "CancelGetSecrets": + if self.secretRequest and self.secretRequest[0] == args[0]: + self.cancel_secret() + invocation.return_value(GLib.Variant("()", ())) + return + if method in ("SaveSecrets", "DeleteSecrets"): + # This agent never owns or stores passwords. NetworkManager owns saved secrets. + invocation.return_value(GLib.Variant("()", ())) + return + settings, connectionPath, settingName, hints, flags = args + connectionId = settings.get("connection", {}).get("uuid") + keyMgmt = settings.get(SECURITY_SETTING, {}).get("key-mgmt") + if (not self.pending or connectionId != self.pending["uuid"] or + settingName != SECURITY_SETTING or keyMgmt not in ("wpa-psk", "sae") or + not flags & int(NM.SecretAgentGetSecretsFlags.ALLOW_INTERACTION)): + invocation.return_dbus_error(SECRET_INTERFACE + ".NoSecrets", "No credentials available for this request") + return + self.cancel_secret() + self.secretRequest = (connectionPath, invocation) + profile = self.client.get_connection_by_uuid(connectionId) + canRemember = bool(profile and not self.permission_problem([self.modification_permission(profile)])) + + def answered(password, remember): + if not self.secretRequest or self.secretRequest[1] != invocation: + return + if password is None: + self.cancel_secret() + return + if not valid_password(password, keyMgmt): + self.cancel_secret() + self.status("The Wi-Fi password has an invalid length or format.") + return + if remember and profile: + if not self.authorize([self.modification_permission(profile)]): + self.cancel_secret() + return + clone = NM.SimpleConnection.new_clone(profile) + security = clone.get_setting_wireless_security() + security.props.psk = password + security.props.psk_flags = NM.SettingSecretFlags.NONE + profile.update2(clone.to_dbus(NM.ConnectionSerializationFlags.ALL), NM.SettingsUpdate2Flags.TO_DISK, + GLib.Variant("a{sv}", {}), self.cancellable, saved, password) + else: + reply(password) + + def saved(profile, result, password): + try: + profile.update2_finish(result) + except GLib.Error: + self.cancel_secret() + self.status("The password could not be saved. Check your connection permissions.") + return + reply(password) + + def reply(password): + if self.secretRequest and self.secretRequest[1] == invocation: + self.secretRequest = None + invocation.return_value(GLib.Variant("(a{sa{sv}})", ({SECURITY_SETTING: {"psk": GLib.Variant("s", password)}},))) + + self.password(self.pending["name"], keyMgmt, canRemember, answered) + + def cancel_secret(self): + if self.secretRequest: + request = self.secretRequest + self.secretRequest = None + request[1].return_dbus_error(SECRET_INTERFACE + ".UserCanceled", "Password entry cancelled") + + def disconnect_row(self, row): + if self.busy or not row.active or not self.authorize([NETWORK_CONTROL]): + return + self.busy = True + self.client.deactivate_connection_async(row.active, self.cancellable, self.on_disconnected, None) + self.changed() + + def on_disconnected(self, client, result, data): + try: + client.deactivate_connection_finish(result) + self.finish_operation("Disconnected. NetworkManager may automatically activate another saved connection.") + except GLib.Error: + self.finish_operation("Could not disconnect. Check your network permissions.") + + def set_wireless(self, enabled): + if self.busy or not self.authorize([NM.ClientPermission.ENABLE_DISABLE_WIFI]): + return + self.busy = True + self.bus.call(self.client.get_dbus_name_owner(), NM_PATH, "org.freedesktop.DBus.Properties", "Set", + GLib.Variant("(ssv)", (NM_NAME, "WirelessEnabled", GLib.Variant("b", enabled))), None, + Gio.DBusCallFlags.NONE, 10000, self.cancellable, self.on_wireless_set, enabled) + self.changed() + + def on_wireless_set(self, bus, result, enabled): + try: + bus.call_finish(result) + self.finish_operation("Wi-Fi enabled." if enabled else "Wi-Fi disabled.") + except GLib.Error: + self.finish_operation("Could not change Wi-Fi state. Check permissions and the hardware wireless switch.") + + def scan(self): + if not self.authorize([NM.ClientPermission.WIFI_SCAN]): + return + devices = [d for d in self.client.get_devices() if isinstance(d, NM.DeviceWifi) and d.get_managed()] + if not devices or not self.client.wireless_get_enabled(): + self.status("No enabled Wi-Fi adapter is available.") + return + self.status("Refreshing Wi-Fi networks…") + for device in devices: + device.request_scan_async(self.cancellable, self.on_scanned, None) + + def on_scanned(self, device, result, data): + try: + device.request_scan_finish(result) + self.status("Wi-Fi scan requested. The list will update as results arrive.") + except GLib.Error: + self.status("Wi-Fi scan could not be requested. Try again shortly.") + self.changed() + + def close(self): + self.closed = True + self.cancellable.cancel() + self.cancel_secret() + if self.refreshSource: + GLib.source_remove(self.refreshSource) + if getattr(self, "activationTimeout", 0): + GLib.source_remove(self.activationTimeout) + for obj, handler in self.signalHandlers: + GObject.signal_handler_disconnect(obj, handler) + if self.client and hasattr(self, "secretObjectId"): + owner = self.client.get_dbus_name_owner() + if owner: + self.bus.call(owner, NM_PATH + "/AgentManager", NM_NAME + ".AgentManager", "Unregister", None, + None, Gio.DBusCallFlags.NONE, 5000, None, None, None) + self.bus.unregister_object(self.secretObjectId) + if self.guard: + self.guard.close()