From 06daa422b92e86b6c064623ae9c8ae325f5c7bfc Mon Sep 17 00:00:00 2001 From: ericbsd Date: Fri, 11 Sep 2026 21:47:17 -0300 Subject: [PATCH 1/2] Drive Wi-Fi through wpa_cli, save networks only once they connect Wireless is now handled through wpa_supplicant's control socket instead of by editing /etc/wpa_supplicant.conf and restarting the daemon. Scanning, saving, forgetting, connecting and disconnecting all go through wpa_cli, and the daemon started by rc is the only one that ever runs. networkmgr never runs the wpa_supplicant binary, never restarts a service on the Wi-Fi path, and never uses shell=True. Connecting - connect_to_ssid() joins the clicked network with select_network, so with two saved networks in range the one clicked is the one joined. wait_for_connection() requires wpa_state=COMPLETED and a matching ssid; ifconfig's "associated" is not used, since it appears before the key handshake finishes. - Save on connect. A new network, or a retyped passphrase, lives in the daemon only until wait_for_connection() succeeds, then save_config writes the file. A passphrase that never works never reaches disk, and retyping one badly cannot overwrite a working one. - A failed attempt reopens the credentials dialog, three attempts, then the network is forgotten. A refused key cannot be told from a card that never answered on driver_bsd (wpas_auth_failed never fires, the network is never marked TEMP-DISABLED), so both are reported as "Could not connect". - reconfigure is sent only at the moment of committing to a connection or a forget, so an edit left in the daemon by an abandoned attempt is discarded before the next one is written. Opening and cancelling a dialog no longer touches the daemon. - Open networks connect on the first click; the first click used to write the block and the second to connect. - A "Forget Network" submenu lists the saved networks from the daemon, including ones out of range. - WPA3-only networks are listed greyed out as "(WPA3 only)". Base wpa_supplicant is built with SAE but driver_bsd offers no SAE key management, so such a network can never be joined here; transition networks (WPA2-PSK+SAE) still join with a passphrase. Reading - Scan results, saved networks and connection state come from wpa_cli and are printf-decoded, so an SSID with non-ASCII bytes, quotes or backslashes is found, shown and joined by its real name. ifconfig list scan renders such a name as a truncated hex column and cannot represent it. - The tray refresh reads only the default-route interface every 30 s (2-4 commands, about 5 ms); the full interface list is collected when the menu opens. Signal for the connected AP comes from bss current. - Wired interface state is Disabled, Unplug, Connected or Disconnected, tested in that order; a static card with the cable out no longer reads Connected. Disconnected offers Disable and Configure instead of a no-op Enable. - The IPv6 tab's interface chooser passed no argument to its handler and raised on every change. - The configuration window reads the gateway from the newest lease, toggles the Search domains entry with the other fields, and refuses to save a Manual configuration with no primary DNS server (IPv4) or no address (IPv6). - The resolv.conf search domain regex no longer stops at a digit or hyphen. Saving - Networks are written with add_network/set_network/enable_network/ save_config. Strings use wpa_supplicant's P"..." form except psk, which only accepts plain quotes. wpa_save_config re-chmods the file to 0600 because the daemon's own chmod is Android-only. - WEP keys are quoted unless they are 10, 26 or 32 hex digits; a bare ASCII key was read as hex and rejected. - EAP-TTLS uses autheap= for EAP inner methods (GTC, MD5); auth= made the method refuse to start. EAP retries replace the block rather than appending one. - src/wpa_supplicant.conf ships the globals the daemon needs (ctrl_interface, ctrl_interface_group=operator, update_config=1, pmf, autoscan). setup-nic.py installs it when the file is missing and prepends any missing global to an existing one. - src/sudoers.d/networkmgr grants %operator rather than %wheel, to match ctrl_interface_group. Wired and devd - Wired Enable/Disable are ifconfig up/down. Handing the default route over on link loss is left to devd's auto-switch.py; switch_default in net_api duplicated it and raced it for the link-down marker. - The link-down marker moves from /tmp to /var/run: /tmp is world-writable with a predictable name and was opened for writing as root, and it is not cleared at boot on a stock install (clear_tmp_enable=NO), so a stale marker could send link-up.py down the wrong branch. /var/run is root-only and cleanvar empties it at every boot. - link-up.py, auto-switch.py and setup-nic.py run their commands without a shell. sysrc values are passed as one argument. Housekeeping - Every function and method name is word_an_other_word; classes are TrayIcon and NetCardConfigWindow. Every touched function has a docstring with its parameters. - Translation extraction pointed at a directory renamed in 2023; networkmgr.pot went from 22 to 50 msgids, merged into every .po, and the f-string call sites that could never match a catalogue entry are fixed. - Dialog headings use Pango attributes rather than markup, so an SSID containing & or < renders as text. - pylint 8.10 to 9.65 with a rebuilt .pylintrc; no shell=True anywhere in NetworkMgr/ or src/. - The tests/ tree and requirements.txt are removed; the suite had not been run since the 2023 rename. The nine net_api functions only the tests imported go with them. - Version 7.0. Tested on rtwn0 (RTL8811AU): connecting to a saved network, switching between two, disconnecting, a stale network timing out and keeping its block, a wrong passphrase running the full timeout three times, and joining a WPA2/WPA3 transition hotspot with a non-ASCII name. Not tested: a real open AP, iwlwifi, the reconfigure-at-commit change and the wired state rework, both from today. Closes ghostbsd/issues#99 Closes ghostbsd/issues#81 Closes ghostbsd/issues#55 Closes ghostbsd/issues#165 Closes #122 Closes #85 Closes #78 Claude-Session: https://claude.ai/code/session_019oi7jvrVW78ZoZKWADbGTN --- .pylintrc | 10 + NetworkMgr/configuration.py | 852 ++++++++------- NetworkMgr/net_api.py | 1601 +++++++++++++++++++++-------- NetworkMgr/query.py | 190 ++-- NetworkMgr/trayicon.py | 1132 +++++++++++++------- NetworkMgr/wg_api.py | 78 +- networkmgr | 4 +- networkmgr_configuration | 2 +- po/de.po | 211 +++- po/nb_NO.po | 211 +++- po/networkmgr.pot | 195 +++- po/pt_Br.po | 211 +++- po/ru.po | 211 +++- po/sv.po | 211 +++- po/zh_CN.po | 211 +++- requirements.txt | 12 - setup.py | 69 +- src/auto-switch.py | 55 +- src/link-up.py | 30 +- src/setup-nic.py | 88 +- src/sudoers.d/networkmgr | 2 +- src/wpa_supplicant.conf | 7 + tests/README.md | 74 -- tests/__init__.py | 0 tests/unit/test_enterprise_wpa.py | 227 ---- tests/unit/test_net_api.py | 105 -- 26 files changed, 4028 insertions(+), 1971 deletions(-) create mode 100644 .pylintrc delete mode 100644 requirements.txt create mode 100644 src/wpa_supplicant.conf delete mode 100644 tests/README.md delete mode 100644 tests/__init__.py delete mode 100644 tests/unit/test_enterprise_wpa.py delete mode 100644 tests/unit/test_net_api.py diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..c87a03b --- /dev/null +++ b/.pylintrc @@ -0,0 +1,10 @@ + +[MASTER] +# Add current directory to Python path so pylint can find local modules +init-hook='import sys; sys.path.append(".")' +ignore=.venv,venv,.git,__pycache__,.pytest_cache,build,dist + +[FORMAT] +# Maximum line length +max-line-length=120 +max-module-lines=1800 \ No newline at end of file diff --git a/NetworkMgr/configuration.py b/NetworkMgr/configuration.py index d0689a4..20e37da 100755 --- a/NetworkMgr/configuration.py +++ b/NetworkMgr/configuration.py @@ -1,12 +1,25 @@ #!/usr/bin/env python -import gi -import os +"""The per-interface network configuration window. + +Two tabs, IPv4 and IPv6, each offering an automatic mode (DHCP or SLAAC) or +a manual address, mask, gateway, DNS and search domain. Saving writes +rc.conf through sysrc and /etc/resolv.conf directly, then applies the same +settings to the running system through NetworkMgr.net_api. +""" + import re +from subprocess import DEVNULL, run + +import gi gi.require_version('Gtk', '3.0') + +# gi.require_version() has to run before the typelib is loaded, so the +# imports below cannot be hoisted above it. +# pylint: disable=wrong-import-position from gi.repository import Gtk, GLib from NetworkMgr.net_api import ( - defaultcard, + default_card, nics_list, restart_card_network, restart_routing_and_dhcp, @@ -14,272 +27,300 @@ start_static_ipv6_network, enable_slaac, disable_slaac, - wait_inet + wait_for_address ) from NetworkMgr.query import get_interface_settings, get_interface_settings_ipv6 -from subprocess import run -rcconf = open('/etc/rc.conf', 'r').read() -if os.path.exists('/etc/rc.conf.local'): - rcconflocal = open('/etc/rc.conf.local', 'r').read() -else: - rcconflocal = "None" - -class netCardConfigWindow(Gtk.Window): +class NetCardConfigWindow(Gtk.Window): + """The configuration window for one network interface.""" def edit_ipv4_setting(self, widget): + """Switch the IPv4 tab between DHCP and manual entry. + + Args: + widget (Gtk.RadioButton): the radio button that changed. Ignored + unless it is the one that became active. + """ if widget.get_active(): self.method = widget.get_label() if self.method == "DHCP": - self.ipInputAddressEntry.set_sensitive(False) - self.ipInputMaskEntry.set_sensitive(False) - self.ipInputGatewayEntry.set_sensitive(False) - self.prymary_dnsEntry.set_sensitive(False) - self.secondary_dnsEntry.set_sensitive(False) + self.ip_input_address_entry.set_sensitive(False) + self.ip_input_mask_entry.set_sensitive(False) + self.ip_input_gateway_entry.set_sensitive(False) + self.prymary_dns_entry.set_sensitive(False) + self.secondary_dns_entry.set_sensitive(False) + self.search_entry.set_sensitive(False) else: - self.ipInputAddressEntry.set_sensitive(True) - self.ipInputMaskEntry.set_sensitive(True) - self.ipInputGatewayEntry.set_sensitive(True) - self.prymary_dnsEntry.set_sensitive(True) - self.secondary_dnsEntry.set_sensitive(True) - if self.method == self.currentSettings["Assignment Method"]: - self.saveButton.set_sensitive(False) + self.ip_input_address_entry.set_sensitive(True) + self.ip_input_mask_entry.set_sensitive(True) + self.ip_input_gateway_entry.set_sensitive(True) + self.prymary_dns_entry.set_sensitive(True) + self.secondary_dns_entry.set_sensitive(True) + self.search_entry.set_sensitive(True) + if self.method == self.current_settings["Assignment Method"]: + self.save_button.set_sensitive(False) else: - self.saveButton.set_sensitive(True) + self.save_button.set_sensitive(self.settings_complete()) def edit_ipv6_setting(self, widget, value): + """Switch the IPv6 tab between SLAAC and manual entry. + + Args: + widget (Gtk.RadioButton): the radio button that changed. Ignored + unless it is the one that became active. + value (str): the method the button stands for, "SLAAC" or + "Manual". + """ if widget.get_active(): self.method6 = value # Check if GUI elements exist (may be called during init) - if not hasattr(self, 'ipInputAddressEntry6'): + if not hasattr(self, 'ip_input_address_entry6'): return if value == "SLAAC": - self.ipInputAddressEntry6.set_sensitive(False) - self.ipInputMaskEntry6.set_sensitive(False) - self.ipInputGatewayEntry6.set_sensitive(False) - self.prymary_dnsEntry6.set_sensitive(False) - self.searchEntry6.set_sensitive(False) + self.ip_input_address_entry6.set_sensitive(False) + self.ip_input_mask_entry6.set_sensitive(False) + self.ip_input_gateway_entry6.set_sensitive(False) + self.prymary_dns_entry6.set_sensitive(False) + self.search_entry6.set_sensitive(False) else: - self.ipInputAddressEntry6.set_sensitive(True) - self.ipInputMaskEntry6.set_sensitive(True) - self.ipInputGatewayEntry6.set_sensitive(True) - self.prymary_dnsEntry6.set_sensitive(True) - self.searchEntry6.set_sensitive(True) + self.ip_input_address_entry6.set_sensitive(True) + self.ip_input_mask_entry6.set_sensitive(True) + self.ip_input_gateway_entry6.set_sensitive(True) + self.prymary_dns_entry6.set_sensitive(True) + self.search_entry6.set_sensitive(True) # Enable save button if method changed - if self.method6 == self.currentSettings6["Assignment Method"]: - self.saveButton.set_sensitive(False) + if self.method6 == self.current_settings6["Assignment Method"]: + self.save_button.set_sensitive(False) else: - self.saveButton.set_sensitive(True) - - def entry_trigger_save_button(self, widget, event): - self.saveButton.set_sensitive(True) + self.save_button.set_sensitive(self.settings_complete()) + + def settings_complete(self): + """Report whether the entries hold enough to save. + + Returns: + bool: False when the IPv4 method is Manual with no primary DNS + server, or the IPv6 method is Manual with no address. + """ + if self.method == 'Manual' and not self.prymary_dns_entry.get_text().strip(): + return False + if self.method6 == 'Manual' and not self.ip_input_address_entry6.get_text().strip(): + return False + return True + + def entry_trigger_save_button(self, _widget, _event): + """Enable Save as soon as the user edits any entry. + + Args: + _widget (Gtk.Widget): the edited entry, unused. + _event (Gdk.Event): the key event, unused. + """ + self.save_button.set_sensitive(self.settings_complete()) def __init__(self, selected_nic=None): # Build Default Window Gtk.Window.__init__(self, title="Network Configuration") self.set_default_size(475, 400) - self.NICS = nics_list() - DEFAULT_NIC = selected_nic if selected_nic else defaultcard() + self.nics = nics_list() + default_nic = selected_nic if selected_nic else default_card() # Build Tab 1 Content # Interface Drop Down Combo Box cell = Gtk.CellRendererText() - interfaceComboBox = Gtk.ComboBox() - interfaceComboBox.pack_start(cell, expand=True) - interfaceComboBox.add_attribute(cell, 'text', 0) + interface_combo_box = Gtk.ComboBox() + interface_combo_box.pack_start(cell, expand=True) + interface_combo_box.add_attribute(cell, 'text', 0) # Add interfaces to a ListStore store = Gtk.ListStore(str) - for nic in self.NICS: + for nic in self.nics: store.append([nic]) - interfaceComboBox.set_model(store) - interfaceComboBox.set_margin_top(15) - interfaceComboBox.set_margin_end(30) - if DEFAULT_NIC: - active_index = self.NICS.index(f"{DEFAULT_NIC}") - interfaceComboBox.set_active(active_index) - self.currentSettings = get_interface_settings(DEFAULT_NIC) - self.method = self.currentSettings["Assignment Method"] + interface_combo_box.set_model(store) + interface_combo_box.set_margin_top(15) + interface_combo_box.set_margin_end(30) + if default_nic: + active_index = self.nics.index(f"{default_nic}") + interface_combo_box.set_active(active_index) + self.current_settings = get_interface_settings(default_nic) + self.method = self.current_settings["Assignment Method"] # IPv6 settings - self.currentSettings6 = get_interface_settings_ipv6(DEFAULT_NIC) - self.method6 = self.currentSettings6["Assignment Method"] - interfaceComboBox.connect("changed", self.cbox_config_refresh, self.NICS) + self.current_settings6 = get_interface_settings_ipv6(default_nic) + self.method6 = self.current_settings6["Assignment Method"] + interface_combo_box.connect("changed", self.cbox_config_refresh) # Build Label to sit in front of the ComboBox - labelOne = Gtk.Label(label="Interface:") - labelOne.set_margin_top(15) - labelOne.set_margin_start(30) + label_one = Gtk.Label(label="Interface:") + label_one.set_margin_top(15) + label_one.set_margin_start(30) # Add both objects to a single box, which will then be added to the grid - interfaceBox = Gtk.Box(orientation=0, spacing=100) - interfaceBox.pack_start(labelOne, False, False, 0) - interfaceBox.pack_end(interfaceComboBox, True, True, 0) + interface_box = Gtk.Box(orientation=0, spacing=100) + interface_box.pack_start(label_one, False, False, 0) + interface_box.pack_end(interface_combo_box, True, True, 0) # Add radio button to toggle DHCP or not - self.version = self.currentSettings["Assignment Method"] self.rb_dhcp4 = Gtk.RadioButton.new_with_label(None, "DHCP") self.rb_dhcp4.set_margin_top(15) self.rb_manual4 = Gtk.RadioButton.new_with_label_from_widget( self.rb_dhcp4, "Manual") self.rb_manual4.set_margin_top(15) - if self.currentSettings["Assignment Method"] == "DHCP": + if self.current_settings["Assignment Method"] == "DHCP": self.rb_dhcp4.set_active(True) else: self.rb_manual4.set_active(True) self.rb_manual4.join_group(self.rb_dhcp4) - radioButtonLabel = Gtk.Label(label="IPv4 Method:") - radioButtonLabel.set_margin_top(15) - radioButtonLabel.set_margin_start(30) + radio_button_label = Gtk.Label(label="IPv4 Method:") + radio_button_label.set_margin_top(15) + radio_button_label.set_margin_start(30) - radioBox = Gtk.Box(orientation=0, spacing=50) - radioBox.set_homogeneous(False) - radioBox.pack_start(radioButtonLabel, False, False, 0) - radioBox.pack_start(self.rb_dhcp4, True, False, 0) - radioBox.pack_end(self.rb_manual4, True, True, 0) + radio_box = Gtk.Box(orientation=0, spacing=50) + radio_box.set_homogeneous(False) + radio_box.pack_start(radio_button_label, False, False, 0) + radio_box.pack_start(self.rb_dhcp4, True, False, 0) + radio_box.pack_end(self.rb_manual4, True, True, 0) # Add Manual Address Field - ipInputAddressLabel = Gtk.Label(label="Address") - ipInputAddressLabel.set_margin_top(15) + ip_input_address_label = Gtk.Label(label="Address") + ip_input_address_label.set_margin_top(15) - ipInputMaskLabel = Gtk.Label(label="Subnet Mask") - ipInputMaskLabel.set_margin_top(15) + ip_input_mask_label = Gtk.Label(label="Subnet Mask") + ip_input_mask_label.set_margin_top(15) - ipInputGatewayLabel = Gtk.Label(label="Gateway") - ipInputGatewayLabel.set_margin_top(15) + ip_input_gateway_label = Gtk.Label(label="Gateway") + ip_input_gateway_label.set_margin_top(15) - self.ipInputAddressEntry = Gtk.Entry() - self.ipInputAddressEntry.set_margin_start(15) - self.ipInputAddressEntry.set_text(self.currentSettings["Interface IP"]) - self.ipInputAddressEntry.connect("key-release-event", self.entry_trigger_save_button) + self.ip_input_address_entry = Gtk.Entry() + self.ip_input_address_entry.set_margin_start(15) + self.ip_input_address_entry.set_text(self.current_settings["Interface IP"]) + self.ip_input_address_entry.connect("key-release-event", self.entry_trigger_save_button) - self.ipInputMaskEntry = Gtk.Entry() - self.ipInputMaskEntry.set_text(self.currentSettings["Interface Subnet Mask"]) - self.ipInputMaskEntry.connect("key-release-event", self.entry_trigger_save_button) + self.ip_input_mask_entry = Gtk.Entry() + self.ip_input_mask_entry.set_text(self.current_settings["Interface Subnet Mask"]) + self.ip_input_mask_entry.connect("key-release-event", self.entry_trigger_save_button) - self.ipInputGatewayEntry = Gtk.Entry() - self.ipInputGatewayEntry.set_margin_end(15) - self.ipInputGatewayEntry.set_text(self.currentSettings["Default Gateway"]) - self.ipInputGatewayEntry.connect("key-release-event", self.entry_trigger_save_button) + self.ip_input_gateway_entry = Gtk.Entry() + self.ip_input_gateway_entry.set_margin_end(15) + self.ip_input_gateway_entry.set_text(self.current_settings["Default Gateway"]) + self.ip_input_gateway_entry.connect("key-release-event", self.entry_trigger_save_button) - ipInputBox = Gtk.Box(orientation=0, spacing=0) - ipInputBox.set_homogeneous(True) - ipInputBox.pack_start(ipInputAddressLabel, False, False, 0) - ipInputBox.pack_start(ipInputMaskLabel, False, False, 0) - ipInputBox.pack_start(ipInputGatewayLabel, False, False, 0) + ip_input_box = Gtk.Box(orientation=0, spacing=0) + ip_input_box.set_homogeneous(True) + ip_input_box.pack_start(ip_input_address_label, False, False, 0) + ip_input_box.pack_start(ip_input_mask_label, False, False, 0) + ip_input_box.pack_start(ip_input_gateway_label, False, False, 0) - ipEntryBox = Gtk.Box(orientation=0, spacing=30) - ipEntryBox.pack_start(self.ipInputAddressEntry, False, False, 0) - ipEntryBox.pack_start(self.ipInputMaskEntry, False, False, 0) - ipEntryBox.pack_start(self.ipInputGatewayEntry, False, False, 0) + ip_entry_box = Gtk.Box(orientation=0, spacing=30) + ip_entry_box.pack_start(self.ip_input_address_entry, False, False, 0) + ip_entry_box.pack_start(self.ip_input_mask_entry, False, False, 0) + ip_entry_box.pack_start(self.ip_input_gateway_entry, False, False, 0) # Add DNS Server Settings - prymary_dns_Label = Gtk.Label(label="Primary DNS Servers: ") - prymary_dns_Label.set_margin_top(15) - prymary_dns_Label.set_margin_end(58) - prymary_dns_Label.set_margin_start(30) + prymary_dns_label = Gtk.Label(label="Primary DNS Servers: ") + prymary_dns_label.set_margin_top(15) + prymary_dns_label.set_margin_end(58) + prymary_dns_label.set_margin_start(30) - secondary_dns_Label = Gtk.Label(label="Secondary DNS Servers: ") - secondary_dns_Label.set_margin_top(15) - secondary_dns_Label.set_margin_end(58) - secondary_dns_Label.set_margin_start(30) + secondary_dns_label = Gtk.Label(label="Secondary DNS Servers: ") + secondary_dns_label.set_margin_top(15) + secondary_dns_label.set_margin_end(58) + secondary_dns_label.set_margin_start(30) - self.prymary_dnsEntry = Gtk.Entry() - self.prymary_dnsEntry.set_margin_end(30) - self.prymary_dnsEntry.set_text(self.currentSettings["DNS Server 1"]) - self.prymary_dnsEntry.connect("key-release-event", self.entry_trigger_save_button) + self.prymary_dns_entry = Gtk.Entry() + self.prymary_dns_entry.set_margin_end(30) + self.prymary_dns_entry.set_text(self.current_settings["DNS Server 1"]) + self.prymary_dns_entry.connect("key-release-event", self.entry_trigger_save_button) - self.secondary_dnsEntry = Gtk.Entry() - self.secondary_dnsEntry.set_margin_end(30) - self.secondary_dnsEntry.set_text(self.currentSettings["DNS Server 2"]) - self.secondary_dnsEntry.connect("key-release-event", self.entry_trigger_save_button) + self.secondary_dns_entry = Gtk.Entry() + self.secondary_dns_entry.set_margin_end(30) + self.secondary_dns_entry.set_text(self.current_settings["DNS Server 2"]) + self.secondary_dns_entry.connect("key-release-event", self.entry_trigger_save_button) - dnsEntryBox1 = Gtk.Box(orientation=0, spacing=0) - dnsEntryBox1.pack_start(prymary_dns_Label, False, False, 0) + dns_entry_box1 = Gtk.Box(orientation=0, spacing=0) + dns_entry_box1.pack_start(prymary_dns_label, False, False, 0) - dnsEntryBox1.pack_end(self.prymary_dnsEntry, True, True, 0) + dns_entry_box1.pack_end(self.prymary_dns_entry, True, True, 0) - dnsEntryBox2 = Gtk.Box(orientation=0, spacing=0) - dnsEntryBox2.pack_start(secondary_dns_Label, False, False, 0) + dns_entry_box2 = Gtk.Box(orientation=0, spacing=0) + dns_entry_box2.pack_start(secondary_dns_label, False, False, 0) - dnsEntryBox2.pack_end(self.secondary_dnsEntry, True, True, 0) + dns_entry_box2.pack_end(self.secondary_dns_entry, True, True, 0) # Add Search Domain Settings - searchLabel = Gtk.Label(label="Search domains: ") - searchLabel.set_margin_top(15) - searchLabel.set_margin_end(30) - searchLabel.set_margin_start(30) - - self.searchEntry = Gtk.Entry() - self.searchEntry.set_margin_top(21) - self.searchEntry.set_margin_end(30) - self.searchEntry.set_margin_bottom(30) - self.searchEntry.set_text(self.currentSettings["Search Domain"]) - self.searchEntry.connect("key-release-event", self.entry_trigger_save_button) - - searchBox = Gtk.Box(orientation=0, spacing=0) - searchBox.pack_start(searchLabel, False, False, 0) - searchBox.pack_end(self.searchEntry, True, True, 0) + search_label = Gtk.Label(label="Search domains: ") + search_label.set_margin_top(15) + search_label.set_margin_end(30) + search_label.set_margin_start(30) + + self.search_entry = Gtk.Entry() + self.search_entry.set_margin_top(21) + self.search_entry.set_margin_end(30) + self.search_entry.set_margin_bottom(30) + self.search_entry.set_text(self.current_settings["Search Domain"]) + self.search_entry.connect("key-release-event", self.entry_trigger_save_button) + + search_box = Gtk.Box(orientation=0, spacing=0) + search_box.pack_start(search_label, False, False, 0) + search_box.pack_end(self.search_entry, True, True, 0) self.rb_dhcp4.connect("toggled", self.edit_ipv4_setting) self.rb_manual4.connect("toggled", self.edit_ipv4_setting) - if self.currentSettings["Assignment Method"] == "DHCP": - self.ipInputAddressEntry.set_sensitive(False) - self.ipInputMaskEntry.set_sensitive(False) - self.ipInputGatewayEntry.set_sensitive(False) - self.prymary_dnsEntry.set_sensitive(False) - self.secondary_dnsEntry.set_sensitive(False) - self.searchEntry.set_sensitive(False) - - gridOne = Gtk.Grid() - gridOne.set_column_homogeneous(True) - gridOne.set_row_homogeneous(False) - gridOne.set_column_spacing(5) - gridOne.set_row_spacing(10) - gridOne.attach(interfaceBox, 0, 0, 4, 1) - gridOne.attach(radioBox, 0, 1, 4, 1) - gridOne.attach(ipInputBox, 0, 2, 4, 1) - gridOne.attach(ipEntryBox, 0, 3, 4, 1) - gridOne.attach(dnsEntryBox1, 0, 4, 4, 1) - gridOne.attach(dnsEntryBox2, 0, 5, 4, 1) - gridOne.attach(searchBox, 0, 6, 4, 1) + if self.current_settings["Assignment Method"] == "DHCP": + self.ip_input_address_entry.set_sensitive(False) + self.ip_input_mask_entry.set_sensitive(False) + self.ip_input_gateway_entry.set_sensitive(False) + self.prymary_dns_entry.set_sensitive(False) + self.secondary_dns_entry.set_sensitive(False) + self.search_entry.set_sensitive(False) + + grid_one = Gtk.Grid() + grid_one.set_column_homogeneous(True) + grid_one.set_row_homogeneous(False) + grid_one.set_column_spacing(5) + grid_one.set_row_spacing(10) + grid_one.attach(interface_box, 0, 0, 4, 1) + grid_one.attach(radio_box, 0, 1, 4, 1) + grid_one.attach(ip_input_box, 0, 2, 4, 1) + grid_one.attach(ip_entry_box, 0, 3, 4, 1) + grid_one.attach(dns_entry_box1, 0, 4, 4, 1) + grid_one.attach(dns_entry_box2, 0, 5, 4, 1) + grid_one.attach(search_box, 0, 6, 4, 1) # Build Tab 2 Content # Interface Drop Down Combo Box cell6 = Gtk.CellRendererText() - interfaceComboBox6 = Gtk.ComboBox() - interfaceComboBox6.pack_start(cell6, expand=True) - interfaceComboBox6.add_attribute(cell6, 'text', 0) + interface_combo_box6 = Gtk.ComboBox() + interface_combo_box6.pack_start(cell6, expand=True) + interface_combo_box6.add_attribute(cell6, 'text', 0) # Add interfaces to a ListStore store6 = Gtk.ListStore(str) - for validinterface6 in self.NICS: + for validinterface6 in self.nics: store6.append([validinterface6]) - interfaceComboBox6.set_model(store) - interfaceComboBox6.set_margin_top(15) - interfaceComboBox6.set_margin_end(30) + interface_combo_box6.set_model(store6) + interface_combo_box6.set_margin_top(15) + interface_combo_box6.set_margin_end(30) - if DEFAULT_NIC: - activeComboBoxObjectIndex6 = self.NICS.index(f"{DEFAULT_NIC}") - interfaceComboBox6.set_active(activeComboBoxObjectIndex6) - interfaceComboBox6.connect("changed", self.cbox_config_refresh) + if default_nic: + active_combo_box_object_index6 = self.nics.index(f"{default_nic}") + interface_combo_box6.set_active(active_combo_box_object_index6) + interface_combo_box6.connect("changed", self.cbox_config_refresh) # Build Label to sit in front of the ComboBox - labelOne6 = Gtk.Label(label="Interface:") - labelOne6.set_margin_top(15) - labelOne6.set_margin_start(30) + label_one6 = Gtk.Label(label="Interface:") + label_one6.set_margin_top(15) + label_one6.set_margin_start(30) # Add both objects to a single box, which will then be added to the grid - interfaceBox6 = Gtk.Box(orientation=0, spacing=100) - interfaceBox6.pack_start(labelOne6, False, False, 0) - interfaceBox6.pack_end(interfaceComboBox6, True, True, 0) + interface_box6 = Gtk.Box(orientation=0, spacing=100) + interface_box6.pack_start(label_one6, False, False, 0) + interface_box6.pack_end(interface_combo_box6, True, True, 0) # Add radio button to toggle SLAAC or Manual self.rb_slaac6 = Gtk.RadioButton.new_with_label(None, "SLAAC") @@ -297,101 +338,101 @@ def __init__(self, selected_nic=None): else: self.rb_slaac6.set_active(True) - radioButtonLabel6 = Gtk.Label(label="IPv6 Method:") - radioButtonLabel6.set_margin_top(15) - radioButtonLabel6.set_margin_start(30) + radio_button_label6 = Gtk.Label(label="IPv6 Method:") + radio_button_label6.set_margin_top(15) + radio_button_label6.set_margin_start(30) - radioBox6 = Gtk.Box(orientation=0, spacing=50) - radioBox6.set_homogeneous(False) - radioBox6.pack_start(radioButtonLabel6, False, False, 0) - radioBox6.pack_start(self.rb_slaac6, True, False, 0) - radioBox6.pack_end(self.rb_manual6, True, True, 0) + radio_box6 = Gtk.Box(orientation=0, spacing=50) + radio_box6.set_homogeneous(False) + radio_box6.pack_start(radio_button_label6, False, False, 0) + radio_box6.pack_start(self.rb_slaac6, True, False, 0) + radio_box6.pack_end(self.rb_manual6, True, True, 0) # Add Manual Address Field - ipInputAddressLabel6 = Gtk.Label(label="Address") - ipInputAddressLabel6.set_margin_top(15) - - ipInputMaskLabel6 = Gtk.Label(label="Prefix Length") - ipInputMaskLabel6.set_margin_top(15) - - ipInputGatewayLabel6 = Gtk.Label(label="Gateway") - ipInputGatewayLabel6.set_margin_top(15) - - self.ipInputAddressEntry6 = Gtk.Entry() - self.ipInputAddressEntry6.set_margin_start(15) - self.ipInputAddressEntry6.connect("key-release-event", self.entry_trigger_save_button) - self.ipInputMaskEntry6 = Gtk.Entry() - self.ipInputMaskEntry6.connect("key-release-event", self.entry_trigger_save_button) - self.ipInputGatewayEntry6 = Gtk.Entry() - self.ipInputGatewayEntry6.set_margin_end(15) - self.ipInputGatewayEntry6.connect("key-release-event", self.entry_trigger_save_button) - - ipInputBox6 = Gtk.Box(orientation=0, spacing=0) - ipInputBox6.set_homogeneous(True) - ipInputBox6.pack_start(ipInputAddressLabel6, False, False, 0) - ipInputBox6.pack_start(ipInputMaskLabel6, False, False, 0) - ipInputBox6.pack_start(ipInputGatewayLabel6, False, False, 0) - - ipEntryBox6 = Gtk.Box(orientation=0, spacing=30) - ipEntryBox6.pack_start(self.ipInputAddressEntry6, False, False, 0) - ipEntryBox6.pack_start(self.ipInputMaskEntry6, False, False, 0) - ipEntryBox6.pack_start(self.ipInputGatewayEntry6, False, False, 0) + ip_input_address_label6 = Gtk.Label(label="Address") + ip_input_address_label6.set_margin_top(15) + + ip_input_mask_label6 = Gtk.Label(label="Prefix Length") + ip_input_mask_label6.set_margin_top(15) + + ip_input_gateway_label6 = Gtk.Label(label="Gateway") + ip_input_gateway_label6.set_margin_top(15) + + self.ip_input_address_entry6 = Gtk.Entry() + self.ip_input_address_entry6.set_margin_start(15) + self.ip_input_address_entry6.connect("key-release-event", self.entry_trigger_save_button) + self.ip_input_mask_entry6 = Gtk.Entry() + self.ip_input_mask_entry6.connect("key-release-event", self.entry_trigger_save_button) + self.ip_input_gateway_entry6 = Gtk.Entry() + self.ip_input_gateway_entry6.set_margin_end(15) + self.ip_input_gateway_entry6.connect("key-release-event", self.entry_trigger_save_button) + + ip_input_box6 = Gtk.Box(orientation=0, spacing=0) + ip_input_box6.set_homogeneous(True) + ip_input_box6.pack_start(ip_input_address_label6, False, False, 0) + ip_input_box6.pack_start(ip_input_mask_label6, False, False, 0) + ip_input_box6.pack_start(ip_input_gateway_label6, False, False, 0) + + ip_entry_box6 = Gtk.Box(orientation=0, spacing=30) + ip_entry_box6.pack_start(self.ip_input_address_entry6, False, False, 0) + ip_entry_box6.pack_start(self.ip_input_mask_entry6, False, False, 0) + ip_entry_box6.pack_start(self.ip_input_gateway_entry6, False, False, 0) # Add DNS Server Settings - prymary_dns_Label6 = Gtk.Label(label="Primary DNS Servers: ") - prymary_dns_Label6.set_margin_top(15) - prymary_dns_Label6.set_margin_end(58) - prymary_dns_Label6.set_margin_start(30) + prymary_dns_label6 = Gtk.Label(label="Primary DNS Servers: ") + prymary_dns_label6.set_margin_top(15) + prymary_dns_label6.set_margin_end(58) + prymary_dns_label6.set_margin_start(30) - secondary_dns_Label6 = Gtk.Label(label="Secondary DNS Servers: ") - secondary_dns_Label6.set_margin_top(15) - secondary_dns_Label6.set_margin_end(58) - secondary_dns_Label6.set_margin_start(30) + secondary_dns_label6 = Gtk.Label(label="Secondary DNS Servers: ") + secondary_dns_label6.set_margin_top(15) + secondary_dns_label6.set_margin_end(58) + secondary_dns_label6.set_margin_start(30) - self.prymary_dnsEntry6 = Gtk.Entry() - self.prymary_dnsEntry6.set_margin_end(30) - self.prymary_dnsEntry6.connect("key-release-event", self.entry_trigger_save_button) + self.prymary_dns_entry6 = Gtk.Entry() + self.prymary_dns_entry6.set_margin_end(30) + self.prymary_dns_entry6.connect("key-release-event", self.entry_trigger_save_button) - dnsEntryBox6 = Gtk.Box(orientation=0, spacing=0) - dnsEntryBox6.pack_start(prymary_dns_Label6, False, False, 0) - dnsEntryBox6.pack_end(self.prymary_dnsEntry6, True, True, 0) + dns_entry_box6 = Gtk.Box(orientation=0, spacing=0) + dns_entry_box6.pack_start(prymary_dns_label6, False, False, 0) + dns_entry_box6.pack_end(self.prymary_dns_entry6, True, True, 0) # Add Search Domain Settings - searchLabel6 = Gtk.Label(label="Search domains: ") - searchLabel6.set_margin_top(15) - searchLabel6.set_margin_end(30) - searchLabel6.set_margin_start(30) + search_label6 = Gtk.Label(label="Search domains: ") + search_label6.set_margin_top(15) + search_label6.set_margin_end(30) + search_label6.set_margin_start(30) - self.searchEntry6 = Gtk.Entry() - self.searchEntry6.set_margin_top(21) - self.searchEntry6.set_margin_end(30) - self.searchEntry6.set_margin_bottom(30) - self.searchEntry6.connect("key-release-event", self.entry_trigger_save_button) + self.search_entry6 = Gtk.Entry() + self.search_entry6.set_margin_top(21) + self.search_entry6.set_margin_end(30) + self.search_entry6.set_margin_bottom(30) + self.search_entry6.connect("key-release-event", self.entry_trigger_save_button) - searchBox6 = Gtk.Box(orientation=0, spacing=0) - searchBox6.pack_start(searchLabel6, False, False, 0) - searchBox6.pack_end(self.searchEntry6, True, True, 0) + search_box6 = Gtk.Box(orientation=0, spacing=0) + search_box6.pack_start(search_label6, False, False, 0) + search_box6.pack_end(self.search_entry6, True, True, 0) # Set initial sensitivity based on current method (SLAAC = disabled, Manual = enabled) - manual_enabled = (self.method6 == "Manual") - self.ipInputAddressEntry6.set_sensitive(manual_enabled) - self.ipInputMaskEntry6.set_sensitive(manual_enabled) - self.ipInputGatewayEntry6.set_sensitive(manual_enabled) - self.prymary_dnsEntry6.set_sensitive(manual_enabled) - self.searchEntry6.set_sensitive(manual_enabled) - - gridOne6 = Gtk.Grid() - gridOne6.set_column_homogeneous(True) - gridOne6.set_row_homogeneous(False) - gridOne6.set_column_spacing(5) - gridOne6.set_row_spacing(10) - gridOne6.attach(interfaceBox6, 0, 0, 4, 1) - gridOne6.attach(radioBox6, 0, 1, 4, 1) - gridOne6.attach(ipInputBox6, 0, 2, 4, 1) - gridOne6.attach(ipEntryBox6, 0, 3, 4, 1) - gridOne6.attach(dnsEntryBox6, 0, 4, 4, 1) - gridOne6.attach(searchBox6, 0, 5, 4, 1) + manual_enabled = self.method6 == "Manual" + self.ip_input_address_entry6.set_sensitive(manual_enabled) + self.ip_input_mask_entry6.set_sensitive(manual_enabled) + self.ip_input_gateway_entry6.set_sensitive(manual_enabled) + self.prymary_dns_entry6.set_sensitive(manual_enabled) + self.search_entry6.set_sensitive(manual_enabled) + + grid_one6 = Gtk.Grid() + grid_one6.set_column_homogeneous(True) + grid_one6.set_row_homogeneous(False) + grid_one6.set_column_spacing(5) + grid_one6.set_row_spacing(10) + grid_one6.attach(interface_box6, 0, 0, 4, 1) + grid_one6.attach(radio_box6, 0, 1, 4, 1) + grid_one6.attach(ip_input_box6, 0, 2, 4, 1) + grid_one6.attach(ip_entry_box6, 0, 3, 4, 1) + grid_one6.attach(dns_entry_box6, 0, 4, 4, 1) + grid_one6.attach(search_box6, 0, 5, 4, 1) # Build Notebook @@ -404,125 +445,139 @@ def __init__(self, selected_nic=None): # nb.set_sensitive(False) # Build Save & Cancel Buttons - self.saveButton = Gtk.Button(label="Save") - self.saveButton.set_margin_bottom(10) - self.saveButton.set_margin_start(10) - self.saveButton.connect("clicked", self.commit_pending_changes) - self.saveButton.set_sensitive(False) - cancelButton = Gtk.Button(label="Cancel") - cancelButton.set_margin_bottom(10) - cancelButton.connect("clicked", self.discard_pending_changes) - buttonsWindow = Gtk.Box(orientation=0, spacing=10) - buttonsWindow.pack_start(self.saveButton, False, False, 0) - buttonsWindow.pack_start(cancelButton, False, False, 0) + self.save_button = Gtk.Button(label="Save") + self.save_button.set_margin_bottom(10) + self.save_button.set_margin_start(10) + self.save_button.connect("clicked", self.commit_pending_changes) + self.save_button.set_sensitive(False) + cancel_button = Gtk.Button(label="Cancel") + cancel_button.set_margin_bottom(10) + cancel_button.connect("clicked", self.discard_pending_changes) + buttons_window = Gtk.Box(orientation=0, spacing=10) + buttons_window.pack_start(self.save_button, False, False, 0) + buttons_window.pack_start(cancel_button, False, False, 0) # Apply Tab 1 content and formatting to the notebook - nb.append_page(gridOne) - nb.set_tab_label_text(gridOne, "IPv4 Settings") + nb.append_page(grid_one) + nb.set_tab_label_text(grid_one, "IPv4 Settings") # Apply Tab 2 content and formatting to the notebook - nb.append_page(gridOne6) - nb.set_tab_label_text(gridOne6, "IPv6 Settings") + nb.append_page(grid_one6) + nb.set_tab_label_text(grid_one6, "IPv6 Settings") # Put all the widgets together into one window - mainBox = Gtk.Box(orientation=1, spacing=0) - mainBox.pack_start(nb, True, True, 0) - mainBox.pack_end(buttonsWindow, False, False, 0) - self.add(mainBox) + main_box = Gtk.Box(orientation=1, spacing=0) + main_box.pack_start(nb, True, True, 0) + main_box.pack_end(buttons_window, False, False, 0) + self.add(main_box) # Used with the combo box to refresh the UI of tab 1 with active settings # for the newly selected active interface. - def cbox_config_refresh(self, widget, nics): - # actions here need to refresh the values on the first two tabs. - selected_nic = nics[widget.get_active()] - self.currentSettings = get_interface_settings(selected_nic) - self.currentSettings6 = get_interface_settings_ipv6(selected_nic) + def cbox_config_refresh(self, widget): + """Reload both tabs for the interface just chosen in the combo box. + + Args: + widget (Gtk.ComboBox): the interface combo box. + """ + selected_nic = self.nics[widget.get_active()] + self.current_settings = get_interface_settings(selected_nic) + self.current_settings6 = get_interface_settings_ipv6(selected_nic) self.update_interface_settings() self.update_interface_settings_ipv6() def update_interface_settings(self): - self.ipInputAddressEntry.set_text(self.currentSettings["Interface IP"]) - self.ipInputMaskEntry.set_text(self.currentSettings["Interface Subnet Mask"]) - self.ipInputGatewayEntry.set_text(self.currentSettings["Default Gateway"]) - self.prymary_dnsEntry.set_text(self.currentSettings["DNS Server 1"]) - self.secondary_dnsEntry.set_text(self.currentSettings["DNS Server 2"]) - self.searchEntry.set_text(self.currentSettings["Search Domain"]) - if self.currentSettings["Assignment Method"] == "DHCP": + """Fill the IPv4 tab's widgets from the current settings.""" + self.ip_input_address_entry.set_text(self.current_settings["Interface IP"]) + self.ip_input_mask_entry.set_text(self.current_settings["Interface Subnet Mask"]) + self.ip_input_gateway_entry.set_text(self.current_settings["Default Gateway"]) + self.prymary_dns_entry.set_text(self.current_settings["DNS Server 1"]) + self.secondary_dns_entry.set_text(self.current_settings["DNS Server 2"]) + self.search_entry.set_text(self.current_settings["Search Domain"]) + if self.current_settings["Assignment Method"] == "DHCP": self.rb_dhcp4.set_active(True) else: self.rb_manual4.set_active(True) def update_interface_settings_ipv6(self): - self.ipInputAddressEntry6.set_text(self.currentSettings6.get("Interface IPv6", "")) - self.ipInputMaskEntry6.set_text(str(self.currentSettings6.get("Prefix Length", "64"))) - self.ipInputGatewayEntry6.set_text(self.currentSettings6.get("Default Gateway", "")) - self.prymary_dnsEntry6.set_text(self.currentSettings6.get("DNS Server 1", "")) - self.searchEntry6.set_text(self.currentSettings6.get("Search Domain", "")) - self.method6 = self.currentSettings6.get("Assignment Method", "SLAAC") + """Fill the IPv6 tab's widgets from the current settings.""" + self.ip_input_address_entry6.set_text(self.current_settings6.get("Interface IPv6", "")) + self.ip_input_mask_entry6.set_text(str(self.current_settings6.get("Prefix Length", "64"))) + self.ip_input_gateway_entry6.set_text(self.current_settings6.get("Default Gateway", "")) + self.prymary_dns_entry6.set_text(self.current_settings6.get("DNS Server 1", "")) + self.search_entry6.set_text(self.current_settings6.get("Search Domain", "")) + self.method6 = self.current_settings6.get("Assignment Method", "SLAAC") if self.method6 == "Manual": self.rb_manual6.set_active(True) - self.ipInputAddressEntry6.set_sensitive(True) - self.ipInputMaskEntry6.set_sensitive(True) - self.ipInputGatewayEntry6.set_sensitive(True) - self.prymary_dnsEntry6.set_sensitive(True) - self.searchEntry6.set_sensitive(True) + self.ip_input_address_entry6.set_sensitive(True) + self.ip_input_mask_entry6.set_sensitive(True) + self.ip_input_gateway_entry6.set_sensitive(True) + self.prymary_dns_entry6.set_sensitive(True) + self.search_entry6.set_sensitive(True) else: self.rb_slaac6.set_active(True) - self.ipInputAddressEntry6.set_sensitive(False) - self.ipInputMaskEntry6.set_sensitive(False) - self.ipInputGatewayEntry6.set_sensitive(False) - self.prymary_dnsEntry6.set_sensitive(False) - self.searchEntry6.set_sensitive(False) - - def commit_pending_changes(self, widget): + self.ip_input_address_entry6.set_sensitive(False) + self.ip_input_mask_entry6.set_sensitive(False) + self.ip_input_gateway_entry6.set_sensitive(False) + self.prymary_dns_entry6.set_sensitive(False) + self.search_entry6.set_sensitive(False) + + def commit_pending_changes(self, _widget): + """Hide the window and apply the settings on the GTK idle handler. + + Args: + _widget (Gtk.Widget): the Save button, unused. + """ self.hide_window() GLib.idle_add(self.update_system) def update_system(self): - nic = self.currentSettings["Active Interface"] - inet = self.ipInputAddressEntry.get_text() - netmask = self.ipInputMaskEntry.get_text() - defaultrouter = self.ipInputGatewayEntry.get_text() + """Write the chosen IPv4 and IPv6 settings and apply them. + + rc.conf is updated through sysrc, /etc/resolv.conf is rewritten for + a manual configuration, and the interface is restarted so the new + settings take effect straight away. + """ + nic = self.current_settings["Active Interface"] + inet = self.ip_input_address_entry.get_text() + netmask = self.ip_input_mask_entry.get_text() + defaultrouter = self.ip_input_gateway_entry.get_text() if self.method == 'Manual': if 'wlan' in nic: - ifconfig_nic = f'ifconfig_{nic}="WPA inet {inet} netmask {netmask}"\n' + ifconfig_value = f'WPA inet {inet} netmask {netmask}' else: - ifconfig_nic = f'ifconfig_{nic}="inet {inet} netmask {netmask}"\n' - self.update_rc_conf(ifconfig_nic) - defaultrouter_line = f'defaultrouter="{defaultrouter}"\n' - self.update_rc_conf(defaultrouter_line) + ifconfig_value = f'inet {inet} netmask {netmask}' + self.set_rc_conf(f'ifconfig_{nic}', ifconfig_value) + self.set_rc_conf('defaultrouter', defaultrouter) start_static_network(nic, inet, netmask) - resolv_conf = open('/etc/resolv.conf', 'w') - resolv_conf.writelines('# Generated by NetworkMgr\n') - search = self.searchEntry.get_text() - if search: - search_line = f'search {search}\n' - resolv_conf.writelines(search_line) - dns1 = self.prymary_dnsEntry.get_text() - nameserver1_line = f'nameserver {dns1}\n' - resolv_conf.writelines(nameserver1_line) - dns2 = self.secondary_dnsEntry.get_text() - if dns2: - nameserver2_line = f'nameserver {dns2}\n' - resolv_conf.writelines(nameserver2_line) - resolv_conf.close() + with open('/etc/resolv.conf', 'w', encoding='utf-8') as resolv_conf: + resolv_conf.writelines('# Generated by NetworkMgr\n') + search = self.search_entry.get_text() + if search: + search_line = f'search {search}\n' + resolv_conf.writelines(search_line) + dns1 = self.prymary_dns_entry.get_text() + nameserver1_line = f'nameserver {dns1}\n' + resolv_conf.writelines(nameserver1_line) + dns2 = self.secondary_dns_entry.get_text() + if dns2: + nameserver2_line = f'nameserver {dns2}\n' + resolv_conf.writelines(nameserver2_line) else: - if 'wlan' in nic: - ifconfig_nic = f'ifconfig_{nic}="WPA DHCP"\n' - else: - ifconfig_nic = f'ifconfig_{nic}="DHCP"\n' - self.update_rc_conf(ifconfig_nic) + self.set_rc_conf(f'ifconfig_{nic}', + 'WPA DHCP' if 'wlan' in nic else 'DHCP') - rc_conf = open('/etc/rc.conf', 'r').read() - for nic_search in self.NICS: + with open('/etc/rc.conf', 'r', encoding='utf-8') as rc_conf_file: + rc_conf = rc_conf_file.read() + for nic_search in self.nics: if re.search(f'^ifconfig_{nic_search}=".*inet', rc_conf, re.MULTILINE): break else: - defaultrouter_line = f'defaultrouter="{defaultrouter}"\n' - self.remove_rc_conf_line(defaultrouter_line) + # Nothing static left, so dhclient takes the default + # route back. + self.remove_rc_conf_var('defaultrouter') restart_card_network(nic) # sometimes the inet address isn't available immediately after dhcp is enabled. start_static_network(nic, inet, netmask) - wait_inet(nic) + wait_for_address(nic) restart_routing_and_dhcp(nic) # Apply IPv6 configuration @@ -532,18 +587,18 @@ def update_system(self): def update_system_ipv6(self, nic): """Apply IPv6 configuration changes.""" - inet6 = self.ipInputAddressEntry6.get_text() - prefixlen = self.ipInputMaskEntry6.get_text() or "64" - gateway6 = self.ipInputGatewayEntry6.get_text() - dns6 = self.prymary_dnsEntry6.get_text() + inet6 = self.ip_input_address_entry6.get_text() + prefixlen = self.ip_input_mask_entry6.get_text() or "64" + gateway6 = self.ip_input_gateway_entry6.get_text() + dns6 = self.prymary_dns_entry6.get_text() if self.method6 == 'Manual': # Static IPv6 configuration - ifconfig_ipv6 = f'ifconfig_{nic}_ipv6="inet6 {inet6} prefixlen {prefixlen}"' - self.update_rc_conf(ifconfig_ipv6) + self.set_rc_conf(f'ifconfig_{nic}_ipv6', + f'inet6 {inet6} prefixlen {prefixlen}') # Disable rtsold for static configuration - self.update_rc_conf('rtsold_enable="NO"') + self.set_rc_conf('rtsold_enable', 'NO') # Apply the static IPv6 address if inet6: @@ -558,82 +613,101 @@ def update_system_ipv6(self, nic): else: gateway6_full = gateway6 # Save with interface suffix in rc.conf for persistence - self.update_rc_conf(f'ipv6_defaultrouter="{gateway6_full}"') + self.set_rc_conf('ipv6_defaultrouter', gateway6_full) # Apply gateway immediately - run('route delete -inet6 default 2>/dev/null', shell=True) - run(f'route add -inet6 default {gateway6_full}', shell=True) + run(['route', 'delete', '-inet6', 'default'], + stderr=DEVNULL, check=False) + run(['route', 'add', '-inet6', 'default', gateway6_full], + check=False) # Add IPv6 DNS to resolv.conf if provided if dns6: self.add_ipv6_dns(dns6) else: # SLAAC configuration - ifconfig_ipv6 = f'ifconfig_{nic}_ipv6="inet6 accept_rtadv"' - self.update_rc_conf(ifconfig_ipv6) + self.set_rc_conf(f'ifconfig_{nic}_ipv6', 'inet6 accept_rtadv') # Enable rtsold for SLAAC - self.update_rc_conf('rtsold_enable="YES"') + self.set_rc_conf('rtsold_enable', 'YES') - # Remove static IPv6 gateway if switching to SLAAC - try: - self.remove_rc_conf_var('ipv6_defaultrouter') - except Exception: - pass # Variable may not exist + # SLAAC supplies the gateway. sysrc -x on an unset variable + # is harmless. + self.remove_rc_conf_var('ipv6_defaultrouter') # Enable SLAAC enable_slaac(nic) def add_ipv6_dns(self, dns6): - """Add IPv6 DNS server to resolv.conf without removing existing entries.""" + """Add an IPv6 nameserver to resolv.conf, keeping the existing ones. + + Args: + dns6 (str): the IPv6 nameserver address to add. Nothing is + written if resolv.conf already lists it. + """ resolv_path = '/etc/resolv.conf' - try: - with open(resolv_path, 'r') as f: - content = f.read() - # Check if this IPv6 DNS is already present - if f'nameserver {dns6}' not in content: - with open(resolv_path, 'a') as f: - f.write(f'nameserver {dns6}\n') - except Exception: - pass # resolv.conf may be managed by dhclient + with open(resolv_path, 'r', encoding='utf-8') as resolv_file: + content = resolv_file.read() + if f'nameserver {dns6}' not in content: + with open(resolv_path, 'a', encoding='utf-8') as resolv_file: + resolv_file.write(f'nameserver {dns6}\n') def remove_rc_conf_var(self, varname): - """Remove a variable from rc.conf using sysrc.""" - run(f'sysrc -x {varname}', shell=True) + """Remove a variable from rc.conf using sysrc. + + Args: + varname (str): the rc.conf variable to unset. + """ + run(['sysrc', '-x', varname], check=False) def hide_window(self): + """Hide the window. + + Returns: + bool: False, so GLib.idle_add does not call this again. + """ self.hide() return False - def discard_pending_changes(self, widget): + def discard_pending_changes(self, _widget): + """Close the window without applying anything. + + Args: + _widget (Gtk.Widget): the Cancel button, unused. + """ self.destroy() - def update_rc_conf(self, line): - run(f'sysrc {line}', shell=True) - - def remove_rc_conf_line(self, line): - try: - with open('/etc/rc.conf', "r+") as rc_conf: - lines = rc_conf.readlines() - if line in lines: - rc_conf.seek(0) - idx = lines.index(line) - lines.pop(idx) - rc_conf.truncate() - rc_conf.writelines(lines) - except (ValueError, FileNotFoundError): - pass # Line doesn't exist, nothing to remove - - -def network_card_configuration(default_int): - win = netCardConfigWindow(default_int) + def set_rc_conf(self, name, value): + """Set one rc.conf variable through sysrc. + + The name and the value are passed as a single argument rather than + built into a shell command, so a value holding spaces needs no + quoting and a value holding a quote or a semicolon cannot run + anything. sysrc adds the quotes when it writes the file. + + Args: + name (str): the rc.conf variable, for instance ifconfig_em0. + value (str): its value, for instance "inet 10.0.0.2 netmask + 255.255.255.0". Spaces are fine. + """ + run(['sysrc', f'{name}={value}'], check=False) + + +def open_configuration(default_int): + """Open the configuration window for one interface and run GTK. + + Args: + default_int (str): the interface to select when the window opens. + """ + win = NetCardConfigWindow(default_int) win.connect("destroy", Gtk.main_quit) win.show_all() win.set_keep_above(True) Gtk.main() -def network_card_configuration_window(): - win = netCardConfigWindow() +def open_default_configuration(): + """Open the configuration window on the default interface and run GTK.""" + win = NetCardConfigWindow() win.connect("destroy", Gtk.main_quit) win.show_all() win.set_keep_above(True) diff --git a/NetworkMgr/net_api.py b/NetworkMgr/net_api.py index b4416c6..081aa9d 100755 --- a/NetworkMgr/net_api.py +++ b/NetworkMgr/net_api.py @@ -1,9 +1,26 @@ #!/usr/bin/env python -from subprocess import Popen, PIPE, run, check_output +"""Network operations backing the tray icon and the configuration window. + +Everything that reads or changes the system's network state lives here: +interface enumeration and status, the wpa_supplicant control-socket layer +(scanning, saved networks, connection state), the wpa_supplicant.conf +readers and writers, and the wired and wireless bring-up commands. Nothing +in this module touches GTK. +""" + +from concurrent.futures import ThreadPoolExecutor +from subprocess import ( + DEVNULL, + CalledProcessError, + TimeoutExpired, + run, + check_output +) import os import re -from time import sleep +from string import hexdigits +from time import monotonic, sleep # EAP methods supported for enterprise WPA @@ -12,144 +29,160 @@ # Phase 2 (inner) authentication methods PHASE2_METHODS = ['MSCHAPV2', 'GTC', 'PAP', 'CHAP', 'MD5'] -# Default CA certificate path on FreeBSD/GhostBSD -DEFAULT_CA_CERT = '/etc/ssl/certs/ca-root-nss.crt' +# Matches a dotted-quad IPv4 address in ifconfig output. +IP_REGEX = r'[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' +# Threads for one refresh pass. They wait on subprocesses, not the CPU. +_REFRESH_WORKERS = 6 -def card_online(netcard): - lan = Popen('ifconfig ' + netcard, shell=True, stdout=PIPE, - universal_newlines=True) - return 'inet ' in lan.stdout.read() +def _run(*args, check=False): + """ + Run a command without a shell and return the finished process. -def defaultcard(): - cmd = "netstat -rn | grep default" - nics = Popen(cmd, shell=True, stdout=PIPE, universal_newlines=True) - device = nics.stdout.readlines() - if len(device) == 0: - return None - else: - return list(filter(None, device[0].rstrip().split()))[3] + Args: + *args (str): the program and its arguments, one word per argument. + check (bool): raise CalledProcessError on a non-zero exit. Left False + where a non-zero exit is a normal answer, such as asking about an + interface that is not there. + Returns: + subprocess.CompletedProcess: with stdout and stderr captured as text. + """ + return run(args, capture_output=True, text=True, check=check) -def ifWlanDisable(wificard): - cmd = "ifconfig %s list scan" % wificard - nics = Popen(cmd, shell=True, stdout=PIPE, universal_newlines=True) - return not nics.stdout.read() +def _ifconfig(card): + """ + Return the ifconfig output for one interface. -def ifStatue(wificard): - cmd = "ifconfig %s" % wificard - wl = Popen(cmd, shell=True, stdout=PIPE, universal_newlines=True) - wlout = wl.stdout.read() - return "associated" in wlout + Args: + card (str): interface name, for instance em0 or wlan0. + Returns: + str: everything ifconfig printed, or an empty string when the + interface does not exist. + """ + return _run('ifconfig', card).stdout -def get_ssid(wificard): - wlan = Popen('ifconfig %s | grep ssid' % wificard, - shell=True, stdout=PIPE, universal_newlines=True) - # If there are quotation marks in the string, use that as a separator, - # otherwise use the default whitespace. This is to handle ssid strings - # with spaces in them. These ssid strings will be double-quoted by ifconfig - temp = wlan.stdout.readlines()[0].rstrip() - if '"' in temp: - out = temp.split('"')[1] - else: - out = temp.split()[1] - return out +def ifconfig_snapshot(): + """Read every interface's ifconfig output in a single call. -def nics_list(): - not_nics_regex = r"(enc|lo|fwe|fwip|tap|plip|pfsync|pflog|ipfw|tun|sl|" \ - r"faith|ppp|bridge|wg)[0-9]+(\s*)|vm-[a-z]+(\s*)" - nics = Popen( - 'ifconfig -l', - shell=True, - stdout=PIPE, - universal_newlines=True - ).stdout.read().strip() - return sorted(re.sub(not_nics_regex, '', nics).strip().split()) + One call, so every interface's state comes from the same instant. + Returns: + dict[str, str]: interface name mapped to its block of output. + """ + return { + match.group(1): match.group(0) + for match in re.finditer(r'^(\w+):.*?(?=^\w+:|\Z)', + _run('ifconfig').stdout, re.M | re.S) + } -def ifcardconnected(netcard): - wifi = Popen('ifconfig ' + netcard, shell=True, stdout=PIPE, - universal_newlines=True) - return 'status: active' in wifi.stdout.read() +def card_has_address(ifconfig_text): + """ + Report whether an interface has an IPv4 address. -def barpercent(sn): - sig = int(sn.partition(':')[0]) - noise = int(sn.partition(':')[2]) - return int((sig - noise) * 4) + Args: + ifconfig_text (str): that interface's block from ifconfig_snapshot(). + + Returns: + bool: True when ifconfig lists an inet address on the interface. + """ + return 'inet ' in ifconfig_text -def is_enterprise_network(caps_string): +def default_card(): """ - Detect if a network uses WPA-Enterprise (802.1X/EAP) authentication. + Return the interface that carries the default route. - FreeBSD ifconfig scan doesn't explicitly distinguish PSK from EAP. - We use heuristics based on capability flags: + The routing table is filtered here rather than piped through grep, which + is what used to require a shell. + + Returns: + str or None: the interface name, or None when there is no default + route at all. + """ + for line in _run('netstat', '-rn').stdout.splitlines(): + if line.startswith('default'): + fields = line.split() + if len(fields) > 3: + return fields[3] + return None - 1. Explicit EAP indicators (if present) - 2. RSN without WPS AND without typical home router flags (HTCAP, VHTCAP, ATH) - suggests a minimal enterprise AP configuration - Note: This is imperfect - some networks may be misdetected. +def _is_disabled(ifconfig_text): """ - caps_upper = caps_string.upper() + Report whether an interface has been administratively downed. - # Explicit enterprise indicators (highest confidence) - enterprise_indicators = ['EAP', '802.1X', 'WPA2-EAP', 'WPA-EAP', 'RSN-EAP'] - for indicator in enterprise_indicators: - if indicator in caps_upper: - return True + The UP flag is the only reliable evidence. An empty scan list does not + mean the card is off (net80211 flushes the cache on INIT), and a wired + `status:` line describes the link, not the interface. - # Heuristic: RSN without WPS and without typical consumer router features - # Enterprise APs often have minimal beacon flags - has_rsn = 'RSN' in caps_upper - has_wps = 'WPS' in caps_upper - has_consumer_features = any(f in caps_upper for f in ['HTCAP', 'VHTCAP', 'ATH', 'WME']) + Args: + ifconfig_text (str): that interface's block from ifconfig_snapshot(). - # Only flag as enterprise if: has RSN, no WPS, and no typical consumer features - if has_rsn and not has_wps and not has_consumer_features: - return True + Returns: + bool: True when the UP flag is absent. False when the interface has + no flags line at all, which means it is not there to be disabled. + """ + flags = re.search(r'flags=\w+<([^>]*)>', ifconfig_text) + if flags is None: + return False + return 'UP' not in flags.group(1).split(',') - return False +def nics_list(snapshot=None): + """ + List the interfaces networkmgr manages. + + Args: + snapshot (dict or None): an ifconfig_snapshot() result to take the + names from. Left None by callers that have no snapshot to hand, + which then costs one `ifconfig -l`. -def get_security_type(caps_string): + Returns: + list[str]: sorted interface names, with the virtual and tunnel + devices such as lo, bridge, tun and wg removed. """ - Determine the security type of a wireless network. - Returns: 'OPEN', 'WEP', 'WPA-PSK', 'WPA2-PSK', 'WPA-EAP', 'WPA2-EAP' + not_nics_regex = r"(enc|lo|fwe|fwip|tap|plip|pfsync|pflog|ipfw|tun|sl|" \ + r"faith|ppp|bridge|wg)[0-9]+(\s*)|vm-[a-z]+(\s*)" + if snapshot is None: + nics = _run('ifconfig', '-l').stdout.strip() + else: + nics = ' '.join(snapshot) + return sorted(re.sub(not_nics_regex, '', nics).strip().split()) + + +def card_has_carrier(ifconfig_text): """ - caps_upper = caps_string.upper() - if is_enterprise_network(caps_string): - if 'RSN' in caps_upper or 'WPA2' in caps_upper: - return 'WPA2-EAP' - return 'WPA-EAP' - elif 'RSN' in caps_upper: - return 'WPA2-PSK' - elif 'WPA' in caps_upper: - return 'WPA-PSK' - elif 'WEP' in caps_upper or 'PRIVACY' in caps_upper: - return 'WEP' - return 'OPEN' + Report whether a wired interface has a cable in it. + Args: + ifconfig_text (str): that interface's block from ifconfig_snapshot(). -def validate_certificate(cert_path): + Returns: + bool: True when ifconfig reports `status: active`, which is carrier + and not the same question as whether the card has an address. """ - Validate that a certificate file exists and is readable. - Returns tuple (is_valid, error_message). + return 'status: active' in ifconfig_text + + +def bar_percent(level, noise): + """ + Turn a signal and noise pair into the percentage the tray shows. + + Args: + level (int): received signal strength in dBm, a negative number. + noise (int): the radio's noise floor in dBm, also negative. + + Returns: + int: a percentage, deliberately not clamped, matching the old + ifconfig S:N arithmetic so the icons pick the same buckets. """ - if not cert_path: - return False, "No certificate path provided" - if not os.path.exists(cert_path): - return False, f"Certificate file not found: {cert_path}" - if not os.path.isfile(cert_path): - return False, f"Not a file: {cert_path}" - if not os.access(cert_path, os.R_OK): - return False, f"Certificate file not readable: {cert_path}" - return True, None + return int((level - noise) * 4) def get_system_ca_certificates(): @@ -168,398 +201,1156 @@ def get_system_ca_certificates(): return available -def network_service_state(): - return False +def connected_signal(wifi_card): + """ + Signal percentage of the access point this card is joined to. + Reads the one BSS the card is on, so this does not depend on the scan + table. -def networkdictionary(): - nlist = nics_list() - maindictionary = { - 'service': network_service_state(), - 'default': defaultcard() - } - cards = {} - for card in nlist: - if 'wlan' in card: - scanv = f"ifconfig {card} list scan | grep -va BSSID" - wifi = Popen(scanv, shell=True, stdout=PIPE, - universal_newlines=True) - connectioninfo = {} - for line in wifi.stdout: - # don't sort empty ssid - # Window, MacOS and Linux does not show does - if line.startswith(" " * 5): - continue - ssid = line[:33].strip() - info = line[:83][33:].strip().split() - percentage = barpercent(info[3]) - # if ssid exist and percentage is higher keep it - # else add the new one if percentage is higher - if ssid in connectioninfo: - if connectioninfo[ssid][4] > percentage: - continue - info[3] = percentage - info.insert(0, ssid) - # append left over - caps_string = line[83:].strip() - info.append(caps_string) - # Add security type info (index 7) - info.append(get_security_type(caps_string)) - # Add enterprise flag (index 8) - info.append(is_enterprise_network(caps_string)) - connectioninfo[ssid] = info - if ifWlanDisable(card): - connectionstat = { - "connection": "Disabled", - "ssid": None, - } - elif not ifStatue(card): - connectionstat = { - "connection": "Disconnected", - "ssid": None, + Args: + wifi_card (str): wireless interface name. + + Returns: + int or None: the percentage, or None when the card is not joined to + anything or the driver reports no level. + """ + bss = _parse_key_values(wpa_cli(wifi_card, 'bss', 'current') or '') + if bss.get('level') and bss.get('noise'): + return bar_percent(int(bss['level']), int(bss['noise'])) + return None + + +def _wireless_state(ifconfig_text, status): + """ + Describe what a wireless interface is doing. + + Connected means wpa_state=COMPLETED, the handshake finished. ifconfig's + `associated` appears about three seconds earlier, before the card can + pass traffic. + + Args: + ifconfig_text (str): that interface's block from ifconfig_snapshot(). + status (dict): the wpa_status() result for the same interface, passed + in so the daemon is asked once per refresh. + + Returns: + dict: with 'connection' set to Disabled, Disconnected or Connected, + and 'ssid' set only when connected. + """ + if _is_disabled(ifconfig_text): + return {'connection': 'Disabled', 'ssid': None} + if status.get('wpa_state') == 'COMPLETED': + return {'connection': 'Connected', 'ssid': status.get('ssid')} + return {'connection': 'Disconnected', 'ssid': None} + + +def _wired_state(ifconfig_text): + """ + Describe what a wired interface is doing. + + Args: + ifconfig_text (str): that interface's block from ifconfig_snapshot(). + + Returns: + dict: with 'connection' set to Disabled when we downed the interface, + Unplug when there is no cable, Connected when it holds an + address, and Disconnected when it has carrier but no address. + """ + if _is_disabled(ifconfig_text): + return {'connection': 'Disabled'} + if not card_has_carrier(ifconfig_text): + return {'connection': 'Unplug'} + if card_has_address(ifconfig_text): + return {'connection': 'Connected'} + return {'connection': 'Disconnected'} + + +def network_dictionary(): + """ + Collect everything the tray menu draws itself from. + + Every command runs here once and the results are handed down, rather + than each reader fetching its own. + + Returns: + dict: 'default' names the interface holding the default route. + 'cards' maps each interface to 'state' (see _wireless_state and + _wired_state), 'signal' for signal, and the raw 'ifconfig' and + 'status' the state came from. No networks in range: that is + scan_networks(), called when the submenu opens. + """ + # Two waves: netstat runs alongside ifconfig, but the daemon queries + # cannot start until ifconfig has said which cards are wireless. + with ThreadPoolExecutor(max_workers=_REFRESH_WORKERS) as pool: + default = pool.submit(default_card) + snapshot = ifconfig_snapshot() + + interfaces = nics_list(snapshot) + wireless = [c for c in interfaces if 'wlan' in c] + statuses = {c: pool.submit(wpa_status, c) for c in wireless} + signals = {c: pool.submit(connected_signal, c) for c in wireless} + + cards = {} + for card in interfaces: + ifconfig_text = snapshot.get(card, '') + if card in statuses: + status = statuses[card].result() + state = _wireless_state(ifconfig_text, status) + cards[card] = { + 'state': state, + 'signal': signals[card].result(), + 'ifconfig': ifconfig_text, + 'status': status, } else: - ssid = get_ssid(card) - connectionstat = { - "connection": "Connected", - "ssid": ssid, + cards[card] = { + 'state': _wired_state(ifconfig_text), + 'signal': None, + 'ifconfig': ifconfig_text, + 'status': {}, } - seconddictionary = { - 'state': connectionstat, - 'info': connectioninfo - } - else: - if card_online(card): - connectionstat = {"connection": "Connected"} - elif ifcardconnected(card): - connectionstat = {"connection": "Disconnected"} - else: - connectionstat = {"connection": "Unplug"} - seconddictionary = {'state': connectionstat, 'info': None} - cards[card] = seconddictionary - maindictionary['cards'] = cards - return maindictionary + return {'default': default.result(), 'cards': cards} + + +def _inet_line(ifconfig_text): + """ + Return an interface's IPv4 address line as ifconfig prints it. + + Args: + ifconfig_text (str): that interface's block from ifconfig_snapshot(). + + Returns: + str: the whole `inet ...` line, or an empty string when the interface + has no IPv4 address. + """ + for line in ifconfig_text.splitlines(): + if line.strip().startswith('inet '): + return line.strip() + return '' + +def tray_state(): + """ + Read only what the tray icon and its tooltip show. + + Deliberately narrower than network_dictionary(): one interface, up or + not, and its signal from `bss current` rather than the scan table. -def connectionStatus(card: str, network_info: dict) -> str: + Returns: + dict: 'card' naming the interface holding the default route or None, + 'kind' being 'wifi', 'wire' or None, 'connection' as + _wireless_state or _wired_state reports it, 'ssid' and 'signal' + filled in for a connected wireless card, and 'tooltip' ready to + display. + """ + card = default_card() if card is None: - netstate = "Network card is not enabled" - elif 'wlan' in card: - if not ifWlanDisable(card) and ifStatue(card): - cmd1 = "ifconfig %s | grep ssid" % card - cmd2 = "ifconfig %s | grep 'inet '" % card - out1 = Popen(cmd1, shell=True, stdout=PIPE, universal_newlines=True) - out2 = Popen(cmd2, shell=True, stdout=PIPE, universal_newlines=True) - ssid_info = out1.stdout.read().strip() - inet_info = out2.stdout.read().strip() - ssid = network_info['cards'][card]['state']["ssid"] - percentage = network_info['cards'][card]['info'][ssid][4] - netstate = f"Signal Strength: {percentage}% \n{ssid_info} \n{subnetHexToDec(inet_info)}" - else: - netstate = "WiFi %s not connected" % card + return {'card': None, 'kind': None, 'connection': 'Disconnected', + 'ssid': None, 'signal': None, + 'tooltip': "Network card is not enabled"} + + wireless = 'wlan' in card + if wireless: + # Three independent reads, so they are worth running together. + with ThreadPoolExecutor(max_workers=_REFRESH_WORKERS) as pool: + text_job = pool.submit(_ifconfig, card) + status_job = pool.submit(wpa_status, card) + bss_job = pool.submit(wpa_cli, card, 'bss', 'current') + ifconfig_text = text_job.result() + status = status_job.result() + bss = _parse_key_values(bss_job.result() or '') else: - cmd = "ifconfig %s | grep 'inet '" % card - out = Popen(cmd, shell=True, stdout=PIPE, universal_newlines=True) - line = out.stdout.read().strip() - netstate = subnetHexToDec(line) - return netstate + # A wired card needs one command, and a pool for one job costs more + # than it saves. + ifconfig_text = _ifconfig(card) + status, bss = {}, {} + if wireless: + state = _wireless_state(ifconfig_text, status) + else: + state = _wired_state(ifconfig_text) -def switch_default(nic): - nics = nics_list() - nics.remove(nic) - if not nics: - return - for card in nics: - nic_info = Popen( - ['ifconfig', card], - stdout=PIPE, - close_fds=True, - universal_newlines=True - ).stdout.read() - if 'status: active' in nic_info or 'status: associated' in nic_info: - if 'inet ' in nic_info or 'inet6' in nic_info: - run(f'service dhclient restart {card}', shell=True) - break - return + signal = None + if bss.get('level') and bss.get('noise'): + signal = bar_percent(int(bss['level']), int(bss['noise'])) + inet = subnet_hex_to_dec(_inet_line(ifconfig_text)) + if wireless: + if state['connection'] != 'Connected': + tooltip = f"WiFi {card} not connected" + else: + detail = f"ssid {state['ssid']} bssid {status.get('bssid', '')}" + tooltip = (f"Signal Strength: {signal if signal is not None else 0}% \n" + f"{detail} \n{inet}") + else: + tooltip = inet + + return { + 'card': card, + 'kind': 'wifi' if wireless else 'wire', + 'connection': state['connection'], + 'ssid': state.get('ssid'), + 'signal': signal, + 'tooltip': tooltip, + } -def restart_all_nics(widget): - run('service netif restart', shell=True) +def restart_all_nics(_widget): + """Restart every network interface. -def stopallnetwork(): - run('service netif stop', shell=True) + Args: + _widget (Gtk.Widget): the menu item that fired this, unused. + """ + _run('service', 'netif', 'restart') -def startallnetwork(): - run('service netif start', shell=True) +def stop_network_card(netcard): + """Take one interface down. + dhclient watches the routing socket, sees RTF_UP cleared and exits + through its FAIL path, which deletes the address, deletes the routes and + restores resolv.conf. A static card keeps its address, deliberately, so + that bringing it back up needs nothing but the up. -def stopnetworkcard(netcard): - run(f'service netif stop {netcard}', shell=True) - switch_default(netcard) + Handing the default route to another interface is devd's: the link + going down fires src/auto-switch.py, which does exactly that. + + Args: + netcard (str): interface name, for instance em0. + """ + _run('ifconfig', netcard, 'down') def restart_card_network(netcard): - run(f'service netif restart {netcard}', shell=True) + """Restart one interface through rc. + + Args: + netcard (str): interface name, for instance em0. + """ + _run('service', 'netif', 'restart', netcard) def restart_routing_and_dhcp(netcard): - run('service routing restart', shell=True) - sleep(1) - run(f'service dhclient restart {netcard}', shell=True) + """Rebuild the routing table, then renew the lease on one interface. + + Args: + netcard (str): interface name to restart dhclient on. + """ + _run('service', 'routing', 'restart') + _run('service', 'dhclient', 'restart', netcard) def start_static_network(netcard, inet, netmask): - run(f'ifconfig {netcard} inet {inet} netmask {netmask}', shell=True) - sleep(1) - run('service routing restart', shell=True) + """Put a static IPv4 address on an interface and rebuild the routes. + + Args: + netcard (str): interface name, for instance em0. + inet (str): IPv4 address in dotted-decimal form. + netmask (str): netmask in dotted-decimal form. + """ + _run('ifconfig', netcard, 'inet', inet, 'netmask', netmask) + _run('service', 'routing', 'restart') # IPv6 configuration functions def start_static_ipv6_network(netcard, inet6, prefixlen): - """Configure a static IPv6 address on the given interface.""" - run(f'ifconfig {netcard} inet6 {inet6} prefixlen {prefixlen}', shell=True) - sleep(1) - run('service routing restart', shell=True) + """Configure a static IPv6 address on an interface and rebuild the routes. + + Args: + netcard (str): interface name, for instance em0. + inet6 (str): IPv6 address. + prefixlen (str or int): prefix length, for instance 64. + """ + _run('ifconfig', netcard, 'inet6', inet6, 'prefixlen', str(prefixlen)) + _run('service', 'routing', 'restart') def enable_slaac(netcard): - """Enable SLAAC (Stateless Address Autoconfiguration) on the interface - by toggling accept_rtadv and soliciting router advertisements.""" + """Turn on stateless address autoconfiguration for an interface. + + accept_rtadv is cleared first so the interface starts from a known + state, then set, then router advertisements are solicited. + + Args: + netcard (str): interface name, for instance em0. + """ # First disable, then re-enable to ensure clean state - run(f'ifconfig {netcard} inet6 -accept_rtadv', shell=True) + _run('ifconfig', netcard, 'inet6', '-accept_rtadv') sleep(0.5) # Enable accept_rtadv for SLAAC - run(f'ifconfig {netcard} inet6 accept_rtadv', shell=True) + _run('ifconfig', netcard, 'inet6', 'accept_rtadv') # Start rtsold to solicit router advertisements - run(f'rtsol {netcard}', shell=True) + _run('rtsol', netcard) def disable_slaac(netcard): - """Disable SLAAC on the interface.""" - run(f'ifconfig {netcard} inet6 -accept_rtadv', shell=True) + """Turn off stateless address autoconfiguration for an interface. + + Args: + netcard (str): interface name, for instance em0. + """ + _run('ifconfig', netcard, 'inet6', '-accept_rtadv') + + +def start_network_card(netcard): + """Bring one interface up. + + Nothing else is needed. The link coming up fires devd's IFNET LINK_UP + event, and our action for it, src/link-up.py, starts dhclient, which + installs the address and the routes. + + Args: + netcard (str): interface name, for instance em0. + """ + _run('ifconfig', netcard, 'up') + + +def disconnect_wifi(wifi_card): + """Drop the current wireless association and stay disconnected. + `disconnect` stays disconnected until a network is selected. Downing the + interface does not: the daemon rejoins as soon as it comes back up. -def get_ipv6_addresses(netcard): - """Get all IPv6 addresses configured on the interface. - Returns list of tuples: (address, prefixlen).""" + Args: + wifi_card (str): wireless interface name, for instance wlan0. + + Returns: + bool: True when the daemon accepted the command. + """ + return wpa_cli(wifi_card, 'disconnect') is not None + + +def disable_wifi(wifi_card): + """Take a wireless interface down. + + Args: + wifi_card (str): wireless interface name, for instance wlan0. + """ + _run('ifconfig', wifi_card, 'down') + + +def enable_wifi(wifi_card): + """Bring a wireless interface up. + + No scan here: the refresh loop requests one on its own clock. + + Args: + wifi_card (str): wireless interface name, for instance wlan0. + """ + _run('ifconfig', wifi_card, 'up') + + +def wpa_cli(wifi_card, *args): + """ + Ask wpa_supplicant something through its control socket. + + Args: + wifi_card (str): wireless interface name, which selects the socket. + *args (str): the wpa_cli command and its arguments, for instance + 'status', or 'set_network', '0', 'ssid', '"name"'. + + Returns: + str or None: what the daemon answered, or None when it is not + running, has no control socket, or refused the command with FAIL. + """ + # -p is where wpa_supplicant exposes its control sockets, one per + # interface. + command = ['wpa_cli', '-p', '/var/run/wpa_supplicant', + '-i', wifi_card] + list(args) try: - output = check_output(f'ifconfig {netcard}', shell=True, universal_newlines=True) - # Match all inet6 addresses with their prefix lengths - addresses = re.findall(r'inet6 ([0-9a-fA-F:]+)%?\S* prefixlen (\d+)', output) - return [(addr, int(prefixlen)) for addr, prefixlen in addresses] - except Exception: + out = check_output( + command, + stderr=DEVNULL, + universal_newlines=True, + timeout=5 + ) + except (CalledProcessError, TimeoutExpired, OSError): + return None + if out.startswith('FAIL'): + return None + return out + + +def _printf_decode(text): + """ + Undo the escaping wpa_supplicant applies to an SSID on its way out. + + The daemon prints every SSID through printf_encode(), so quotes and + backslashes come back doubled and non-ASCII bytes as \\xHH. Comparing + that against a plain string silently fails to match. + + Args: + text (str): one SSID exactly as status, list_networks or + scan_results printed it. + + Returns: + str: the real name. Invalid UTF-8 becomes the replacement character + rather than raising; a beacon can hold anything. + """ + simple = {'"': 0x22, '\\': 0x5c, 'e': 0x1b, 'n': 0x0a, 'r': 0x0d, + 't': 0x09} + out = bytearray() + index = 0 + while index < len(text): + char = text[index] + if char != '\\' or index + 1 >= len(text): + out.extend(char.encode('utf-8')) + index += 1 + continue + marker = text[index + 1] + if marker in simple: + out.append(simple[marker]) + index += 2 + elif marker == 'x' and index + 3 < len(text): + out.append(int(text[index + 2:index + 4], 16)) + index += 4 + else: + out.extend(char.encode('utf-8')) + index += 1 + return out.decode('utf-8', 'replace') + + +def _parse_key_values(text): + """ + Turn wpa_cli's key=value output into a dict. + + Args: + text (str): output from a command such as status or bss. + + Returns: + dict: every key=value line, whitespace stripped. Lines without an + equals sign are ignored, which is what the leading 'Selected + interface' banner is. + """ + values = {} + for line in text.splitlines(): + key, sep, value = line.partition('=') + if sep: + values[key.strip()] = value.strip() + return values + + +def wpa_status(wifi_card): + """ + Ask wpa_supplicant what it is currently doing. + + Args: + wifi_card (str): wireless interface name. + + Returns: + dict: the fields of `wpa_cli status`, among them wpa_state, ssid, + bssid, id, key_mgmt and ip_address. Empty when the daemon cannot + be reached, so callers use .get() rather than testing for None. + """ + out = wpa_cli(wifi_card, 'status') + if out is None: + return {} + values = _parse_key_values(out) + if 'ssid' in values: + values['ssid'] = _printf_decode(values['ssid']) + return values + + +def wpa_networks(wifi_card): + """ + List the saved networks the running daemon knows about. + + Asking the daemon rather than parsing wpa_supplicant.conf means the two + cannot disagree about which networks exist or how a name was spelled. + + Args: + wifi_card (str): wireless interface name. + + Returns: + list[dict]: one dict per saved network with 'id', 'ssid', 'bssid' and + 'flags'. Empty when the daemon cannot be reached. + """ + out = wpa_cli(wifi_card, 'list_networks') + if out is None: return [] + networks = [] + # network id / ssid / bssid / flags, one network per line after a header + for line in out.splitlines()[1:]: + fields = line.split('\t') + if len(fields) > 3: + networks.append({ + 'id': fields[0].strip(), + 'ssid': _printf_decode(fields[1]), + 'bssid': fields[2], + 'flags': fields[3], + }) + return networks + + +def wait_for_daemon(wifi_card, timeout=5): + """ + Wait for wpa_supplicant's control socket to start answering. + The socket may not exist yet at boot or after a hot plug. This waits; + it never restarts anything. -def has_slaac_enabled(netcard): - """Check if SLAAC (accept_rtadv) is enabled on the interface.""" - try: - output = check_output(f'ifconfig {netcard}', shell=True, universal_newlines=True) - return 'ACCEPT_RTADV' in output - except Exception: + Args: + wifi_card (str): wireless interface name. + timeout (int): seconds to keep trying before giving up. + + Returns: + bool: True as soon as the daemon answers, False if it never does. + """ + deadline = monotonic() + timeout + while True: + if wpa_cli(wifi_card, 'ping') is not None: + return True + if monotonic() >= deadline: + return False + sleep(0.25) + + +def wpa_reconfigure(wifi_card): + """ + Make wpa_supplicant re-read wpa_supplicant.conf, dropping unsaved edits. + + Run before this attempt's block is written and before a forget is + saved, so an edit left in the daemon by an abandoned attempt is not + persisted with them. Network ids are handed out afresh by this, so + any id read before it is stale. The daemon deauthenticates on it. + + Args: + wifi_card (str): wireless interface name. + + Returns: + bool: True when the daemon accepted the command. + """ + return wpa_cli(wifi_card, 'reconfigure') is not None + + +def wpa_network_id(wifi_card, ssid): + """ + Find the daemon's id for a saved network. + + Args: + wifi_card (str): wireless interface name. + ssid (str): the network name to look up. + + Returns: + str or None: the network id, or None when the daemon has no block + for that name. + """ + for network in wpa_networks(wifi_card): + if network['ssid'] == ssid: + return network['id'] + return None + + +def enable_all_networks(wifi_card): + """ + Re-enable every saved network so the card can roam again. + + select_network() disabled the others to pin one; leaving them disabled + would strand the card, so this must run however the attempt ended. + + Args: + wifi_card (str): wireless interface name. + + Returns: + bool: True when the daemon accepted the command. + """ + return wpa_cli(wifi_card, 'enable_network', 'all') is not None + + +def connect_to_ssid(ssid, wifi_card): + """ + Join one saved network, and only that one. + + select_network disables every other saved network, so the caller must + call enable_all_networks() once the attempt has finished either way. + + Args: + ssid (str): the network name to join. The daemon must already hold + a block for it, saved to the file or not. + wifi_card (str): wireless interface name. + + Returns: + bool: True when the daemon accepted the selection, which is the + start of the attempt and not its outcome. Follow it with + wait_for_connection(). + """ + if not wait_for_daemon(wifi_card): + return False + network_id = wpa_network_id(wifi_card, ssid) + if network_id is None: return False + return wpa_cli(wifi_card, 'select_network', network_id) is not None -def get_ipv6_gateway(): - """Get the default IPv6 gateway from routing table.""" - try: - output = check_output('netstat -rn -f inet6', shell=True, universal_newlines=True) - for line in output.splitlines(): - if line.startswith('default'): - parts = line.split() - if len(parts) >= 2: - return parts[1] - except Exception: - pass - return "" +def _printf_encode(value): + """ + Escape a value the way wpa_supplicant's own printf_encode() does. + The exact inverse of _printf_decode, so a name read back out of the + daemon can be handed straight back to it. -def startnetworkcard(netcard): - run(f'service netif start {netcard}', shell=True) - sleep(1) - run('service routing restart', shell=True) - run(f'service dhclient start {netcard}', shell=True) + Args: + value (str): the raw text. + Returns: + str: printable ASCII, with quotes, backslashes and control + characters escaped and every other byte written as \\xHH. + """ + simple = {0x22: '\\"', 0x5c: '\\\\', 0x1b: '\\e', 0x0a: '\\n', + 0x0d: '\\r', 0x09: '\\t'} + out = [] + for byte in value.encode('utf-8'): + if byte in simple: + out.append(simple[byte]) + elif 32 <= byte <= 126: + out.append(chr(byte)) + else: + out.append(f'\\x{byte:02x}') + return ''.join(out) -def wifiDisconnection(wificard): - run(f'ifconfig {wificard} down', shell=True) - run(f"ifconfig {wificard} ssid 'none'", shell=True) - run(f'ifconfig {wificard} up', shell=True) +def _quoted(value): + """ + Render a value for set_network using wpa_supplicant's escaped form. -def disableWifi(wificard): - run(f'ifconfig {wificard} down', shell=True) + A plain "..." is taken literally, so hand-added backslashes are stored + as backslashes. P"..." is the form that gets decoded, and keeps the + command printable ASCII whatever the beacon held. + Args: + value (str): the raw value, such as an SSID or an identity. -def enableWifi(wificard): - run(f'ifconfig {wificard} up', shell=True) - run(f'ifconfig {wificard} up scan', shell=True) + Returns: + str: the value wrapped as P"..." and ready for set_network. + """ + return f'P"{_printf_encode(value)}"' -def connectToSsid(name, wificard): - run('killall wpa_supplicant', shell=True) - # service - sleep(0.5) - run(f"ifconfig {wificard} ssid '{name}'", shell=True) - sleep(0.5) - wpa_supplicant = run( - f'wpa_supplicant -B -i {wificard} -c /etc/wpa_supplicant.conf', - shell=True - ) - return wpa_supplicant.returncode == 0 +def _plain_quoted(value): + """ + Render a value for the fields that only accept plain quotes. + wpa_config_parse_psk() takes a quoted passphrase or a hex key and + nothing else, so psk cannot use P"...". Nothing is escaped: the parser + reads first quote to last, so an embedded quote survives. -def subnetHexToDec(ifconfigstring): - snethex = re.search('0x.{8}', ifconfigstring).group(0)[2:] - snethexlist = re.findall('..', snethex) - snetdec = ".".join(str(int(li, 16)) for li in snethexlist) - outputline = ifconfigstring.replace(re.search('0x.{8}', ifconfigstring).group(0), snetdec) - return outputline + Args: + value (str): the raw passphrase. + Returns: + str: the passphrase wrapped in double quotes. + """ + return f'"{value}"' -def get_ssid_wpa_supplicant_config(ssid): - cmd = f"""grep -A 3 'ssid="{ssid}"' /etc/wpa_supplicant.conf""" - out = Popen(cmd, shell=True, stdout=PIPE, universal_newlines=True) - return out.stdout.read().splitlines() +def wpa_save_config(wifi_card): + """ + Ask the daemon to write wpa_supplicant.conf, then lock the file down. -def delete_ssid_wpa_supplicant_config(ssid): - cmd = f"""awk '/sid="{ssid}"/ """ \ - """{print NR-2 "," NR+4 "d"}' """ \ - """/etc/wpa_supplicant.conf | sed -f - /etc/wpa_supplicant.conf""" - out = Popen(cmd, shell=True, stdout=PIPE, universal_newlines=True) - left_over = out.stdout.read() - old_umask = os.umask(0o077) - try: - with open('/etc/wpa_supplicant.conf', 'w') as wpa_supplicant_conf: - wpa_supplicant_conf.write(left_over) - os.chmod('/etc/wpa_supplicant.conf', 0o600) - finally: - os.umask(old_umask) + The daemon's own chmod of the temporary file is #ifdef ANDROID, so on + FreeBSD the rename leaves whatever its umask produced. The file holds + passphrases, so 0600 has to be put back every time. + Args: + wifi_card (str): wireless interface name. -def nic_status(card): - out = Popen( - f'ifconfig {card} | grep status:', - shell=True, stdout=PIPE, - universal_newlines=True - ) - return out.stdout.read().split(':')[1].strip() + Returns: + bool: True when the daemon reported the file written. + """ + if wpa_cli(wifi_card, 'save_config') is None: + return False + os.chmod('/etc/wpa_supplicant.conf', 0o600) + return True -def start_dhcp(wificard): - run(f'dhclient {wificard}', shell=True) +def _save_network(wifi_card, fields): + """ + Create one saved network from a list of fields, in the daemon only. + Nothing reaches wpa_supplicant.conf here. The caller runs + wpa_save_config() once the network has actually connected, so a + passphrase that turns out to be wrong is never written to disk. -def wait_inet(card): - IPREGEX = r'[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' - status = 'associated' if 'wlan' in card else 'active' - while nic_status(card) != status: - sleep(0.1) - print(nic_status(card)) - while True: - ifcmd = f"ifconfig -f inet:dotted {card}" - ifoutput = check_output(ifcmd.split(" "), universal_newlines=True) - print(ifoutput) - re_ip = re.search(fr'inet {IPREGEX}', ifoutput) - if re_ip and '0.0.0.0' not in re_ip.group(): - print(re_ip) - break + add_network creates the network disabled, so it is enabled here rather + than at save time; the config writer records the disabled flag and would + leave a network that never joins after a restart. + Args: + wifi_card (str): wireless interface name. + fields (list[tuple[str, str]]): (name, value) pairs to set, with + values already quoted where wpa_supplicant expects a string. -def _escape_wpa_value(value): - """Escape special characters for wpa_supplicant.conf quoted strings.""" - if not value: - return value - # Escape backslashes first, then quotes - return value.replace('\\', '\\\\').replace('"', '\\"') + Returns: + str or None: the new network's id, or None when anything failed. A + half-built network is removed rather than left behind. + """ + if not wait_for_daemon(wifi_card): + return None + answer = wpa_cli(wifi_card, 'add_network') + if answer is None: + return None + network_id = answer.strip().splitlines()[-1].strip() + for name, value in fields: + if wpa_cli(wifi_card, 'set_network', network_id, name, value) is None: + wpa_cli(wifi_card, 'remove_network', network_id) + return None + if wpa_cli(wifi_card, 'enable_network', network_id) is None: + wpa_cli(wifi_card, 'remove_network', network_id) + return None + return network_id + + +def _wep_key_value(key): + """ + Render a WEP key as either a hex value or a quoted ASCII one. + + wpa_supplicant reads a bare wep_key0 as hex and a quoted one as ASCII, + and requires 5, 13 or 16 bytes either way. So only 10, 26 or 32 hex + digits can be meant as hex; everything else is ASCII and must be quoted. + Args: + key (str): the key exactly as the user typed it. -def generate_eap_config(ssid, eap_config): + Returns: + str: the key ready for set_network, bare when it is hex and quoted + when it is ASCII. """ - Generate wpa_supplicant network block for EAP/Enterprise authentication. + if len(key) in (10, 26, 32) and all(c in hexdigits for c in key): + return key + return _quoted(key) + - eap_config dict should contain: - - eap_method: 'PEAP', 'TTLS', 'TLS', etc. - - identity: username - - password: password (for PEAP/TTLS) - - ca_cert: path to CA certificate (optional) - - client_cert: path to client certificate (for TLS) - - private_key: path to private key (for TLS) - - private_key_passwd: private key password (for TLS) - - phase2: inner authentication method (for PEAP/TTLS) - - anonymous_identity: anonymous outer identity (optional) - - domain_suffix_match: server domain validation (optional) +def save_psk_network(ssid, security, pwd, wifi_card): """ - eap_method = eap_config.get('eap_method', 'PEAP') - identity = _escape_wpa_value(eap_config.get('identity', '')) - password = _escape_wpa_value(eap_config.get('password', '')) - ca_cert = _escape_wpa_value(eap_config.get('ca_cert', '')) - client_cert = _escape_wpa_value(eap_config.get('client_cert', '')) - private_key = _escape_wpa_value(eap_config.get('private_key', '')) - private_key_passwd = _escape_wpa_value(eap_config.get('private_key_passwd', '')) - phase2 = eap_config.get('phase2', 'MSCHAPV2') - anonymous_identity = _escape_wpa_value(eap_config.get('anonymous_identity', '')) - domain_suffix_match = _escape_wpa_value(eap_config.get('domain_suffix_match', '')) - ssid_escaped = _escape_wpa_value(ssid) - - ws = '\nnetwork={' - ws += f'\n\tssid="{ssid_escaped}"' - ws += '\n\tkey_mgmt=WPA-EAP' - ws += f'\n\teap={eap_method}' - ws += f'\n\tidentity="{identity}"' + Save a PSK or WEP network through the daemon. - if anonymous_identity: - ws += f'\n\tanonymous_identity="{anonymous_identity}"' + Args: + ssid (str): the network name. + security (str): the security type from security_from_flags, one of + WPA2-PSK, WPA-PSK, WPA3-SAE or WEP. + pwd (str): the passphrase, or the key for a WEP network. + wifi_card (str): wireless interface name. + + Returns: + str or None: the new network's id, or None when the save failed. + + Raises: + ValueError: for a security type this writer does not cover, such as + enterprise or open, which have their own savers. + """ + # A WPA2/WPA3 transition network reports SAE alongside PSK and joins + # perfectly well with a passphrase, so it is saved as RSN here. + if security in ('WPA2-PSK', 'WPA3-SAE'): + fields = [ + ('ssid', _quoted(ssid)), + ('key_mgmt', 'WPA-PSK'), + ('proto', 'RSN'), + ('psk', _plain_quoted(pwd)), + ] + elif security == 'WPA-PSK': + fields = [ + ('ssid', _quoted(ssid)), + ('key_mgmt', 'WPA-PSK'), + ('proto', 'WPA'), + ('psk', _plain_quoted(pwd)), + ] + elif security == 'WEP': + fields = [ + ('ssid', _quoted(ssid)), + ('key_mgmt', 'NONE'), + ('wep_tx_keyidx', '0'), + ('wep_key0', _wep_key_value(pwd)), + ] + else: + raise ValueError(f'{security} is not a PSK or WEP network') + return _save_network(wifi_card, fields) + + +def update_psk_network(ssid, pwd, wifi_card): + """ + Change the passphrase on a network that is already saved. + + Only the psk field is wrong, so it is replaced in place; deleting and + re-adding would discard everything else on the block. + + The change is made in the daemon only. The file keeps the old passphrase + until the new one has connected and the caller runs wpa_save_config(), + so retyping a password badly cannot destroy one that worked. + + Args: + ssid (str): the network name, which must already be saved. + pwd (str): the new passphrase. + wifi_card (str): wireless interface name. + Returns: + bool: True when the daemon took the new passphrase. + """ + if not wait_for_daemon(wifi_card): + return False + network_id = wpa_network_id(wifi_card, ssid) + if network_id is None: + return False + if wpa_cli(wifi_card, 'set_network', network_id, 'psk', + _plain_quoted(pwd)) is None: + return False + return wpa_cli(wifi_card, 'enable_network', network_id) is not None + + +def save_open_network(ssid, wifi_card): + """ + Save an open network through the daemon. + + Args: + ssid (str): the network name. + wifi_card (str): wireless interface name. + + Returns: + str or None: the new network's id, or None when the save failed. + """ + return _save_network(wifi_card, [ + ('ssid', _quoted(ssid)), + ('key_mgmt', 'NONE'), + ]) + + +def _phase2_value(eap_method, phase2): + """ + Build the phase2 string for an inner authentication method. + + PEAP always takes `auth=`. TTLS accepts only MSCHAPV2, MSCHAP, PAP and + CHAP after `auth=` (eap_ttls.c refuses to start otherwise), so EAP inner + methods must go through `autheap=`. + + Args: + eap_method (str): the outer method, such as PEAP or TTLS. + phase2 (str): the inner method, one of PHASE2_METHODS. + + Returns: + str: the phase2 field value, for instance "auth=MSCHAPV2" or + "autheap=GTC". + """ + if eap_method == 'TTLS' and phase2 not in ('MSCHAPV2', 'MSCHAP', 'PAP', 'CHAP'): + return f'autheap={phase2}' + return f'auth={phase2}' + + +def save_eap_network(ssid, eap_config, wifi_card): + """ + Save an enterprise (802.1X/EAP) network through the daemon. + + Args: + ssid (str): the network name. + eap_config (dict): the credentials collected by the EAP dialog, with + eap_method, identity, password, phase2, anonymous_identity, + ca_cert, client_cert, private_key, private_key_passwd and + domain_suffix_match. Only eap_method and identity are required. + wifi_card (str): wireless interface name. + + Returns: + str or None: the new network's id, or None when the save failed. + """ + eap_method = eap_config.get('eap_method', 'PEAP') + fields = [ + ('ssid', _quoted(ssid)), + ('key_mgmt', 'WPA-EAP'), + ('eap', eap_method), + ('identity', _quoted(eap_config.get('identity', ''))), + ] + anonymous_identity = eap_config.get('anonymous_identity', '') + if anonymous_identity: + fields.append(('anonymous_identity', _quoted(anonymous_identity))) if eap_method == 'TLS': - # TLS requires client certificate - if client_cert: - ws += f'\n\tclient_cert="{client_cert}"' - if private_key: - ws += f'\n\tprivate_key="{private_key}"' - if private_key_passwd: - ws += f'\n\tprivate_key_passwd="{private_key_passwd}"' + # TLS authenticates with a client certificate rather than a password. + for name in ('client_cert', 'private_key', 'private_key_passwd'): + value = eap_config.get(name, '') + if value: + fields.append((name, _quoted(value))) else: - # PEAP, TTLS, etc. use password - ws += f'\n\tpassword="{password}"' + fields.append(('password', _quoted(eap_config.get('password', '')))) + phase2 = eap_config.get('phase2', 'MSCHAPV2') if phase2: - if eap_method == 'TTLS': - ws += f'\n\tphase2="auth={phase2}"' - else: # PEAP - ws += f'\n\tphase2="auth={phase2}"' + fields.append( + ('phase2', _quoted(_phase2_value(eap_method, phase2))) + ) + for name in ('ca_cert', 'domain_suffix_match'): + value = eap_config.get(name, '') + if value: + fields.append((name, _quoted(value))) + if not wait_for_daemon(wifi_card): + return None + # Retyping credentials replaces the block rather than adding a second + # one, because wpa_network_id returns the lowest id and the retry would + # otherwise keep selecting the credentials that just failed. + _remove_networks(wifi_card, ssid) + return _save_network(wifi_card, fields) - if ca_cert: - ws += f'\n\tca_cert="{ca_cert}"' - if domain_suffix_match: - ws += f'\n\tdomain_suffix_match="{domain_suffix_match}"' +def _remove_networks(wifi_card, ssid): + """ + Drop every saved network with this name from the daemon. - ws += '\n}\n' - return ws + Nothing is written to disk, so the caller decides whether this is a + removal or is making room for a replacement block. + Args: + wifi_card (str): wireless interface name. + ssid (str): the network name to remove. -def write_eap_config(ssid, eap_config): + Returns: + bool: True when at least one network was removed. """ - Write EAP configuration to wpa_supplicant.conf with secure permissions. + removed = False + # Ids shift as networks are removed, so the list is re-read each time. + while True: + network_id = wpa_network_id(wifi_card, ssid) + if network_id is None: + break + if wpa_cli(wifi_card, 'remove_network', network_id) is None: + break + removed = True + return removed + + +def forget_network(ssid, wifi_card): """ - config = generate_eap_config(ssid, eap_config) - wpa_conf_path = '/etc/wpa_supplicant.conf' + Remove every saved network with this name and persist the removal. - # Write with restrictive permissions (owner read/write only) - old_umask = os.umask(0o077) - try: - with open(wpa_conf_path, 'a') as wsf: - wsf.write(config) - finally: - os.umask(old_umask) + Args: + ssid (str): the network name to forget. + wifi_card (str): wireless interface name. - # Ensure file permissions are correct - try: - os.chmod(wpa_conf_path, 0o600) - except PermissionError: - pass # May need root, handled by sudoers + Returns: + bool: True when at least one network was removed and written out. + """ + if not wait_for_daemon(wifi_card): + return False + if not _remove_networks(wifi_card, ssid): + return False + return wpa_save_config(wifi_card) + + +def ssid_is_saved(ssid, wifi_card): + """ + Report whether the daemon already holds a network block for a name. + + Asking the daemon rather than reading wpa_supplicant.conf means the two + cannot disagree about which networks exist or how a name was spelled. + + Args: + ssid (str): the network name to look for. + wifi_card (str): wireless interface name. + + Returns: + bool: True when a saved network carries that name. + """ + return wpa_network_id(wifi_card, ssid) is not None + + +def security_from_flags(flags): + """ + Read the security type out of a scan result's flags. + + PSK is tested before SAE on purpose: a WPA2/WPA3 transition AP + advertises both as [WPA2-PSK+SAE-CCMP] and joins with a passphrase, + while SAE needs a wpa_supplicant built with it. + + Args: + flags (str): the flags column, for instance '[WPA2-PSK-CCMP][ESS]'. + + Returns: + str: one of WPA2-EAP, WPA-EAP, WPA2-PSK, WPA-PSK, WPA3-SAE, WEP or + OPEN. + """ + modern = 'WPA2' in flags or 'RSN' in flags + if 'EAP' in flags: + return 'WPA2-EAP' if modern else 'WPA-EAP' + if 'PSK' in flags: + return 'WPA2-PSK' if modern else 'WPA-PSK' + if 'SAE' in flags: + return 'WPA3-SAE' + if 'WEP' in flags: + return 'WEP' + return 'OPEN' + + +# Noise readings already taken, keyed by interface. See _noise_floor. +_NOISE_FLOOR = {} + + +def _noise_floor(wifi_card): + """ + Return the radio's noise floor, which the scan results do not carry. + + Cached per interface: noise belongs to the radio, not the access point, + and does not move. A failed reading is deliberately not cached, since + the BSS table is empty until the first scan completes. + + Args: + wifi_card (str): wireless interface name. + + Returns: + int: noise in dBm from the daemon's BSS table, or -95 when the + driver does not report it. + """ + if wifi_card in _NOISE_FLOOR: + return _NOISE_FLOOR[wifi_card] + out = wpa_cli(wifi_card, 'bss', '0') + if out: + noise = _parse_key_values(out).get('noise') + if noise: + _NOISE_FLOOR[wifi_card] = int(noise) + return _NOISE_FLOOR[wifi_card] + return -95 + + +def request_scan(wifi_card): + """ + Ask the daemon to scan for access points now. + + Unlike `ifconfig scan` this works as an unprivileged user. It is also the + only way to force a refresh: reading scan results never triggers one. + + Args: + wifi_card (str): wireless interface name. + + Returns: + bool: True when the daemon accepted the request. + """ + return wpa_cli(wifi_card, 'scan') is not None + + +def request_scan_all(): + """Ask every wireless card to scan. + + Called on the refresh tick and when the menu opens, so the BSS table is + already warm by the time a submenu is drawn from it. + """ + for card in nics_list(): + if 'wlan' in card: + request_scan(card) + + +def scan_networks(wifi_card): + """ + List the access points in range. + + Reads the BSS table only. It expires entries after bss_expiration_age + (180s) and autoscan refills it only while disconnected, so something + has to call request_scan_all() for this to stay current. + + Args: + wifi_card (str): wireless interface name. + + Returns: + dict: maps each SSID to a record with 'ssid', 'bssid', 'frequency', + 'level', 'noise', 'signal', 'flags', 'security' and 'enterprise'. + Strongest access point wins; hidden networks are left out. + """ + out = wpa_cli(wifi_card, 'scan_results') + if out is None: + return {} + noise = _noise_floor(wifi_card) + networks = {} + # bssid / frequency / signal level / flags / ssid, after a header line + for line in out.splitlines()[1:]: + fields = line.split('\t') + if len(fields) < 5: + continue + ssid = _printf_decode(fields[4]) + if not ssid: + continue + level = int(fields[2]) + signal = bar_percent(level, noise) + if ssid in networks and networks[ssid]['signal'] >= signal: + continue + flags = fields[3] + security = security_from_flags(flags) + networks[ssid] = { + 'ssid': ssid, + 'bssid': fields[0], + 'frequency': int(fields[1]), + 'level': level, + 'noise': noise, + 'signal': signal, + 'flags': flags, + 'security': security, + 'enterprise': security.endswith('-EAP'), + } + return networks + + +def wait_for_connection(wifi_card, ssid, timeout=30): + """ + Watch a connection attempt and report whether it joined. + + COMPLETED alone is not success: the daemon still reports the previous + association for a moment after select_network, so the reported ssid has + to match the one asked for. + + A refused key cannot be told from a card that never answered. That would + need wpas_auth_failed() to fire, and on driver_bsd it does not: tested + 2026-09-10 with a knowingly wrong passphrase, every attempt ran the full + timeout without the network ever being marked TEMP-DISABLED. + + Args: + wifi_card (str): wireless interface name. + ssid (str): the network the caller asked to join. + timeout (int): seconds to wait before giving up. + + Returns: + bool: True once the card is joined to ssid, False on timeout. + """ + deadline = monotonic() + timeout + while monotonic() < deadline: + status = wpa_status(wifi_card) + if status.get('wpa_state') == 'COMPLETED' and status.get('ssid') == ssid: + return True + sleep(0.5) + return False + + +def subnet_hex_to_dec(ifconfig_string): + """ + Rewrite the hexadecimal netmask in an ifconfig line as dotted decimal. + + ifconfig prints `netmask 0xffffff00`, which no one reads at a glance. + + Args: + ifconfig_string (str): an ifconfig line, normally the `inet ` one. + + Returns: + str: the same line with the mask as 255.255.255.0. The line is + returned untouched when it holds no hexadecimal mask, which is + the case for an interface with no IPv4 address. + """ + found = re.search('0x.{8}', ifconfig_string) + if found is None: + return ifconfig_string + snethexlist = re.findall('..', found.group(0)[2:]) + snetdec = ".".join(str(int(li, 16)) for li in snethexlist) + return ifconfig_string.replace(found.group(0), snetdec) + + +def wait_for_address(card, timeout=5): + """Wait for an interface to hold an IPv4 address. + + Best effort: the caller renews the lease afterwards either way, so a + timeout here means a slow interface, not a failure. + + Nothing waits on ifconfig's status word. An interface cannot hold a + leased IPv4 address with the link down, so the address is the only + thing worth watching, and waiting for "active" was the half that could + never finish on an unplugged card. + + Args: + card (str): interface name, for instance em0 or wlan0. + timeout (int): seconds to wait before giving up. + """ + deadline = monotonic() + timeout + while monotonic() < deadline: + ifoutput = _run('ifconfig', '-f', 'inet:dotted', card).stdout + if re.search(fr'inet {IP_REGEX}', ifoutput): + return + sleep(0.1) diff --git a/NetworkMgr/query.py b/NetworkMgr/query.py index 2f0d2dc..245c56d 100644 --- a/NetworkMgr/query.py +++ b/NetworkMgr/query.py @@ -1,29 +1,90 @@ #!/usr/bin/env python -from subprocess import check_output +"""Read-only queries for the current network configuration. + +Answers what the configuration UI needs to know about an interface: its +assignment method, addresses, gateway, DNS servers and search domain, for +both IPv4 and IPv6. Values come from rc.conf(5) via sysrc, from ifconfig, +from the routing table and from /etc/resolv.conf. +""" + +from subprocess import CalledProcessError, check_output, run import re import os +IP_REGEX = r'[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' + + +def _rc_conf_value(name): + """Return the effective value of an rc.conf(5) variable. + + sysrc reads every file in rc_conf_files, so a setting placed in + /etc/rc.conf.local is seen here and overrides /etc/rc.conf, matching + both the boot-time behaviour and where our own sysrc writes land. + + Args: + name (str): The rc.conf variable to read, such as "defaultrouter". + + Returns: + str: The variable's value with surrounding whitespace removed, or an + empty string if it is set nowhere. + """ + sysrc = run( + ['sysrc', '-n', name], + capture_output=True, + universal_newlines=True, + check=False + ) + if sysrc.returncode != 0: + return "" + return sysrc.stdout.strip() + + +def _address_after(keyword, text): + """Read the dotted-quad address that follows a keyword. + + Args: + keyword (str): the word before the address, such as "netmask". + text (str): ifconfig output. + + Returns: + str: the address, or an empty string when the keyword is not there. + A missing field is ordinary rather than exceptional: a loopback or + point-to-point interface carries an address with no broadcast. + """ + found = re.search(fr'{keyword} {IP_REGEX}', text) + if not found: + return "" + return found.group().replace(f'{keyword} ', '').strip() + def get_interface_settings_ipv6(active_nic): - """Get IPv6 settings for the given network interface.""" + """Collect the IPv6 settings of one network interface. + + Args: + active_nic (str): Interface name, such as "em0" or "wlan0". + + Returns: + dict[str, str]: Assignment Method, Interface IPv6, Prefix Length, + Default Gateway, DNS Server 1 and Search Domain. Missing values are + empty strings rather than absent keys. + """ ipv6_settings = {} - rc_conf = open("/etc/rc.conf", "r").read() + ifconfig_ipv6 = _rc_conf_value(f'ifconfig_{active_nic}_ipv6') # Check if SLAAC is enabled (accept_rtadv in rc.conf) slaac_search = re.search( - fr'^ifconfig_{active_nic}_ipv6=".*accept_rtadv', - rc_conf, - re.MULTILINE | re.IGNORECASE + r'accept_rtadv', + ifconfig_ipv6, + re.IGNORECASE ) if slaac_search: ipv6_settings["Assignment Method"] = "SLAAC" else: # Check for static IPv6 configuration static_search = re.search( - fr'^ifconfig_{active_nic}_ipv6="inet6\s+([0-9a-fA-F:]+).*prefixlen\s+(\d+)', - rc_conf, - re.MULTILINE + r'^inet6\s+([0-9a-fA-F:]+).*prefixlen\s+(\d+)', + ifconfig_ipv6 ) if static_search: ipv6_settings["Assignment Method"] = "Manual" @@ -50,19 +111,18 @@ def get_interface_settings_ipv6(active_nic): else: ipv6_settings["Interface IPv6"] = "" ipv6_settings["Prefix Length"] = "64" - except Exception: + except (CalledProcessError, OSError): ipv6_settings["Interface IPv6"] = "" ipv6_settings["Prefix Length"] = "64" - # Get IPv6 default gateway from rc.conf or routing table - # Pattern allows optional interface suffix for link-local (e.g., fe80::1%em0) - gateway_search = re.search( - r'^ipv6_defaultrouter="([0-9a-fA-F:]+(?:%[a-zA-Z0-9]+)?)"', - rc_conf, - re.MULTILINE + # The suffix allows a link-local gateway (fe80::1%em0). An unset + # variable reads as the "NO" sentinel, which fails this match. + gateway_search = re.fullmatch( + r'[0-9a-fA-F:]+(?:%[a-zA-Z0-9]+)?', + _rc_conf_value('ipv6_defaultrouter') ) if gateway_search: - ipv6_settings["Default Gateway"] = gateway_search.group(1) + ipv6_settings["Default Gateway"] = gateway_search.group() else: # Try to get from routing table try: @@ -80,13 +140,14 @@ def get_interface_settings_ipv6(active_nic): break else: ipv6_settings["Default Gateway"] = "" - except Exception: + except (CalledProcessError, OSError): ipv6_settings["Default Gateway"] = "" # Get IPv6 DNS servers from resolv.conf ipv6_settings["DNS Server 1"] = "" if os.path.exists('/etc/resolv.conf'): - resolv_conf = open('/etc/resolv.conf').read() + with open('/etc/resolv.conf', encoding='utf-8') as resolv_file: + resolv_conf = resolv_file.read() # Match IPv6 nameservers (must contain at least one colon) ipv6_nameservers = re.findall( r'^nameserver\s+([0-9a-fA-F]*:[0-9a-fA-F:]+)', @@ -99,7 +160,8 @@ def get_interface_settings_ipv6(active_nic): # Get search domain (shared with IPv4) ipv6_settings["Search Domain"] = "" if os.path.exists('/etc/resolv.conf'): - resolv_conf = open('/etc/resolv.conf').read() + with open('/etc/resolv.conf', encoding='utf-8') as resolv_file: + resolv_conf = resolv_file.read() search_match = re.search(r'^search\s+(.+)$', resolv_conf, re.MULTILINE) if search_match: ipv6_settings["Search Domain"] = search_match.group(1).strip() @@ -112,79 +174,79 @@ def get_interface_settings_ipv6(active_nic): def get_interface_settings(active_nic): + """Collect the IPv4 settings of one network interface. + + Args: + active_nic (str): Interface name, such as "em0" or "wlan0". + + Returns: + dict[str, str]: Active Interface, Assignment Method, Interface IP, + Interface Subnet Mask, Broadcast Address, Default Gateway, Search + Domain and one "DNS Server N" entry per nameserver. DNS Server 1 and + 2 are always present, empty when unset. + """ interface_settings = {} - rc_conf = open("/etc/rc.conf", "r").read() - DHCPSearch = re.findall(fr'^ifconfig_{active_nic}=".*DHCP', rc_conf, re.MULTILINE) - print(f"DHCPSearch is {DHCPSearch} and the length is {len(DHCPSearch)}") - if len(DHCPSearch) < 1: - DHCPStatusOutput = "Manual" + if 'DHCP' in _rc_conf_value(f'ifconfig_{active_nic}'): + dhcp_status_output = "DHCP" else: - DHCPStatusOutput = "DHCP" - - IPREGEX = r'[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' + dhcp_status_output = "Manual" ifcmd = f"ifconfig -f inet:dotted {active_nic}" ifoutput = check_output(ifcmd.split(" "), universal_newlines=True) - re_ip = re.search(fr'inet {IPREGEX}', ifoutput) - if re_ip: - if_ip = re_ip.group().replace("inet ", "").strip() - re_netmask = re.search(fr'netmask {IPREGEX}', ifoutput) - if_netmask = re_netmask.group().replace("netmask ", "").strip() - re_broadcast = re.search(fr'broadcast {IPREGEX}', ifoutput) - if_broadcast = re_broadcast.group().replace("broadcast ", "").strip() - else: - if_ip = "" - if_netmask = "" - if_broadcast = "" - if (DHCPStatusOutput == "DHCP"): + if_ip = _address_after('inet', ifoutput) + if_netmask = _address_after('netmask', ifoutput) + if_broadcast = _address_after('broadcast', ifoutput) + if dhcp_status_output == "DHCP": dhclient_leases = f"/var/db/dhclient.leases.{active_nic}" if os.path.exists(dhclient_leases) is False: - print("DHCP is enabled, but we're unable to read the lease " - f"file a /var/db/dhclient.leases.{active_nic}") + # No lease file yet, so there is no router option to read. The + # window shows an empty gateway rather than a stale one. gateway = "" else: - dh_lease = open(dhclient_leases, "r").read() - re_gateway = re.search(fr"option routers {IPREGEX}", dh_lease) - gateway = re_gateway.group().replace("option routers ", "") + with open(dhclient_leases, "r", encoding='utf-8') as lease_file: + dh_lease = lease_file.read() + # dhclient appends leases, so the last one is the current one. + routers = re.findall(fr'option routers ({IP_REGEX})', dh_lease) + gateway = routers[-1] if routers else "" else: - rc_conf = open('/etc/rc.conf', 'r').read() - re_gateway = re.search(fr'^defaultrouter="{IPREGEX}"', rc_conf, re.MULTILINE) + # An unset defaultrouter reads as the "NO" sentinel from + # /etc/defaults/rc.conf, which fails this match. + re_gateway = re.fullmatch(IP_REGEX, _rc_conf_value('defaultrouter')) if re_gateway: - gateway = re_gateway.group().replace('"', "") - gateway = gateway.replace('defaultrouter=', "") + gateway = re_gateway.group() else: gateway = "" if os.path.exists('/etc/resolv.conf'): - resolv_conf = open('/etc/resolv.conf').read() - nameservers = re.findall(fr'^nameserver {IPREGEX}', str(resolv_conf), re.MULTILINE) - print(nameservers) - - re_domain_search = re.findall('search [a-zA-Z.]*', str(resolv_conf)) - if len(re_domain_search) < 1: - re_domain_search = re.findall('domain (.*)', resolv_conf) - domain_search = str(re_domain_search).replace("domain ", "") - domain_search = domain_search.replace("'", "") - domain_search = domain_search.replace("[", "") - domain_search = domain_search.replace("]", "") - domain_search = domain_search.replace('search', '').strip() + with open('/etc/resolv.conf', encoding='utf-8') as resolv_file: + resolv_conf = resolv_file.read() + nameservers = re.findall(fr'^nameserver {IP_REGEX}', str(resolv_conf), re.MULTILINE) + + domain_search = '' + search_match = re.search(r'^search\s+(.+)$', resolv_conf, re.MULTILINE) + if search_match: + domain_search = search_match.group(1).strip() + else: + domain_match = re.search(r'^domain\s+(.+)$', resolv_conf, re.MULTILINE) + if domain_match: + domain_search = domain_match.group(1).strip() else: domain_search = '' nameservers = [] interface_settings["Active Interface"] = active_nic - interface_settings["Assignment Method"] = DHCPStatusOutput + interface_settings["Assignment Method"] = dhcp_status_output interface_settings["Interface IP"] = if_ip interface_settings["Interface Subnet Mask"] = if_netmask interface_settings["Broadcast Address"] = if_broadcast interface_settings["Default Gateway"] = gateway interface_settings["Search Domain"] = domain_search - for num in range(len(nameservers)): + for num, nameserver in enumerate(nameservers): interface_settings[ f"DNS Server {num + 1}" - ] = str(nameservers[(num)]).replace("nameserver", "").strip() + ] = str(nameserver).replace("nameserver", "").strip() # if DNS Server 1 and 2 are missing create them with empty string if "DNS Server 1" not in interface_settings: interface_settings["DNS Server 1"] = "" diff --git a/NetworkMgr/trayicon.py b/NetworkMgr/trayicon.py index fea26fe..e2224c2 100755 --- a/NetworkMgr/trayicon.py +++ b/NetworkMgr/trayicon.py @@ -1,90 +1,180 @@ #!/usr/bin/env python -import gi -gi.require_version('Gtk', '3.0') +"""The NetworkMgr tray icon and its menu. + +Owns everything the user sees from the system tray: the interface and +network menus, the passphrase and enterprise credential dialogs, the signal +icons, and the background thread that refreshes all of it. Every system +call it makes goes through NetworkMgr.net_api or NetworkMgr.wg_api. +""" + import gettext import threading import _thread -import locale +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk, GObject, GLib, Pango from time import sleep -from gi.repository import Gtk, GObject, GLib from NetworkMgr.net_api import ( - stopnetworkcard, - startnetworkcard, - wifiDisconnection, + stop_network_card, + start_network_card, + disconnect_wifi, restart_all_nics, - stopallnetwork, - startallnetwork, - connectToSsid, - disableWifi, - enableWifi, - connectionStatus, - networkdictionary, - delete_ssid_wpa_supplicant_config, - nic_status, + connect_to_ssid, + enable_all_networks, + disable_wifi, + enable_wifi, + network_dictionary, + request_scan_all, + scan_networks, + tray_state, + forget_network, + ssid_is_saved, + save_psk_network, + update_psk_network, + save_open_network, + wpa_networks, + wpa_reconfigure, + wpa_save_config, + wait_for_connection, EAP_METHODS, PHASE2_METHODS, - DEFAULT_CA_CERT, - is_enterprise_network, - get_security_type, - validate_certificate, get_system_ca_certificates, - write_eap_config + save_eap_network ) -from NetworkMgr.configuration import network_card_configuration +from NetworkMgr.configuration import open_configuration from NetworkMgr.wg_api import ( wg_dictionary, + wg_service_state, disable_wg, - enable_wg, - wg_status + enable_wg ) gettext.bindtextdomain('networkmgr', '/usr/local/share/locale') gettext.textdomain('networkmgr') _ = gettext.gettext -encoding = locale.getpreferredencoding() -threadBreak = False GObject.threads_init() +# Attempts on one network before its saved block is removed. One failure is +# not proof of a wrong password (issue #81); three is enough either way. +MAX_ATTEMPTS = 3 + + + +def _heading_label(text): + """Build a dialog heading without going through the markup parser. + + Headings carry an SSID, so a name holding `&` or a tag would break the + Pango parse or have its markup obeyed. Attributes leave the text alone. + + Args: + text (str): the heading, already translated. + + Returns: + Gtk.Label: the label, bold and one size up, showing text verbatim. + """ + label = Gtk.Label(label=text) + attributes = Pango.AttrList() + attributes.insert(Pango.attr_weight_new(Pango.Weight.BOLD)) + # PANGO_SCALE_LARGE is a C macro and not introspectable. + attributes.insert(Pango.attr_scale_new(1.2)) + label.set_attributes(attributes) + return label -class trayIcon(object): - def stop_manager(self, widget): +class TrayIcon: + """The status icon, its menu, and the thread that keeps them current.""" + + def stop_manager(self, _widget): + """Quit the application. + + Args: + _widget (Gtk.Widget): the menu item that fired this, unused. + """ Gtk.main_quit() def __init__(self): - self.if_running = False self.cardinfo = None - self.statusIcon = Gtk.StatusIcon() - self.statusIcon.set_visible(True) - self.statusIcon.connect("activate", self.leftclick) - self.statusIcon.connect('popup-menu', self.icon_clicked) - - def leftclick(self, status_icon): - if not self.thr.is_alive(): - self.thr.start() + self.status_icon = Gtk.StatusIcon() + self.status_icon.set_visible(True) + self.status_icon.connect("activate", self.left_click) + self.status_icon.connect('popup-menu', self.menu_requested) + # Built on demand by the menu and dialog builders below. + self.thr = None + self.menu = None + # Set while an attempt runs so the refresh does not draw over the + # animation. Written from the worker; bool assignment is atomic. + self.connecting = False + self.connect_frame = 0 + # Failed attempts since the user last picked this network from the + # menu. At three the saved block is removed, see MAX_ATTEMPTS. + self.connect_attempts = 0 + self.traystate = tray_state() + # Read once: 13 ms of shell, and it only changes on an rc.conf edit. + self.wg_service = wg_service_state() + self.window = None + self.password = None + self.eap_window = None + self.eap_method_combo = None + self.phase2_combo = None + self.identity_entry = None + self.anon_identity_entry = None + self.eap_password = None + self.ca_cert_chooser = None + self.client_cert_chooser = None + self.private_key_chooser = None + self.private_key_passwd = None + self.eap_rows = {} + + def left_click(self, status_icon): + """Pop the menu up under a left click on the tray icon. + + Args: + status_icon (Gtk.StatusIcon): the icon that was clicked. + """ button = 1 time = Gtk.get_current_event_time() position = Gtk.StatusIcon.position_menu - self.nm_menu().popup(None, None, position, status_icon, button, time) + self.build_menu().popup(None, None, position, status_icon, button, time) + + def menu_requested(self, status_icon, button, time): + """Pop the menu up for a right click or a popup-menu key press. - def icon_clicked(self, status_icon, button, time): - if not self.thr.is_alive(): - self.thr.start() + Args: + status_icon (Gtk.StatusIcon): the icon that was clicked. + button (int): the mouse button number GTK reported. + time (int): the event's timestamp, passed straight to popup(). + """ position = Gtk.StatusIcon.position_menu - self.nm_menu().popup(None, None, position, status_icon, button, time) + self.build_menu().popup(None, None, position, status_icon, button, time) - def nm_menu(self): + def build_menu(self): + """Build the whole tray menu. + + The refresh tick collects only what the icon and the tooltip show, + so the interfaces are read here instead. That is about 10 ms, which + a click can afford. The scan list under each wireless card costs + more again, so it is filled when its submenu opens rather than now. + + Returns: + Gtk.Menu: the assembled menu, ready to pop up. + """ self.menu = Gtk.Menu() - if len(self.wginfo['configs'])> 0 and self.wginfo['service'] == '"NO"': + # Asked for, not waited on: this refreshes the list for the next + # look, while the submenu below is drawn from what is known now. + request_scan_all() + # The refresh tick deliberately does not collect this. + self.cardinfo = network_dictionary() + wg_info = wg_dictionary(self.wg_service) + if len(wg_info['configs']) > 0 and wg_info['service'] == 'NO': wg_title = Gtk.MenuItem() wg_title.set_label(_("WireGuard VPN")) wg_title.set_sensitive(False) self.menu.append(wg_title) self.menu.append(Gtk.SeparatorMenuItem()) - wg_devices = self.wginfo['configs'] + wg_devices = wg_info['configs'] for wg_dev in wg_devices: connection_state = wg_devices[wg_dev]['state'] connection_info = wg_devices[wg_dev]['info'] @@ -92,15 +182,15 @@ def nm_menu(self): wg_item = Gtk.MenuItem(_("%s Connected") % connection_info) wg_item.set_sensitive(False) self.menu.append(wg_item) - disconnectwg_item = Gtk.ImageMenuItem(_(f"Disable {wg_dev}")) - disconnectwg_item.connect("activate", self.disconnectWG, wg_dev) + disconnectwg_item = Gtk.ImageMenuItem(_("Disable %s") % wg_dev) + disconnectwg_item.connect("activate", self.disconnect_wg, wg_dev) self.menu.append(disconnectwg_item) else: notonlinewg = Gtk.MenuItem(_("%s Disconnected") % connection_info) notonlinewg.set_sensitive(False) self.menu.append(notonlinewg) wiredwg_item = Gtk.MenuItem(_("Enable")) - wiredwg_item.connect("activate", self.connectWG, wg_dev) + wiredwg_item.connect("activate", self.connect_wg, wg_dev) self.menu.append(wiredwg_item) self.menu.append(Gtk.SeparatorMenuItem()) @@ -115,24 +205,35 @@ def nm_menu(self): for netcard in cards: connection_state = cards[netcard]['state']["connection"] if "wlan" not in netcard: - if connection_state == "Connected": + if connection_state == "Disabled": + wd_title = Gtk.MenuItem(_("Wired %s Disabled") % cardnum) + wd_title.set_sensitive(False) + self.menu.append(wd_title) + wired_item = Gtk.MenuItem(_("Enable")) + wired_item.connect("activate", self.enable_card, netcard) + self.menu.append(wired_item) + elif connection_state == "Connected": wired_item = Gtk.MenuItem(_("Wired %s Connected") % cardnum) wired_item.set_sensitive(False) self.menu.append(wired_item) - disconnect_item = Gtk.ImageMenuItem(_(f"Disable {netcard}")) - disconnect_item.connect("activate", self.disconnectcard, + disconnect_item = Gtk.ImageMenuItem(_("Disable %s") % netcard) + disconnect_item.connect("activate", self.disable_card, netcard) self.menu.append(disconnect_item) - configure_item = Gtk.ImageMenuItem(f"Configure {netcard}") + configure_item = Gtk.ImageMenuItem(_("Configure %s") % netcard) configure_item.connect("activate", self.configuration_window_open, netcard) self.menu.append(configure_item) elif connection_state == "Disconnected": notonline = Gtk.MenuItem(_("Wired %s Disconnected") % cardnum) notonline.set_sensitive(False) self.menu.append(notonline) - wired_item = Gtk.MenuItem(_("Enable")) - wired_item.connect("activate", self.connectcard, netcard) - self.menu.append(wired_item) + disconnect_item = Gtk.ImageMenuItem(_("Disable %s") % netcard) + disconnect_item.connect("activate", self.disable_card, + netcard) + self.menu.append(disconnect_item) + configure_item = Gtk.ImageMenuItem(_("Configure %s") % netcard) + configure_item.connect("activate", self.configuration_window_open, netcard) + self.menu.append(configure_item) else: disconnected = Gtk.MenuItem(_("Wired %s Unplug") % cardnum) disconnected.set_sensitive(False) @@ -146,36 +247,38 @@ def nm_menu(self): wd_title.set_sensitive(False) self.menu.append(wd_title) enawifi = Gtk.MenuItem(_("Enable Wifi %s") % wifinum) - enawifi.connect("activate", self.enable_Wifi, netcard) + enawifi.connect("activate", self.enable_wifi, netcard) self.menu.append(enawifi) elif connection_state == "Disconnected": d_title = Gtk.MenuItem() d_title.set_label(_("WiFi %s Disconnected") % wifinum) d_title.set_sensitive(False) self.menu.append(d_title) - self.wifiListMenu(netcard, None, False, cards) + self.wifi_list_menu(netcard, None, False) + self.forget_list_menu(netcard) diswifi = Gtk.MenuItem(_("Disable Wifi %s") % wifinum) - diswifi.connect("activate", self.disable_Wifi, netcard) + diswifi.connect("activate", self.disable_wifi, netcard) self.menu.append(diswifi) else: ssid = cards[netcard]['state']["ssid"] - bar = cards[netcard]['info'][ssid][4] + signal = cards[netcard]['signal'] or 0 wc_title = Gtk.MenuItem(_("WiFi %s Connected") % wifinum) wc_title.set_sensitive(False) self.menu.append(wc_title) connection_item = Gtk.ImageMenuItem(ssid) - connection_item.set_image(self.wifi_signal_icon(bar)) + connection_item.set_image(self.wifi_signal_icon(signal)) connection_item.show() disconnect_item = Gtk.MenuItem(_("Disconnect from %s") % ssid) disconnect_item.connect("activate", self.disconnect_wifi, netcard) self.menu.append(connection_item) self.menu.append(disconnect_item) - self.wifiListMenu(netcard, ssid, True, cards) + self.wifi_list_menu(netcard, ssid, True) + self.forget_list_menu(netcard) diswifi = Gtk.MenuItem(_("Disable Wifi %s") % wifinum) - diswifi.connect("activate", self.disable_Wifi, netcard) + diswifi.connect("activate", self.disable_wifi, netcard) self.menu.append(diswifi) - configure_item = Gtk.ImageMenuItem(f"Configure {netcard}") + configure_item = Gtk.ImageMenuItem(_("Configure %s") % netcard) configure_item.connect("activate", self.configuration_window_open, netcard) self.menu.append(configure_item) self.menu.append(Gtk.SeparatorMenuItem()) @@ -190,234 +293,582 @@ def nm_menu(self): self.menu.show_all() return self.menu - def ssid_menu_item(self, sn, caps, ssid, ssid_info, wificard): + def ssid_menu_item(self, network, wifi_card): + """ + Build one clickable access point entry for the menu. + + Args: + network (dict): the network's scan record from scan_networks. + wifi_card (str): the interface the entry would connect. + + Returns: + Gtk.ImageMenuItem: labelled with the SSID, carrying a signal icon + that shows a padlock for anything but an open network, and + wired to the dialog its security type calls for. A WPA3-only + network is shown greyed out: driver_bsd offers no SAE. + """ + ssid = network['ssid'] + if network['security'] == 'WPA3-SAE': + menu_item = Gtk.ImageMenuItem(_("%s (WPA3 only)") % ssid) + menu_item.set_image(self.wifi_signal_icon(network['signal'], True)) + menu_item.set_sensitive(False) + menu_item.show() + return menu_item menu_item = Gtk.ImageMenuItem(ssid) - # Check if enterprise network (index 9 contains enterprise flag boolean) - is_enterprise = len(ssid_info) > 9 and ssid_info[9] is True - if caps in ('E', 'ES'): + if network['security'] == 'OPEN': is_secure = False - click_action = self.menu_click_open - ssid_type = ssid - elif is_enterprise: + click_action = self.connect_open_network + click_argument = ssid + elif network['enterprise']: is_secure = True - click_action = self.menu_click_enterprise - ssid_type = ssid_info + click_action = self.connect_enterprise_network + click_argument = network else: is_secure = True - click_action = self.menu_click_lock - ssid_type = ssid_info - menu_item.set_image(self.wifi_signal_icon(sn, is_secure)) - menu_item.connect("activate", click_action, ssid_type, wificard) + click_action = self.connect_psk_network + click_argument = network + menu_item.set_image(self.wifi_signal_icon(network['signal'], is_secure)) + menu_item.connect("activate", click_action, click_argument, wifi_card) menu_item.show() return menu_item - def wifiListMenu(self, wificard, cssid, passes, cards): + def wifi_list_menu(self, wifi_card, cssid, passes): + """ + Add the "Available Connections" submenu for one wireless card. + + The submenu fills itself when opened, so a tray click does not pay + for a scan read. + + Args: + wifi_card (str): the interface whose scan results to list. + cssid (str or None): the SSID already connected, if any. + passes (bool): True to leave cssid out of the list, so the card + the user is already on is not offered again. + """ wiconncmenu = Gtk.Menu() avconnmenu = Gtk.MenuItem(_("Available Connections")) avconnmenu.set_submenu(wiconncmenu) - for ssid in cards[wificard]['info']: - ssid_info = cards[wificard]['info'][ssid] - ssid = cards[wificard]['info'][ssid][0] - sn = cards[wificard]['info'][ssid][4] - caps = cards[wificard]['info'][ssid][6] - if passes: - if cssid != ssid: - menu_item = self.ssid_menu_item(sn, caps, ssid, ssid_info, wificard) - wiconncmenu.append(menu_item) - else: - menu_item = self.ssid_menu_item(sn, caps, ssid, ssid_info, wificard) - wiconncmenu.append(menu_item) + wiconncmenu.connect("show", self.fill_wifi_list, wifi_card, cssid, + passes) self.menu.append(avconnmenu) - def configuration_window_open(self, widget, interface): - network_card_configuration(interface) - - def menu_click_open(self, widget, ssid, wificard): - if f'"{ssid}"' in open("/etc/wpa_supplicant.conf").read(): - connectToSsid(ssid, wificard) - else: - self.Open_Wpa_Supplicant(ssid, wificard) - self.updateinfo() - - def menu_click_lock(self, widget, ssid_info, wificard): - if f'"{ssid_info[0]}"' in open('/etc/wpa_supplicant.conf').read(): - connectToSsid(ssid_info[0], wificard) + def forget_list_menu(self, wifi_card): + """ + Add the "Forget Network" submenu for one wireless card. + + The only way to correct a network saved with the wrong passphrase: + clicking it connects with the stored key, and an access point that + never answers never refuses, so nothing reopens the dialog. + + Args: + wifi_card (str): the interface whose saved networks to list. + """ + forget_menu = Gtk.Menu() + forget_item = Gtk.MenuItem(_("Forget Network")) + forget_item.set_submenu(forget_menu) + forget_menu.connect("show", self.fill_forget_list, wifi_card) + self.menu.append(forget_item) + + def fill_forget_list(self, submenu, wifi_card): + """ + List the saved networks, filled when the submenu is opened. + + These come from the daemon rather than a scan, so a network that is + saved but out of range can still be forgotten. + + Args: + submenu (Gtk.Menu): the submenu to fill. + wifi_card (str): the interface whose saved networks to list. + """ + for child in submenu.get_children(): + submenu.remove(child) + for network in wpa_networks(wifi_card): + entry = Gtk.MenuItem(network['ssid']) + entry.connect("activate", self.forget_ssid, network['ssid'], + wifi_card) + entry.show() + submenu.append(entry) + + def forget_ssid(self, _widget, ssid, wifi_card): + """ + Remove one saved network. + + Args: + _widget (Gtk.Widget): the menu item, unused. + ssid (str): the network to forget. + wifi_card (str): the interface it is saved on. + """ + wpa_reconfigure(wifi_card) + forget_network(ssid, wifi_card) + self.update_info() + + def fill_wifi_list(self, submenu, wifi_card, cssid, passes): + """ + Read the scan table and fill the Available Connections submenu. + + Args: + submenu (Gtk.Menu): the submenu to fill. + wifi_card (str): the interface whose scan results to list. + cssid (str or None): the SSID already connected, if any. + passes (bool): True to leave cssid out of the list. + """ + for child in submenu.get_children(): + submenu.remove(child) + for ssid, network in scan_networks(wifi_card).items(): + if passes and cssid == ssid: + continue + submenu.append(self.ssid_menu_item(network, wifi_card)) + + def configuration_window_open(self, _widget, interface): + """Open the configuration window for one interface. + + Args: + _widget (Gtk.Widget): the menu item, unused. + interface (str): interface name to configure. + """ + open_configuration(interface) + + def connect_open_network(self, _widget, ssid, wifi_card): + """ + Join an open network, asking for nothing. + + Args: + _widget (Gtk.Widget): the menu item, unused. + ssid (str): the network name that was clicked. + wifi_card (str): the interface to connect. + """ + self.connect_attempts = 0 + wpa_reconfigure(wifi_card) + if not ssid_is_saved(ssid, wifi_card) \ + and save_open_network(ssid, wifi_card) is None: + self.connection_failed(ssid, wifi_card) + return + self.start_connection(ssid, None, wifi_card) + + def connect_psk_network(self, _widget, network, wifi_card): + """ + Join a passphrase network, asking for the password if it is new. + + Args: + _widget (Gtk.Widget): the menu item, unused. + network (dict): the clicked network's scan record. + wifi_card (str): the interface to connect. + """ + self.connect_attempts = 0 + if ssid_is_saved(network['ssid'], wifi_card): + wpa_reconfigure(wifi_card) + self.start_connection(network['ssid'], network, wifi_card) else: - self.Authentication(ssid_info, wificard, False) - self.updateinfo() - - def menu_click_enterprise(self, widget, ssid_info, wificard): - ssid_configured = False - try: - with open('/etc/wpa_supplicant.conf', 'r') as conf: - ssid_configured = f'"{ssid_info[0]}"' in conf.read() - except (FileNotFoundError, PermissionError, IOError): - ssid_configured = False - if ssid_configured: - connectToSsid(ssid_info[0], wificard) + self.passphrase_dialog(network, wifi_card, False) + + def connect_enterprise_network(self, _widget, network, wifi_card): + """ + Join an enterprise network, asking for credentials if it is new. + + Args: + _widget (Gtk.Widget): the menu item, unused. + network (dict): the clicked network's scan record. + wifi_card (str): the interface to connect. + """ + self.connect_attempts = 0 + if ssid_is_saved(network['ssid'], wifi_card): + wpa_reconfigure(wifi_card) + self.start_connection(network['ssid'], network, wifi_card, + enterprise=True) else: - self.EnterpriseAuthentication(ssid_info, wificard, False) - self.updateinfo() - - def disconnect_wifi(self, widget, wificard): - wifiDisconnection(wificard) - self.updateinfo() - - def disable_Wifi(self, widget, wificard): - disableWifi(wificard) - self.updateinfo() - - def enable_Wifi(self, widget, wificard): - enableWifi(wificard) - self.updateinfo() - - def connectcard(self, widget, netcard): - startnetworkcard(netcard) - self.updateinfo() - - def disconnectcard(self, widget, netcard): - stopnetworkcard(netcard) - self.updateinfo() - - def connectWG(self, widget, wginfo): - enable_wg(wginfo) - self.updateinfo() - - def disconnectWG(self, widget, wginfo): - disable_wg(wginfo) - self.updateinfo() - - def closeNetwork(self, widget): - stopallnetwork() - self.updateinfo() - - def openNetwork(self, widget): - startallnetwork() - self.updateinfo() - - def signal_icon_name(self, bar, suffix): - if bar > 75: + self.eap_dialog(network, wifi_card, False) + + def disconnect_wifi(self, _widget, wifi_card): + """Drop the current wireless association. + + Args: + _widget (Gtk.Widget): the menu item, unused. + wifi_card (str): wireless interface name. + """ + disconnect_wifi(wifi_card) + self.update_info() + + def disable_wifi(self, _widget, wifi_card): + """Take a wireless interface down. + + Args: + _widget (Gtk.Widget): the menu item, unused. + wifi_card (str): wireless interface name. + """ + disable_wifi(wifi_card) + self.update_info() + + def enable_wifi(self, _widget, wifi_card): + """Bring a wireless interface back up. + + Args: + _widget (Gtk.Widget): the menu item, unused. + wifi_card (str): wireless interface name. + """ + enable_wifi(wifi_card) + self.update_info() + + def enable_card(self, _widget, netcard): + """Start a wired interface. + + Args: + _widget (Gtk.Widget): the menu item, unused. + netcard (str): interface name. + """ + start_network_card(netcard) + self.update_info() + + def disable_card(self, _widget, netcard): + """Stop a wired interface. + + Args: + _widget (Gtk.Widget): the menu item, unused. + netcard (str): interface name. + """ + stop_network_card(netcard) + self.update_info() + + def connect_wg(self, _widget, wg_device): + """Bring a WireGuard tunnel up. + + Args: + _widget (Gtk.Widget): the menu item, unused. + wg_device (str): the tunnel's configuration name, without .conf. + """ + enable_wg(wg_device) + self.update_info() + + def disconnect_wg(self, _widget, wg_device): + """Take a WireGuard tunnel down. + + Args: + _widget (Gtk.Widget): the menu item, unused. + wg_device (str): the tunnel's configuration name, without .conf. + """ + disable_wg(wg_device) + self.update_info() + + def signal_icon_name(self, signal, suffix): + """Pick the signal icon name for a strength percentage. + + Args: + signal (int): signal strength as a percentage. + suffix (str): icon name suffix, "-secure" or empty. + + Returns: + str: an icon name from the nm-signal set. + """ + if signal > 75: icon_name = f"nm-signal-100{suffix}" - elif bar > 50: + elif signal > 50: icon_name = f"nm-signal-75{suffix}" - elif bar > 25: + elif signal > 25: icon_name = f"nm-signal-50{suffix}" - elif bar > 5: + elif signal > 5: icon_name = f"nm-signal-25{suffix}" else: icon_name = f"nm-signal-00{suffix}" return icon_name - def wifi_signal_icon(self, bar, is_secure=False): + def wifi_signal_icon(self, signal, is_secure=False): + """Build the signal strength image shown next to an SSID. + + Args: + signal (int): signal strength as a percentage. + is_secure (bool): True to use the padlocked variant. + + Returns: + Gtk.Image: a realised image widget. + """ img = Gtk.Image() suffix = "" if is_secure: suffix = "-secure" - icon_name = self.signal_icon_name(bar, suffix) + icon_name = self.signal_icon_name(signal, suffix) img.set_from_icon_name(icon_name, Gtk.IconSize.MENU) img.show() return img - def updateinfo(self): - if not self.if_running: - self.if_running = True - self.cardinfo = networkdictionary() - defaultcard = self.cardinfo['default'] - default_type = self.network_type(defaultcard) - GLib.idle_add(self.updatetray, defaultcard, default_type) - self.wginfo = wg_dictionary() - self.if_running = False - - def updatetray(self, defaultdev, default_type): - self.updatetrayicon(defaultdev, default_type) - self.trayStatus(defaultdev) - - def updatetrayloop(self): - while True: - self.updateinfo() - sleep(20) - - def network_type(self, defaultdev): - if defaultdev is None: - return None - elif 'wlan' in defaultdev: - return 'wifi' - else: - return 'wire' + def update_info(self): + """ + Refresh the icon and the tooltip. - def default_wifi_state(self, defaultdev): - info = self.cardinfo['cards'][defaultdev] - if info['state']["connection"] == "Connected": - ssid = info['state']["ssid"] - return info['info'][ssid][4] - else: - return None + The refresh-tick path: only the default-route interface, whether it + is up, and its signal. The menu's data is collected on menu open. + + No lock. tray_state() builds the whole dict before it is assigned, + so a reader sees the old one or the new one, never a half-built one. + """ + self.traystate = tray_state() + GLib.idle_add(self.update_tray) + + def update_tray(self): + """Redraw the tray icon and its tooltip from the last light refresh. + + Returns: + bool: False, so GLib.idle_add does not call this again. + """ + self.update_tray_icon() + self.status_icon.set_tooltip_text(self.traystate['tooltip']) + return False + + def update_tray_loop(self): + """Refresh the tray for the life of the app. - def updatetrayicon(self, defaultdev, card_type): - if card_type is None: + Thirty seconds, two to four commands, about 5 ms. It catches only + passive change: a cable pulled, a link dropping, signal drifting. + Anything the user does refreshes at the end of the action. + + The scan request goes out here too, so the BSS table is warm before + anyone opens the menu. It is asked for, never waited on. + """ + while True: + self.update_info() + request_scan_all() + sleep(30) + + def start_icon_animation(self): + """Begin cycling the signal icons for a connection attempt. + + Returns: + bool: False, so GLib.idle_add does not repeat this. + """ + if self.connecting: + return False + self.connecting = True + self.connect_frame = 0 + # 300 ms a frame: slow enough to read as deliberate, fast enough to + # look busy. + GLib.timeout_add(300, self.next_animation_frame) + return False + + def next_animation_frame(self): + """Draw the next frame of the connecting animation. + + Returns: + bool: True to stay on the timer, False once the attempt has + finished, which removes the timeout. + """ + if not self.connecting: + return False + # Filling bars: the signal icons already shipped, cycled the way + # NetworkManager does it, so there is no new artwork to install. + frames = ('nm-signal-00', 'nm-signal-25', 'nm-signal-50', + 'nm-signal-75', 'nm-signal-100') + self.status_icon.set_from_icon_name( + frames[self.connect_frame % len(frames)] + ) + self.connect_frame += 1 + return True + + def stop_icon_animation(self): + """End the animation and put the real icon back. + + Returns: + bool: False, so GLib.idle_add does not repeat this. + """ + self.connecting = False + self.update_tray() + return False + + def update_tray_icon(self): + """Set the tray icon to match the last light refresh. + + Does nothing while a connection attempt is running, so the refresh + loop cannot overwrite the animation mid-attempt. + """ + if self.connecting: + return + kind = self.traystate['kind'] + if kind is None: icon_name = 'nm-no-connection' - elif card_type == 'wire': + elif kind == 'wire': icon_name = 'nm-device-wired' else: - wifi_state = self.default_wifi_state(defaultdev) + wifi_state = (self.traystate['signal'] + if self.traystate['connection'] == 'Connected' + else None) if wifi_state is None: icon_name = 'nm-no-connection' - elif wifi_state > 80: - icon_name = 'nm-signal-100' - elif wifi_state > 60: - icon_name = 'nm-signal-75' - elif wifi_state > 40: - icon_name = 'nm-signal-50' - elif wifi_state > 20: - icon_name = 'nm-signal-25' else: - icon_name = 'nm-signal-00' - self.statusIcon.set_from_icon_name(icon_name) - - def trayStatus(self, defaultdev): - self.statusIcon.set_tooltip_text(connectionStatus(defaultdev, self.cardinfo)) - - def tray(self): - self.if_running = False - self.thr = threading.Thread(target=self.updatetrayloop) - self.thr.setDaemon(True) + icon_name = self.signal_icon_name(wifi_state, '') + self.status_icon.set_from_icon_name(icon_name) + + def run(self): + """Start the background refresh thread and hand control to GTK. + + This is the only place the refresh thread is created. It starts + before Gtk.main(), so it is already running by the time any click + can arrive, which is why the click handlers do not check on it. + """ + self.thr = threading.Thread(target=self.update_tray_loop) + self.thr.daemon = True self.thr.start() Gtk.main() - def close(self, widget): + def close(self, _widget): + """Hide the passphrase dialog. + + Args: + _widget (Gtk.Widget): the Cancel button, unused. + """ self.window.hide() - def add_to_wpa_supplicant(self, widget, ssid_info, card): + def add_to_wpa_supplicant(self, _widget, network, card): + """ + Save the typed passphrase and start connecting in the background. + + Args: + _widget (Gtk.Widget): the Connect button, unused. + network (dict): the network's scan record. + card (str): the interface to connect. + """ pwd = self.password.get_text() - self.setup_wpa_supplicant(ssid_info[0], ssid_info, pwd, card) + ssid = network['ssid'] + self.window.hide() + wpa_reconfigure(card) + # Change the one field, do not delete and rebuild the block. + if ssid_is_saved(ssid, card): + stored = update_psk_network(ssid, pwd, card) + else: + stored = save_psk_network(ssid, network['security'], pwd, + card) is not None + if not stored: + self.connection_failed(ssid, card) + return + self.start_connection(ssid, network, card) + + def start_connection(self, ssid, network, card, enterprise=False): + """ + Begin joining a network on a worker thread. + + The attempt takes up to thirty seconds, so it must not run on the + GTK thread. + + Args: + ssid (str): the network name to join. + network (dict or None): the network's scan record, needed to + rebuild the credentials dialog if the key is refused. None + for an open network, which has no dialog. + card (str): the interface to connect. + enterprise (bool): True to reopen the EAP dialog rather than the + passphrase one when credentials are refused. + """ _thread.start_new_thread( self.try_to_connect_to_ssid, - (ssid_info[0], ssid_info, card) + (ssid, network, card, enterprise) ) - self.window.hide() - def try_to_connect_to_ssid(self, ssid, ssid_info, card): - if not connectToSsid(ssid, card): - delete_ssid_wpa_supplicant_config(ssid) - GLib.idle_add(self.restart_authentication, ssid_info, card) - else: - for _ in list(range(60)): - if nic_status(card) == 'associated': - self.updateinfo() - break - sleep(1) + def try_to_connect_to_ssid(self, ssid, network, card, enterprise=False): + """ + Connect, then report the outcome. + + Runs on its own thread, so it must reach the UI through + GLib.idle_add. + + Args: + ssid (str): the network name. + network (dict or None): the network's scan record, or None for + an open network. + card (str): the interface to connect. + enterprise (bool): True to reopen the EAP dialog on refusal. + """ + GLib.idle_add(self.start_icon_animation) + try: + if not connect_to_ssid(ssid, card): + GLib.idle_add(self.connection_failed, ssid, card) + return + if wait_for_connection(card, ssid): + # It works, so now it is worth keeping. Until this point the + # network existed only in the daemon. + wpa_save_config(card) else: - delete_ssid_wpa_supplicant_config(ssid) - GLib.idle_add(self.restart_authentication, ssid_info, card) - return + self.connect_attempts += 1 + if network is not None and self.connect_attempts < MAX_ATTEMPTS: + GLib.idle_add(self.reopen_credentials_dialog, network, card, + enterprise) + else: + # Out of tries, or an open network with nothing to + # retype. Either way the block does not work, so it goes. + forget_network(ssid, card) + GLib.idle_add(self.connection_failed, ssid, card, True) + finally: + # select_network disabled the others; leaving them disabled + # would strand the card. + enable_all_networks(card) + # Refresh before the animation ends: stop_icon_animation draws + # whatever traystate holds. + self.update_info() + GLib.idle_add(self.stop_icon_animation) + + def reopen_credentials_dialog(self, network, card, enterprise=False): + """ + Reopen the credentials dialog after an attempt did not connect. + + Args: + network (dict): the network's scan record. + card (str): the interface to connect. + enterprise (bool): True for the EAP dialog, False for the + passphrase one. + """ + if enterprise: + self.eap_dialog(network, card, True) + else: + self.passphrase_dialog(network, card, True) + + def connection_failed(self, ssid, card, timed_out=False): + """ + Tell the user the card never joined, without blaming the password. + + Args: + ssid (str): the network that was not joined. + card (str): the interface that tried. + timed_out (bool): True when an attempt ran and the card never + joined. False when no attempt was made because the daemon + did not answer or refused the network. + + Returns: + bool: False, so GLib.idle_add does not call this again. + """ + if timed_out: + detail = _( + "%(card)s did not associate with %(ssid)s before timing out." + "\n\nA weak signal is the usual cause." + ) % {'card': card, 'ssid': ssid} + else: + detail = _("wpa_supplicant did not accept the request on %s.") % card + dialog = Gtk.MessageDialog( + None, 0, Gtk.MessageType.WARNING, Gtk.ButtonsType.CLOSE, + _("Could not connect to %s") % ssid + ) + dialog.format_secondary_text(detail) + dialog.run() + dialog.destroy() + return False + + def toggle_password_visibility(self, widget): + """ + Show or hide the typed password. + + Args: + widget (Gtk.CheckButton): the "Show password" box. + """ + self.password.set_visibility(widget.get_active()) - def restart_authentication(self, ssid_info, card): - self.Authentication(ssid_info, card, True) + def passphrase_dialog(self, network, card, failed): + """ + Build and show the passphrase dialog for one network. - def on_check(self, widget): - self.password.set_visibility(widget.get_active()) + Args: + network (dict): the network's scan record. + card (str): the interface the password is for. + failed (bool): True when a previous attempt did not connect, + which only changes the heading. - def Authentication(self, ssid_info, card, failed): + Returns: + str: 'Done', kept because the caller has always ignored it. + """ + ssid = network['ssid'] self.window = Gtk.Window() self.window.set_title(_("Wi-Fi Network Authentication Required")) self.window.set_border_width(0) @@ -429,18 +880,18 @@ def Authentication(self, ssid_info, card, failed): box2.set_border_width(10) box1.pack_start(box2, True, True, 0) box2.show() - # Creating MBR or GPT drive if failed: - title = _(f"{ssid_info[0]} Wi-Fi Network Authentication failed") + # A refused key and a card that never answered look identical + # on this driver, so the wording blames neither. + title = _("Could not connect to %s. Try again.") % ssid else: - title = _(f"Authentication required by {ssid_info[0]} Wi-Fi Network") - label = Gtk.Label(f"{title}") - label.set_use_markup(True) + title = _("Authentication required by %s Wi-Fi Network") % ssid + label = _heading_label(title) pwd_label = Gtk.Label(_("Password:")) self.password = Gtk.Entry() self.password.set_visibility(False) check = Gtk.CheckButton(_("Show password")) - check.connect("toggled", self.on_check) + check.connect("toggled", self.toggle_password_visibility) table = Gtk.Table(1, 2, True) table.attach(label, 0, 5, 0, 1) table.attach(pwd_label, 1, 2, 2, 3) @@ -455,7 +906,7 @@ def Authentication(self, ssid_info, card, failed): cancel = Gtk.Button(stock=Gtk.STOCK_CANCEL) cancel.connect("clicked", self.close) connect = Gtk.Button(stock=Gtk.STOCK_CONNECT) - connect.connect("clicked", self.add_to_wpa_supplicant, ssid_info, card) + connect.connect("clicked", self.add_to_wpa_supplicant, network, card) table = Gtk.Table(1, 2, True) table.set_col_spacings(10) table.attach(connect, 4, 5, 0, 1) @@ -464,118 +915,101 @@ def Authentication(self, ssid_info, card, failed): self.window.show_all() return 'Done' - def setup_wpa_supplicant(self, ssid, ssid_info, pwd, card): - # Determine caps string - handle extended ssid_info format - # Index 7 contains the full caps_string (e.g., "RSN HTCAP WME...") - # Index 6 is the short CAPS (e.g., "EPS") which doesn't contain RSN/WPA - caps_string = ssid_info[7] if len(ssid_info) > 7 else ssid_info[-1] - if 'RSN' in caps_string: - # /etc/wpa_supplicant.conf written by networkmgr - ws = '\nnetwork={' - ws += f'\n ssid="{ssid}"' - ws += '\n key_mgmt=WPA-PSK' - ws += '\n proto=RSN' - ws += f'\n psk="{pwd}"\n' - ws += '}\n' - elif 'WPA' in caps_string: - ws = '\nnetwork={' - ws += f'\n ssid="{ssid}"' - ws += '\n key_mgmt=WPA-PSK' - ws += '\n proto=WPA' - ws += f'\n psk="{pwd}"\n' - ws += '}\n' - else: - ws = '\nnetwork={' - ws += f'\n ssid="{ssid}"' - ws += '\n key_mgmt=NONE' - ws += '\n wep_tx_keyidx=0' - ws += f'\n wep_key0={pwd}\n' - ws += '}\n' - import os - old_umask = os.umask(0o077) - try: - with open("/etc/wpa_supplicant.conf", 'a') as wsf: - wsf.write(ws) - finally: - os.umask(old_umask) - - def Open_Wpa_Supplicant(self, ssid, card): - ws = '\nnetwork={' - ws += f'\n ssid="{ssid}"' - ws += '\n key_mgmt=NONE\n}\n' - import os - old_umask = os.umask(0o077) - try: - with open("/etc/wpa_supplicant.conf", 'a') as wsf: - wsf.write(ws) - finally: - os.umask(old_umask) + def add_enterprise_to_wpa_supplicant(self, _widget, network, card): + """ + Collect the EAP form, save it, and start connecting in the background. - def add_enterprise_to_wpa_supplicant(self, widget, ssid_info, card): - """Handle enterprise authentication form submission.""" + Args: + _widget (Gtk.Widget): the Connect button, unused. + network (dict): the network's scan record. + card (str): the interface to connect. + """ eap_config = { 'eap_method': self.eap_method_combo.get_active_text(), 'identity': self.identity_entry.get_text(), 'password': self.eap_password.get_text(), - 'phase2': self.phase2_combo.get_active_text() if hasattr(self, 'phase2_combo') else 'MSCHAPV2', - 'anonymous_identity': self.anon_identity_entry.get_text() if hasattr(self, 'anon_identity_entry') else '', + 'phase2': self.phase2_combo.get_active_text() if self.phase2_combo is not None else 'MSCHAPV2', + 'anonymous_identity': self.anon_identity_entry.get_text() if self.anon_identity_entry is not None else '', } # Get CA certificate path - if hasattr(self, 'ca_cert_chooser'): + if self.ca_cert_chooser is not None: ca_file = self.ca_cert_chooser.get_filename() if ca_file: eap_config['ca_cert'] = ca_file # For TLS, get client certificate and key if eap_config['eap_method'] == 'TLS': - if hasattr(self, 'client_cert_chooser'): + if self.client_cert_chooser is not None: client_cert = self.client_cert_chooser.get_filename() if client_cert: eap_config['client_cert'] = client_cert - if hasattr(self, 'private_key_chooser'): + if self.private_key_chooser is not None: private_key = self.private_key_chooser.get_filename() if private_key: eap_config['private_key'] = private_key - if hasattr(self, 'private_key_passwd'): + if self.private_key_passwd is not None: eap_config['private_key_passwd'] = self.private_key_passwd.get_text() - write_eap_config(ssid_info[0], eap_config) - _thread.start_new_thread( - self.try_to_connect_to_ssid, - (ssid_info[0], ssid_info, card) - ) self.eap_window.hide() + wpa_reconfigure(card) + if save_eap_network(network['ssid'], eap_config, card) is None: + self.connection_failed(network['ssid'], card) + return + self.start_connection(network['ssid'], network, card, enterprise=True) def on_eap_method_changed(self, combo): - """Update dialog fields based on selected EAP method.""" - method = combo.get_active_text() + """Show only the fields the chosen EAP method uses. - # Show/hide TLS-specific fields - tls_mode = method == 'TLS' - if hasattr(self, 'client_cert_box'): - self.client_cert_box.set_visible(tls_mode) - if hasattr(self, 'private_key_box'): - self.private_key_box.set_visible(tls_mode) - if hasattr(self, 'private_key_passwd_box'): - self.private_key_passwd_box.set_visible(tls_mode) + Each row is a label and a field, hidden as a pair. Hiding the field + alone leaves its label naming an empty cell. - # Show/hide password field (not needed for TLS) - if hasattr(self, 'password_box'): - self.password_box.set_visible(not tls_mode) + Args: + combo (Gtk.ComboBoxText): the EAP method chooser. + """ + method = combo.get_active_text() + tls = method == 'TLS' + shown = { + 'phase2': method in ('PEAP', 'TTLS'), + 'password': not tls, + 'client_cert': tls, + 'private_key': tls, + 'private_key_passwd': tls, + } + for name, visible in shown.items(): + for widget in self.eap_rows.get(name, ()): + widget.set_visible(visible) - # Show/hide phase2 (only for PEAP/TTLS) - if hasattr(self, 'phase2_box'): - self.phase2_box.set_visible(method in ('PEAP', 'TTLS')) + def close_eap_window(self, _widget): + """Hide the enterprise credentials dialog. - def close_eap_window(self, widget): + Args: + _widget (Gtk.Widget): the Cancel button, unused. + """ self.eap_window.hide() def on_eap_password_check(self, widget): + """Show or hide the typed EAP password. + + Args: + widget (Gtk.CheckButton): the "show password" box. + """ self.eap_password.set_visibility(widget.get_active()) - def EnterpriseAuthentication(self, ssid_info, card, failed): - """Create authentication dialog for WPA-Enterprise networks.""" + def eap_dialog(self, network, card, failed): + """ + Build and show the EAP credentials dialog for one network. + + Args: + network (dict): the network's scan record. + card (str): the interface the credentials are for. + failed (bool): True to title the window as a failed attempt. + + Returns: + str: 'Done', kept because the caller has always ignored it. + """ + ssid = network['ssid'] + self.eap_rows = {} self.eap_window = Gtk.Window() self.eap_window.set_title(_("Enterprise Wi-Fi Authentication")) self.eap_window.set_border_width(10) @@ -586,16 +1020,13 @@ def EnterpriseAuthentication(self, ssid_info, card, failed): # Title if failed: - title_text = _("%s Enterprise Authentication Failed") % ssid_info[0] + title_text = _("%s Enterprise Authentication Failed") % ssid else: - title_text = _("Enterprise Authentication for %s") % ssid_info[0] - title_label = Gtk.Label() - title_label.set_markup(f"{title_text}") + title_text = _("Enterprise Authentication for %s") % ssid + title_label = _heading_label(title_text) main_box.pack_start(title_label, False, False, 5) - # Security info - security_type = ssid_info[7] if len(ssid_info) > 7 else "WPA2-EAP" - security_label = Gtk.Label(_("Security: %s") % security_type) + security_label = Gtk.Label(_("Security: %s") % network['security']) main_box.pack_start(security_label, False, False, 0) # Grid for form fields @@ -619,7 +1050,7 @@ def EnterpriseAuthentication(self, ssid_info, card, failed): row += 1 # Phase 2 Authentication (inner method) - self.phase2_box = Gtk.HBox(spacing=5) + phase2_box = Gtk.HBox(spacing=5) phase2_label = Gtk.Label(_("Inner Auth:")) phase2_label.set_halign(Gtk.Align.END) grid.attach(phase2_label, 0, row, 1, 1) @@ -627,8 +1058,9 @@ def EnterpriseAuthentication(self, ssid_info, card, failed): for method in PHASE2_METHODS: self.phase2_combo.append_text(method) self.phase2_combo.set_active(0) # Default to MSCHAPV2 - self.phase2_box.pack_start(self.phase2_combo, True, True, 0) - grid.attach(self.phase2_box, 1, row, 2, 1) + phase2_box.pack_start(self.phase2_combo, True, True, 0) + grid.attach(phase2_box, 1, row, 2, 1) + self.eap_rows['phase2'] = (phase2_label, phase2_box) row += 1 # Identity (Username) @@ -650,7 +1082,7 @@ def EnterpriseAuthentication(self, ssid_info, card, failed): row += 1 # Password - self.password_box = Gtk.VBox() + password_box = Gtk.VBox() pwd_label = Gtk.Label(_("Password:")) pwd_label.set_halign(Gtk.Align.END) grid.attach(pwd_label, 0, row, 1, 1) @@ -662,8 +1094,9 @@ def EnterpriseAuthentication(self, ssid_info, card, failed): show_pwd_check = Gtk.CheckButton(_("Show")) show_pwd_check.connect("toggled", self.on_eap_password_check) pwd_hbox.pack_start(show_pwd_check, False, False, 0) - self.password_box.pack_start(pwd_hbox, True, True, 0) - grid.attach(self.password_box, 1, row, 2, 1) + password_box.pack_start(pwd_hbox, True, True, 0) + grid.attach(password_box, 1, row, 2, 1) + self.eap_rows['password'] = (pwd_label, password_box) row += 1 # CA Certificate @@ -691,7 +1124,7 @@ def EnterpriseAuthentication(self, ssid_info, card, failed): # TLS-specific fields (hidden by default) # Client Certificate - self.client_cert_box = Gtk.HBox(spacing=5) + client_cert_box = Gtk.HBox(spacing=5) client_cert_label = Gtk.Label(_("Client Cert:")) client_cert_label.set_halign(Gtk.Align.END) grid.attach(client_cert_label, 0, row, 1, 1) @@ -700,13 +1133,13 @@ def EnterpriseAuthentication(self, ssid_info, card, failed): action=Gtk.FileChooserAction.OPEN ) self.client_cert_chooser.add_filter(ca_filter) - self.client_cert_box.pack_start(self.client_cert_chooser, True, True, 0) - grid.attach(self.client_cert_box, 1, row, 2, 1) - self.client_cert_box.set_visible(False) + client_cert_box.pack_start(self.client_cert_chooser, True, True, 0) + grid.attach(client_cert_box, 1, row, 2, 1) + self.eap_rows['client_cert'] = (client_cert_label, client_cert_box) row += 1 # Private Key - self.private_key_box = Gtk.HBox(spacing=5) + private_key_box = Gtk.HBox(spacing=5) key_label = Gtk.Label(_("Private Key:")) key_label.set_halign(Gtk.Align.END) grid.attach(key_label, 0, row, 1, 1) @@ -720,21 +1153,22 @@ def EnterpriseAuthentication(self, ssid_info, card, failed): key_filter.add_pattern("*.key") key_filter.add_pattern("*.p12") self.private_key_chooser.add_filter(key_filter) - self.private_key_box.pack_start(self.private_key_chooser, True, True, 0) - grid.attach(self.private_key_box, 1, row, 2, 1) - self.private_key_box.set_visible(False) + private_key_box.pack_start(self.private_key_chooser, True, True, 0) + grid.attach(private_key_box, 1, row, 2, 1) + self.eap_rows['private_key'] = (key_label, private_key_box) row += 1 # Private Key Password - self.private_key_passwd_box = Gtk.HBox(spacing=5) + private_key_passwd_box = Gtk.HBox(spacing=5) key_pwd_label = Gtk.Label(_("Key Password:")) key_pwd_label.set_halign(Gtk.Align.END) grid.attach(key_pwd_label, 0, row, 1, 1) self.private_key_passwd = Gtk.Entry() self.private_key_passwd.set_visibility(False) - self.private_key_passwd_box.pack_start(self.private_key_passwd, True, True, 0) - grid.attach(self.private_key_passwd_box, 1, row, 2, 1) - self.private_key_passwd_box.set_visible(False) + private_key_passwd_box.pack_start(self.private_key_passwd, True, True, 0) + grid.attach(private_key_passwd_box, 1, row, 2, 1) + self.eap_rows['private_key_passwd'] = (key_pwd_label, + private_key_passwd_box) row += 1 # Buttons @@ -747,7 +1181,7 @@ def EnterpriseAuthentication(self, ssid_info, card, failed): button_box.pack_start(cancel_btn, False, False, 0) connect_btn = Gtk.Button(stock=Gtk.STOCK_CONNECT) - connect_btn.connect("clicked", self.add_enterprise_to_wpa_supplicant, ssid_info, card) + connect_btn.connect("clicked", self.add_enterprise_to_wpa_supplicant, network, card) button_box.pack_start(connect_btn, False, False, 0) self.eap_window.show_all() diff --git a/NetworkMgr/wg_api.py b/NetworkMgr/wg_api.py index e762d1e..3d9b07f 100644 --- a/NetworkMgr/wg_api.py +++ b/NetworkMgr/wg_api.py @@ -1,29 +1,60 @@ -#!/usr/local/bin/python3.11 +#!/usr/bin/env python +"""WireGuard tunnel discovery and control. + +Lists the tunnel configurations under $PREFIX/etc/wireguard, reports which +are running, and brings them up or down through the wireguard rc service. +""" + +import os +import sys from platform import system -from subprocess import PIPE, run, os +from subprocess import PIPE, run PREFIX = '/usr/local' if system() == 'FreeBSD' else sys.prefix WG_CONFIG_PATH = f'{PREFIX}/etc/wireguard/' + def wg_service_state(): - """Function returns the WireGuard service status.""" - result = run(['service', 'wireguard', 'rcvar'], stdout=PIPE, stderr=PIPE, check=False) - out = result.stdout.decode('utf-8') + """Report whether rc is set to manage the WireGuard tunnels. + + Asked through sysrc rather than `service wireguard rcvar`, which answers + the same question but wraps it in four lines of prose that have to be + parsed, and quotes the value so every caller has to compare against + '"NO"'. sysrc gives the bare value. + + Returns: + str: 'YES' or 'NO'. A variable set nowhere reads as 'NO', which is + the rc script's own default: line 69 of + /usr/local/etc/rc.d/wireguard is `: ${wireguard_enable="NO"}`, + and sysrc reports an unset variable as an error rather than a + value. + """ + result = run(['sysrc', '-n', 'wireguard_enable'], + stdout=PIPE, stderr=PIPE, check=False, text=True) + if result.returncode != 0: + return 'NO' + return result.stdout.strip() or 'NO' - state = 'Unknown' - for line in out.splitlines(): - if "wireguard_enable=" in line: - state = line.split('=')[1].strip() - break +def wg_dictionary(service_state): + """Collect the WireGuard tunnels and their state. - return state + The service state is passed in rather than read here. Asking rc for it + costs about 13 ms of shell, against under 1 ms for everything else in + this function, and `wireguard_enable` only changes when someone edits + rc.conf. Reading it once and handing it down keeps the tray refresh + cheap. -def wg_dictionary(): - """Function returns the WireGuard configurations.""" + Args: + service_state (str): 'YES' or 'NO' from wg_service_state(). + + Returns: + dict: 'service' as given, 'default', and 'configs' mapping each + tunnel device to its 'state' and its 'info' display name. + """ maindictionary = { - 'service': wg_service_state(), + 'service': service_state, 'default': '', } configs = {} @@ -46,13 +77,24 @@ def wg_dictionary(): maindictionary['configs'] = configs return maindictionary + def disable_wg(wgconfig): - """Function disable the specified WireGuard configuration (device).""" - run(f'wg-quick down {wgconfig}', shell=True, check=False) + """Take the specified WireGuard configuration (device) down. + + Args: + wgconfig (str): the tunnel's configuration name, without .conf. + """ + run(['wg-quick', 'down', wgconfig], check=False) + def enable_wg(wgconfig): - """Function enable the specified WireGuard configuration (device).""" - run(f'wg-quick up {wgconfig}', shell=True, check=False) + """Bring the specified WireGuard configuration (device) up. + + Args: + wgconfig (str): the tunnel's configuration name, without .conf. + """ + run(['wg-quick', 'up', wgconfig], check=False) + def wg_status(wgconfig): """Function returning the WireGuard configuration (device) is connected or not.""" diff --git a/networkmgr b/networkmgr index b804412..6675df0 100755 --- a/networkmgr +++ b/networkmgr @@ -1,8 +1,8 @@ #!/usr/bin/env python import signal -from NetworkMgr.trayicon import trayIcon +from NetworkMgr.trayicon import TrayIcon signal.signal(signal.SIGINT, signal.SIG_DFL) -trayIcon().tray() +TrayIcon().run() diff --git a/networkmgr_configuration b/networkmgr_configuration index 9b57566..4282571 100755 --- a/networkmgr_configuration +++ b/networkmgr_configuration @@ -5,4 +5,4 @@ from NetworkMgr import configuration signal.signal(signal.SIGINT, signal.SIG_DFL) -configuration.network_card_configuration_window() +configuration.open_default_configuration() diff --git a/po/de.po b/po/de.po index 047fd63..4175d6a 100644 --- a/po/de.po +++ b/po/de.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-12-14 17:25+0000\n" +"POT-Creation-Date: 2026-09-11 21:33-0300\n" "PO-Revision-Date: 2023-12-14 17:25+0000\n" "Last-Translator: Joshua Hoffmann \n" "Language-Team: Language locale/de\n" @@ -17,97 +17,236 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: src/trayicon.py:67 +#: NetworkMgr/trayicon.py:173 +msgid "WireGuard VPN" +msgstr "" + +#: NetworkMgr/trayicon.py:182 +#, fuzzy, python-format +msgid "%s Connected" +msgstr "WLAN %s verbunden" + +#: NetworkMgr/trayicon.py:185 NetworkMgr/trayicon.py:219 +#: NetworkMgr/trayicon.py:230 +#, fuzzy, python-format +msgid "Disable %s" +msgstr "Deaktivieren" + +#: NetworkMgr/trayicon.py:189 +#, fuzzy, python-format +msgid "%s Disconnected" +msgstr "WLAN %s getrennt" + +#: NetworkMgr/trayicon.py:192 NetworkMgr/trayicon.py:212 +msgid "Enable" +msgstr "Aktivieren" + +#: NetworkMgr/trayicon.py:198 msgid "Ethernet Network" msgstr "Ethernet-Netzwerk" -#: src/trayicon.py:78 +#: NetworkMgr/trayicon.py:209 +#, fuzzy, python-format +msgid "Wired %s Disabled" +msgstr "WLAN %s deaktiviert" + +#: NetworkMgr/trayicon.py:216 #, python-format msgid "Wired %s Connected" msgstr "Kabel an %s angeschlossen" -#: src/trayicon.py:81 -msgid "Disable" -msgstr "Deaktivieren" +#: NetworkMgr/trayicon.py:223 NetworkMgr/trayicon.py:234 +#: NetworkMgr/trayicon.py:281 +#, python-format +msgid "Configure %s" +msgstr "" -#: src/trayicon.py:86 +#: NetworkMgr/trayicon.py:227 #, python-format msgid "Wired %s Disconnected" msgstr "Kabel von %s getrennt" -#: src/trayicon.py:89 -msgid "Enable" -msgstr "Aktivieren" - -#: src/trayicon.py:93 +#: NetworkMgr/trayicon.py:238 #, python-format msgid "Wired %s Unplug" msgstr "Kabel von %s abgesteckt" -#: src/trayicon.py:101 +#: NetworkMgr/trayicon.py:246 #, python-format msgid "WiFi %s Disabled" msgstr "WLAN %s deaktiviert" -#: src/trayicon.py:104 +#: NetworkMgr/trayicon.py:249 #, python-format msgid "Enable Wifi %s" msgstr "WLAN %s aktivieren" -#: src/trayicon.py:109 +#: NetworkMgr/trayicon.py:254 #, python-format msgid "WiFi %s Disconnected" msgstr "WLAN %s getrennt" -#: src/trayicon.py:113 src/trayicon.py:131 +#: NetworkMgr/trayicon.py:259 NetworkMgr/trayicon.py:278 #, python-format msgid "Disable Wifi %s" msgstr "WLAN %s deaktivieren" -#: src/trayicon.py:119 +#: NetworkMgr/trayicon.py:265 #, python-format msgid "WiFi %s Connected" msgstr "WLAN %s verbunden" -#: src/trayicon.py:125 +#: NetworkMgr/trayicon.py:271 #, python-format msgid "Disconnect from %s" msgstr "%s trennen" -#: src/trayicon.py:139 -msgid "Enable Networking" -msgstr "Netzwerk aktivieren" - -#: src/trayicon.py:143 -msgid "Disable Networking" +#: NetworkMgr/trayicon.py:287 +#, fuzzy +msgid "Restart Networking" msgstr "Netzwerkverbindungen deaktivieren" -#: src/trayicon.py:148 +#: NetworkMgr/trayicon.py:290 msgid "Close Network Manager" msgstr "Network Manager schließen" -#: src/trayicon.py:156 +#: NetworkMgr/trayicon.py:312 +#, python-format +msgid "%s (WPA3 only)" +msgstr "" + +#: NetworkMgr/trayicon.py:349 msgid "Available Connections" msgstr "Verfügbare Verbindungen" -#: src/trayicon.py:364 +#: NetworkMgr/trayicon.py:367 +#, fuzzy +msgid "Forget Network" +msgstr "Ethernet-Netzwerk" + +#: NetworkMgr/trayicon.py:835 +#, python-format +msgid "" +"%(card)s did not associate with %(ssid)s before timing out.\n" +"\n" +"A weak signal is the usual cause." +msgstr "" + +#: NetworkMgr/trayicon.py:839 +#, python-format +msgid "wpa_supplicant did not accept the request on %s." +msgstr "" + +#: NetworkMgr/trayicon.py:842 +#, python-format +msgid "Could not connect to %s" +msgstr "" + +#: NetworkMgr/trayicon.py:873 msgid "Wi-Fi Network Authentication Required" msgstr "WLAN erfordert Legitimierung" -#: src/trayicon.py:376 -#, python-brace-format -msgid "{ssid_info[0]} Wi-Fi Network Authentication failed" -msgstr "Legitimierung für WLAN {ssid_info[0]} fehlgeschlagen" +#: NetworkMgr/trayicon.py:886 +#, python-format +msgid "Could not connect to %s. Try again." +msgstr "" -#: src/trayicon.py:378 -#, python-brace-format -msgid "Authentication required by {ssid_info[0]} Wi-Fi Network" +#: NetworkMgr/trayicon.py:888 +#, fuzzy, python-format +msgid "Authentication required by %s Wi-Fi Network" msgstr "Legitimierung für WLAN {ssid_info[0]} erforderlich" -#: src/trayicon.py:381 +#: NetworkMgr/trayicon.py:890 NetworkMgr/trayicon.py:1086 msgid "Password:" msgstr "Passwort:" -#: src/trayicon.py:384 +#: NetworkMgr/trayicon.py:893 msgid "Show password" msgstr "Passwort anzeigen" + +#: NetworkMgr/trayicon.py:1014 +msgid "Enterprise Wi-Fi Authentication" +msgstr "" + +#: NetworkMgr/trayicon.py:1023 +#, fuzzy, python-format +msgid "%s Enterprise Authentication Failed" +msgstr "WLAN erfordert Legitimierung" + +#: NetworkMgr/trayicon.py:1025 +#, python-format +msgid "Enterprise Authentication for %s" +msgstr "" + +#: NetworkMgr/trayicon.py:1029 +#, python-format +msgid "Security: %s" +msgstr "" + +#: NetworkMgr/trayicon.py:1041 +msgid "EAP Method:" +msgstr "" + +#: NetworkMgr/trayicon.py:1054 +msgid "Inner Auth:" +msgstr "" + +#: NetworkMgr/trayicon.py:1067 +msgid "Username:" +msgstr "" + +#: NetworkMgr/trayicon.py:1076 +msgid "Anonymous ID:" +msgstr "" + +#: NetworkMgr/trayicon.py:1080 +msgid "Optional - for privacy" +msgstr "" + +#: NetworkMgr/trayicon.py:1094 +msgid "Show" +msgstr "" + +#: NetworkMgr/trayicon.py:1103 +msgid "CA Certificate:" +msgstr "" + +#: NetworkMgr/trayicon.py:1108 +msgid "Select CA Certificate" +msgstr "" + +#: NetworkMgr/trayicon.py:1116 +msgid "Certificates (*.pem, *.crt, *.cer)" +msgstr "" + +#: NetworkMgr/trayicon.py:1128 +msgid "Client Cert:" +msgstr "" + +#: NetworkMgr/trayicon.py:1132 +msgid "Select Client Certificate" +msgstr "" + +#: NetworkMgr/trayicon.py:1143 +msgid "Private Key:" +msgstr "" + +#: NetworkMgr/trayicon.py:1147 +msgid "Select Private Key" +msgstr "" + +#: NetworkMgr/trayicon.py:1151 +msgid "Key files (*.pem, *.key, *.p12)" +msgstr "" + +#: NetworkMgr/trayicon.py:1163 +#, fuzzy +msgid "Key Password:" +msgstr "Passwort:" + +#~ msgid "Enable Networking" +#~ msgstr "Netzwerk aktivieren" + +#, python-brace-format +#~ msgid "{ssid_info[0]} Wi-Fi Network Authentication failed" +#~ msgstr "Legitimierung für WLAN {ssid_info[0]} fehlgeschlagen" diff --git a/po/nb_NO.po b/po/nb_NO.po index 5052424..3b57936 100644 --- a/po/nb_NO.po +++ b/po/nb_NO.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-12-14 17:25+0000\n" +"POT-Creation-Date: 2026-09-11 21:33-0300\n" "PO-Revision-Date: 2023-12-14 17:25+0000\n" "Last-Translator: Joshua Hoffmann \n" "Language-Team: Language locale/nb_NO\n" @@ -17,97 +17,236 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: src/trayicon.py:67 +#: NetworkMgr/trayicon.py:173 +msgid "WireGuard VPN" +msgstr "" + +#: NetworkMgr/trayicon.py:182 +#, fuzzy, python-format +msgid "%s Connected" +msgstr "WiFi %s tillkoblet" + +#: NetworkMgr/trayicon.py:185 NetworkMgr/trayicon.py:219 +#: NetworkMgr/trayicon.py:230 +#, fuzzy, python-format +msgid "Disable %s" +msgstr "Deaktiver" + +#: NetworkMgr/trayicon.py:189 +#, fuzzy, python-format +msgid "%s Disconnected" +msgstr "WiFi %s frakoblet" + +#: NetworkMgr/trayicon.py:192 NetworkMgr/trayicon.py:212 +msgid "Enable" +msgstr "Aktiver" + +#: NetworkMgr/trayicon.py:198 msgid "Ethernet Network" msgstr "Ethernet-nettverk" -#: src/trayicon.py:78 +#: NetworkMgr/trayicon.py:209 +#, fuzzy, python-format +msgid "Wired %s Disabled" +msgstr "WiFi %s deaktivert" + +#: NetworkMgr/trayicon.py:216 #, python-format msgid "Wired %s Connected" msgstr "Kablet %s er tilkoblet" -#: src/trayicon.py:81 -msgid "Disable" -msgstr "Deaktiver" +#: NetworkMgr/trayicon.py:223 NetworkMgr/trayicon.py:234 +#: NetworkMgr/trayicon.py:281 +#, python-format +msgid "Configure %s" +msgstr "" -#: src/trayicon.py:86 +#: NetworkMgr/trayicon.py:227 #, python-format msgid "Wired %s Disconnected" msgstr "Kablet forbindelse %s er frakoblet" -#: src/trayicon.py:89 -msgid "Enable" -msgstr "Aktiver" - -#: src/trayicon.py:93 +#: NetworkMgr/trayicon.py:238 #, python-format msgid "Wired %s Unplug" msgstr "Kablet forbindelse %s er frakoblet" -#: src/trayicon.py:101 +#: NetworkMgr/trayicon.py:246 #, python-format msgid "WiFi %s Disabled" msgstr "WiFi %s deaktivert" -#: src/trayicon.py:104 +#: NetworkMgr/trayicon.py:249 #, python-format msgid "Enable Wifi %s" msgstr "Aktiver WiFi %s" -#: src/trayicon.py:109 +#: NetworkMgr/trayicon.py:254 #, python-format msgid "WiFi %s Disconnected" msgstr "WiFi %s frakoblet" -#: src/trayicon.py:113 src/trayicon.py:131 +#: NetworkMgr/trayicon.py:259 NetworkMgr/trayicon.py:278 #, python-format msgid "Disable Wifi %s" msgstr "Deaktiver WiFi %s" -#: src/trayicon.py:119 +#: NetworkMgr/trayicon.py:265 #, python-format msgid "WiFi %s Connected" msgstr "WiFi %s tillkoblet" -#: src/trayicon.py:125 +#: NetworkMgr/trayicon.py:271 #, python-format msgid "Disconnect from %s" msgstr "Koble fra %s" -#: src/trayicon.py:139 -msgid "Enable Networking" -msgstr "Aktiver nettverksfunksjon" - -#: src/trayicon.py:143 -msgid "Disable Networking" +#: NetworkMgr/trayicon.py:287 +#, fuzzy +msgid "Restart Networking" msgstr "Deaktiver nettverkstilkoblinger" -#: src/trayicon.py:148 +#: NetworkMgr/trayicon.py:290 msgid "Close Network Manager" msgstr "Lukk Network Manager" -#: src/trayicon.py:156 +#: NetworkMgr/trayicon.py:312 +#, python-format +msgid "%s (WPA3 only)" +msgstr "" + +#: NetworkMgr/trayicon.py:349 msgid "Available Connections" msgstr "Tilgjengelige forbindelser" -#: src/trayicon.py:364 +#: NetworkMgr/trayicon.py:367 +#, fuzzy +msgid "Forget Network" +msgstr "Ethernet-nettverk" + +#: NetworkMgr/trayicon.py:835 +#, python-format +msgid "" +"%(card)s did not associate with %(ssid)s before timing out.\n" +"\n" +"A weak signal is the usual cause." +msgstr "" + +#: NetworkMgr/trayicon.py:839 +#, python-format +msgid "wpa_supplicant did not accept the request on %s." +msgstr "" + +#: NetworkMgr/trayicon.py:842 +#, python-format +msgid "Could not connect to %s" +msgstr "" + +#: NetworkMgr/trayicon.py:873 msgid "Wi-Fi Network Authentication Required" msgstr "Wi-Fi-nettverksautentisering kreves" -#: src/trayicon.py:376 -#, python-brace-format -msgid "{ssid_info[0]} Wi-Fi Network Authentication failed" -msgstr "{ssid_info[0]} Wi-Fi-nettverksautentisering mislyktes" +#: NetworkMgr/trayicon.py:886 +#, python-format +msgid "Could not connect to %s. Try again." +msgstr "" -#: src/trayicon.py:378 -#, python-brace-format -msgid "Authentication required by {ssid_info[0]} Wi-Fi Network" +#: NetworkMgr/trayicon.py:888 +#, fuzzy, python-format +msgid "Authentication required by %s Wi-Fi Network" msgstr "Autentisering kreves av {ssid_info[0]} Wi-Fi-nettverk" -#: src/trayicon.py:381 +#: NetworkMgr/trayicon.py:890 NetworkMgr/trayicon.py:1086 msgid "Password:" msgstr "Passord:" -#: src/trayicon.py:384 +#: NetworkMgr/trayicon.py:893 msgid "Show password" msgstr "Vis passord" + +#: NetworkMgr/trayicon.py:1014 +msgid "Enterprise Wi-Fi Authentication" +msgstr "" + +#: NetworkMgr/trayicon.py:1023 +#, fuzzy, python-format +msgid "%s Enterprise Authentication Failed" +msgstr "Wi-Fi-nettverksautentisering kreves" + +#: NetworkMgr/trayicon.py:1025 +#, python-format +msgid "Enterprise Authentication for %s" +msgstr "" + +#: NetworkMgr/trayicon.py:1029 +#, python-format +msgid "Security: %s" +msgstr "" + +#: NetworkMgr/trayicon.py:1041 +msgid "EAP Method:" +msgstr "" + +#: NetworkMgr/trayicon.py:1054 +msgid "Inner Auth:" +msgstr "" + +#: NetworkMgr/trayicon.py:1067 +msgid "Username:" +msgstr "" + +#: NetworkMgr/trayicon.py:1076 +msgid "Anonymous ID:" +msgstr "" + +#: NetworkMgr/trayicon.py:1080 +msgid "Optional - for privacy" +msgstr "" + +#: NetworkMgr/trayicon.py:1094 +msgid "Show" +msgstr "" + +#: NetworkMgr/trayicon.py:1103 +msgid "CA Certificate:" +msgstr "" + +#: NetworkMgr/trayicon.py:1108 +msgid "Select CA Certificate" +msgstr "" + +#: NetworkMgr/trayicon.py:1116 +msgid "Certificates (*.pem, *.crt, *.cer)" +msgstr "" + +#: NetworkMgr/trayicon.py:1128 +msgid "Client Cert:" +msgstr "" + +#: NetworkMgr/trayicon.py:1132 +msgid "Select Client Certificate" +msgstr "" + +#: NetworkMgr/trayicon.py:1143 +msgid "Private Key:" +msgstr "" + +#: NetworkMgr/trayicon.py:1147 +msgid "Select Private Key" +msgstr "" + +#: NetworkMgr/trayicon.py:1151 +msgid "Key files (*.pem, *.key, *.p12)" +msgstr "" + +#: NetworkMgr/trayicon.py:1163 +#, fuzzy +msgid "Key Password:" +msgstr "Passord:" + +#~ msgid "Enable Networking" +#~ msgstr "Aktiver nettverksfunksjon" + +#, python-brace-format +#~ msgid "{ssid_info[0]} Wi-Fi Network Authentication failed" +#~ msgstr "{ssid_info[0]} Wi-Fi-nettverksautentisering mislyktes" diff --git a/po/networkmgr.pot b/po/networkmgr.pot index 319dc7d..8c871c0 100644 --- a/po/networkmgr.pot +++ b/po/networkmgr.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-08-29 15:54+0800\n" +"POT-Creation-Date: 2026-09-11 21:33-0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,97 +17,226 @@ msgstr "" "Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -#: src/trayicon.py:67 -msgid "Ethernet Network" +#: NetworkMgr/trayicon.py:173 +msgid "WireGuard VPN" msgstr "" -#: src/trayicon.py:78 +#: NetworkMgr/trayicon.py:182 #, python-format -msgid "Wired %s Connected" +msgid "%s Connected" msgstr "" -#: src/trayicon.py:81 -msgid "Disable" +#: NetworkMgr/trayicon.py:185 NetworkMgr/trayicon.py:219 +#: NetworkMgr/trayicon.py:230 +#, python-format +msgid "Disable %s" msgstr "" -#: src/trayicon.py:86 +#: NetworkMgr/trayicon.py:189 #, python-format -msgid "Wired %s Disconnected" +msgid "%s Disconnected" msgstr "" -#: src/trayicon.py:89 +#: NetworkMgr/trayicon.py:192 NetworkMgr/trayicon.py:212 msgid "Enable" msgstr "" -#: src/trayicon.py:93 +#: NetworkMgr/trayicon.py:198 +msgid "Ethernet Network" +msgstr "" + +#: NetworkMgr/trayicon.py:209 +#, python-format +msgid "Wired %s Disabled" +msgstr "" + +#: NetworkMgr/trayicon.py:216 +#, python-format +msgid "Wired %s Connected" +msgstr "" + +#: NetworkMgr/trayicon.py:223 NetworkMgr/trayicon.py:234 +#: NetworkMgr/trayicon.py:281 +#, python-format +msgid "Configure %s" +msgstr "" + +#: NetworkMgr/trayicon.py:227 +#, python-format +msgid "Wired %s Disconnected" +msgstr "" + +#: NetworkMgr/trayicon.py:238 #, python-format msgid "Wired %s Unplug" msgstr "" -#: src/trayicon.py:101 +#: NetworkMgr/trayicon.py:246 #, python-format msgid "WiFi %s Disabled" msgstr "" -#: src/trayicon.py:104 +#: NetworkMgr/trayicon.py:249 #, python-format msgid "Enable Wifi %s" msgstr "" -#: src/trayicon.py:109 +#: NetworkMgr/trayicon.py:254 #, python-format msgid "WiFi %s Disconnected" msgstr "" -#: src/trayicon.py:113 src/trayicon.py:131 +#: NetworkMgr/trayicon.py:259 NetworkMgr/trayicon.py:278 #, python-format msgid "Disable Wifi %s" msgstr "" -#: src/trayicon.py:119 +#: NetworkMgr/trayicon.py:265 #, python-format msgid "WiFi %s Connected" msgstr "" -#: src/trayicon.py:125 +#: NetworkMgr/trayicon.py:271 #, python-format msgid "Disconnect from %s" msgstr "" -#: src/trayicon.py:139 -msgid "Enable Networking" +#: NetworkMgr/trayicon.py:287 +msgid "Restart Networking" msgstr "" -#: src/trayicon.py:143 -msgid "Disable Networking" +#: NetworkMgr/trayicon.py:290 +msgid "Close Network Manager" msgstr "" -#: src/trayicon.py:148 -msgid "Close Network Manager" +#: NetworkMgr/trayicon.py:312 +#, python-format +msgid "%s (WPA3 only)" msgstr "" -#: src/trayicon.py:156 +#: NetworkMgr/trayicon.py:349 msgid "Available Connections" msgstr "" -#: src/trayicon.py:364 +#: NetworkMgr/trayicon.py:367 +msgid "Forget Network" +msgstr "" + +#: NetworkMgr/trayicon.py:835 +#, python-format +msgid "" +"%(card)s did not associate with %(ssid)s before timing out.\n" +"\n" +"A weak signal is the usual cause." +msgstr "" + +#: NetworkMgr/trayicon.py:839 +#, python-format +msgid "wpa_supplicant did not accept the request on %s." +msgstr "" + +#: NetworkMgr/trayicon.py:842 +#, python-format +msgid "Could not connect to %s" +msgstr "" + +#: NetworkMgr/trayicon.py:873 msgid "Wi-Fi Network Authentication Required" msgstr "" -#: src/trayicon.py:376 -#, python-brace-format -msgid "{ssid_info[0]} Wi-Fi Network Authentication failed" +#: NetworkMgr/trayicon.py:886 +#, python-format +msgid "Could not connect to %s. Try again." msgstr "" -#: src/trayicon.py:378 -#, python-brace-format -msgid "Authentication required by {ssid_info[0]} Wi-Fi Network" +#: NetworkMgr/trayicon.py:888 +#, python-format +msgid "Authentication required by %s Wi-Fi Network" msgstr "" -#: src/trayicon.py:381 +#: NetworkMgr/trayicon.py:890 NetworkMgr/trayicon.py:1086 msgid "Password:" msgstr "" -#: src/trayicon.py:384 +#: NetworkMgr/trayicon.py:893 msgid "Show password" msgstr "" + +#: NetworkMgr/trayicon.py:1014 +msgid "Enterprise Wi-Fi Authentication" +msgstr "" + +#: NetworkMgr/trayicon.py:1023 +#, python-format +msgid "%s Enterprise Authentication Failed" +msgstr "" + +#: NetworkMgr/trayicon.py:1025 +#, python-format +msgid "Enterprise Authentication for %s" +msgstr "" + +#: NetworkMgr/trayicon.py:1029 +#, python-format +msgid "Security: %s" +msgstr "" + +#: NetworkMgr/trayicon.py:1041 +msgid "EAP Method:" +msgstr "" + +#: NetworkMgr/trayicon.py:1054 +msgid "Inner Auth:" +msgstr "" + +#: NetworkMgr/trayicon.py:1067 +msgid "Username:" +msgstr "" + +#: NetworkMgr/trayicon.py:1076 +msgid "Anonymous ID:" +msgstr "" + +#: NetworkMgr/trayicon.py:1080 +msgid "Optional - for privacy" +msgstr "" + +#: NetworkMgr/trayicon.py:1094 +msgid "Show" +msgstr "" + +#: NetworkMgr/trayicon.py:1103 +msgid "CA Certificate:" +msgstr "" + +#: NetworkMgr/trayicon.py:1108 +msgid "Select CA Certificate" +msgstr "" + +#: NetworkMgr/trayicon.py:1116 +msgid "Certificates (*.pem, *.crt, *.cer)" +msgstr "" + +#: NetworkMgr/trayicon.py:1128 +msgid "Client Cert:" +msgstr "" + +#: NetworkMgr/trayicon.py:1132 +msgid "Select Client Certificate" +msgstr "" + +#: NetworkMgr/trayicon.py:1143 +msgid "Private Key:" +msgstr "" + +#: NetworkMgr/trayicon.py:1147 +msgid "Select Private Key" +msgstr "" + +#: NetworkMgr/trayicon.py:1151 +msgid "Key files (*.pem, *.key, *.p12)" +msgstr "" + +#: NetworkMgr/trayicon.py:1163 +msgid "Key Password:" +msgstr "" diff --git a/po/pt_Br.po b/po/pt_Br.po index 632763e..80ce6f1 100644 --- a/po/pt_Br.po +++ b/po/pt_Br.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-08-29 15:54+0800\n" +"POT-Creation-Date: 2026-09-11 21:33-0300\n" "PO-Revision-Date: 2025-01-05 16:17-0400\n" "Last-Translator: Edu_Amr \n" "Language-Team: Portuguese \n" @@ -17,97 +17,236 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: src/trayicon.py:67 +#: NetworkMgr/trayicon.py:173 +msgid "WireGuard VPN" +msgstr "" + +#: NetworkMgr/trayicon.py:182 +#, fuzzy, python-format +msgid "%s Connected" +msgstr "Wi-Fi %s Conectado" + +#: NetworkMgr/trayicon.py:185 NetworkMgr/trayicon.py:219 +#: NetworkMgr/trayicon.py:230 +#, fuzzy, python-format +msgid "Disable %s" +msgstr "Desativar" + +#: NetworkMgr/trayicon.py:189 +#, fuzzy, python-format +msgid "%s Disconnected" +msgstr "Wi-Fi %s Desconectado" + +#: NetworkMgr/trayicon.py:192 NetworkMgr/trayicon.py:212 +msgid "Enable" +msgstr "Ativar" + +#: NetworkMgr/trayicon.py:198 msgid "Ethernet Network" msgstr "Rede Ethernet" -#: src/trayicon.py:78 +#: NetworkMgr/trayicon.py:209 +#, fuzzy, python-format +msgid "Wired %s Disabled" +msgstr "Wi-Fi %s Desativado" + +#: NetworkMgr/trayicon.py:216 #, python-format msgid "Wired %s Connected" msgstr "Cabo %s Conectado" -#: src/trayicon.py:81 -msgid "Disable" -msgstr "Desativar" +#: NetworkMgr/trayicon.py:223 NetworkMgr/trayicon.py:234 +#: NetworkMgr/trayicon.py:281 +#, python-format +msgid "Configure %s" +msgstr "" -#: src/trayicon.py:86 +#: NetworkMgr/trayicon.py:227 #, python-format msgid "Wired %s Disconnected" msgstr "Cabo %s Desconectado" -#: src/trayicon.py:89 -msgid "Enable" -msgstr "Ativar" - -#: src/trayicon.py:93 +#: NetworkMgr/trayicon.py:238 #, python-format msgid "Wired %s Unplug" msgstr "Cabo %s Desconectado fisicamente" -#: src/trayicon.py:101 +#: NetworkMgr/trayicon.py:246 #, python-format msgid "WiFi %s Disabled" msgstr "Wi-Fi %s Desativado" -#: src/trayicon.py:104 +#: NetworkMgr/trayicon.py:249 #, python-format msgid "Enable Wifi %s" msgstr "Ativar Wi-Fi %s" -#: src/trayicon.py:109 +#: NetworkMgr/trayicon.py:254 #, python-format msgid "WiFi %s Disconnected" msgstr "Wi-Fi %s Desconectado" -#: src/trayicon.py:113 src/trayicon.py:131 +#: NetworkMgr/trayicon.py:259 NetworkMgr/trayicon.py:278 #, python-format msgid "Disable Wifi %s" msgstr "Desativar Wi-Fi %s" -#: src/trayicon.py:119 +#: NetworkMgr/trayicon.py:265 #, python-format msgid "WiFi %s Connected" msgstr "Wi-Fi %s Conectado" -#: src/trayicon.py:125 +#: NetworkMgr/trayicon.py:271 #, python-format msgid "Disconnect from %s" msgstr "Desconectar de %s" -#: src/trayicon.py:139 -msgid "Enable Networking" -msgstr "Ativar Rede" - -#: src/trayicon.py:143 -msgid "Disable Networking" +#: NetworkMgr/trayicon.py:287 +#, fuzzy +msgid "Restart Networking" msgstr "Desativar Rede" -#: src/trayicon.py:148 +#: NetworkMgr/trayicon.py:290 msgid "Close Network Manager" msgstr "Fechar Gerenciador de Rede" -#: src/trayicon.py:156 +#: NetworkMgr/trayicon.py:312 +#, python-format +msgid "%s (WPA3 only)" +msgstr "" + +#: NetworkMgr/trayicon.py:349 msgid "Available Connections" msgstr "Conexões Disponíveis" -#: src/trayicon.py:364 +#: NetworkMgr/trayicon.py:367 +#, fuzzy +msgid "Forget Network" +msgstr "Rede Ethernet" + +#: NetworkMgr/trayicon.py:835 +#, python-format +msgid "" +"%(card)s did not associate with %(ssid)s before timing out.\n" +"\n" +"A weak signal is the usual cause." +msgstr "" + +#: NetworkMgr/trayicon.py:839 +#, python-format +msgid "wpa_supplicant did not accept the request on %s." +msgstr "" + +#: NetworkMgr/trayicon.py:842 +#, python-format +msgid "Could not connect to %s" +msgstr "" + +#: NetworkMgr/trayicon.py:873 msgid "Wi-Fi Network Authentication Required" msgstr "Autenticação Necessária para a Rede Wi-Fi" -#: src/trayicon.py:376 -#, python-brace-format -msgid "{ssid_info[0]} Wi-Fi Network Authentication failed" -msgstr "Autenticação falhou na rede Wi-Fi {ssid_info[0]}" +#: NetworkMgr/trayicon.py:886 +#, python-format +msgid "Could not connect to %s. Try again." +msgstr "" -#: src/trayicon.py:378 -#, python-brace-format -msgid "Authentication required by {ssid_info[0]} Wi-Fi Network" +#: NetworkMgr/trayicon.py:888 +#, fuzzy, python-format +msgid "Authentication required by %s Wi-Fi Network" msgstr "Autenticação requerida pela rede Wi-Fi {ssid_info[0]}" -#: src/trayicon.py:381 +#: NetworkMgr/trayicon.py:890 NetworkMgr/trayicon.py:1086 msgid "Password:" msgstr "Senha:" -#: src/trayicon.py:384 +#: NetworkMgr/trayicon.py:893 msgid "Show password" msgstr "Mostrar senha" + +#: NetworkMgr/trayicon.py:1014 +msgid "Enterprise Wi-Fi Authentication" +msgstr "" + +#: NetworkMgr/trayicon.py:1023 +#, fuzzy, python-format +msgid "%s Enterprise Authentication Failed" +msgstr "Autenticação Necessária para a Rede Wi-Fi" + +#: NetworkMgr/trayicon.py:1025 +#, python-format +msgid "Enterprise Authentication for %s" +msgstr "" + +#: NetworkMgr/trayicon.py:1029 +#, python-format +msgid "Security: %s" +msgstr "" + +#: NetworkMgr/trayicon.py:1041 +msgid "EAP Method:" +msgstr "" + +#: NetworkMgr/trayicon.py:1054 +msgid "Inner Auth:" +msgstr "" + +#: NetworkMgr/trayicon.py:1067 +msgid "Username:" +msgstr "" + +#: NetworkMgr/trayicon.py:1076 +msgid "Anonymous ID:" +msgstr "" + +#: NetworkMgr/trayicon.py:1080 +msgid "Optional - for privacy" +msgstr "" + +#: NetworkMgr/trayicon.py:1094 +msgid "Show" +msgstr "" + +#: NetworkMgr/trayicon.py:1103 +msgid "CA Certificate:" +msgstr "" + +#: NetworkMgr/trayicon.py:1108 +msgid "Select CA Certificate" +msgstr "" + +#: NetworkMgr/trayicon.py:1116 +msgid "Certificates (*.pem, *.crt, *.cer)" +msgstr "" + +#: NetworkMgr/trayicon.py:1128 +msgid "Client Cert:" +msgstr "" + +#: NetworkMgr/trayicon.py:1132 +msgid "Select Client Certificate" +msgstr "" + +#: NetworkMgr/trayicon.py:1143 +msgid "Private Key:" +msgstr "" + +#: NetworkMgr/trayicon.py:1147 +msgid "Select Private Key" +msgstr "" + +#: NetworkMgr/trayicon.py:1151 +msgid "Key files (*.pem, *.key, *.p12)" +msgstr "" + +#: NetworkMgr/trayicon.py:1163 +#, fuzzy +msgid "Key Password:" +msgstr "Senha:" + +#~ msgid "Enable Networking" +#~ msgstr "Ativar Rede" + +#, python-brace-format +#~ msgid "{ssid_info[0]} Wi-Fi Network Authentication failed" +#~ msgstr "Autenticação falhou na rede Wi-Fi {ssid_info[0]}" diff --git a/po/ru.po b/po/ru.po index 1d0ee0e..ef956bc 100644 --- a/po/ru.po +++ b/po/ru.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-08-29 15:54+0800\n" +"POT-Creation-Date: 2026-09-11 21:33-0300\n" "PO-Revision-Date: 2022-08-30 00:05+0300\n" "Last-Translator: Alexander Alexeev \n" "Language-Team: Language locale/ru\n" @@ -17,97 +17,236 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: src/trayicon.py:67 +#: NetworkMgr/trayicon.py:173 +msgid "WireGuard VPN" +msgstr "" + +#: NetworkMgr/trayicon.py:182 +#, fuzzy, python-format +msgid "%s Connected" +msgstr "Подключение по WiFi %s установлено" + +#: NetworkMgr/trayicon.py:185 NetworkMgr/trayicon.py:219 +#: NetworkMgr/trayicon.py:230 +#, fuzzy, python-format +msgid "Disable %s" +msgstr "Отключить" + +#: NetworkMgr/trayicon.py:189 +#, fuzzy, python-format +msgid "%s Disconnected" +msgstr "Подключение по WiFi %s разорвано" + +#: NetworkMgr/trayicon.py:192 NetworkMgr/trayicon.py:212 +msgid "Enable" +msgstr "Включить" + +#: NetworkMgr/trayicon.py:198 msgid "Ethernet Network" msgstr "Сеть Ethernet" -#: src/trayicon.py:78 +#: NetworkMgr/trayicon.py:209 +#, fuzzy, python-format +msgid "Wired %s Disabled" +msgstr "Соединение по WiFi %s отключено" + +#: NetworkMgr/trayicon.py:216 #, python-format msgid "Wired %s Connected" msgstr "Проводное соединение %s подключено" -#: src/trayicon.py:81 -msgid "Disable" -msgstr "Отключить" +#: NetworkMgr/trayicon.py:223 NetworkMgr/trayicon.py:234 +#: NetworkMgr/trayicon.py:281 +#, python-format +msgid "Configure %s" +msgstr "" -#: src/trayicon.py:86 +#: NetworkMgr/trayicon.py:227 #, python-format msgid "Wired %s Disconnected" msgstr "Проводное соединение %s отключено" -#: src/trayicon.py:89 -msgid "Enable" -msgstr "Включить" - -#: src/trayicon.py:93 +#: NetworkMgr/trayicon.py:238 #, python-format msgid "Wired %s Unplug" msgstr "Проводное соединение %s разорвано" -#: src/trayicon.py:101 +#: NetworkMgr/trayicon.py:246 #, python-format msgid "WiFi %s Disabled" msgstr "Соединение по WiFi %s отключено" -#: src/trayicon.py:104 +#: NetworkMgr/trayicon.py:249 #, python-format msgid "Enable Wifi %s" msgstr "Включить соединение по WiFi %s" -#: src/trayicon.py:109 +#: NetworkMgr/trayicon.py:254 #, python-format msgid "WiFi %s Disconnected" msgstr "Подключение по WiFi %s разорвано" -#: src/trayicon.py:113 src/trayicon.py:131 +#: NetworkMgr/trayicon.py:259 NetworkMgr/trayicon.py:278 #, python-format msgid "Disable Wifi %s" msgstr "Отключить соединение по WiFi %s" -#: src/trayicon.py:119 +#: NetworkMgr/trayicon.py:265 #, python-format msgid "WiFi %s Connected" msgstr "Подключение по WiFi %s установлено" -#: src/trayicon.py:125 +#: NetworkMgr/trayicon.py:271 #, python-format msgid "Disconnect from %s" msgstr "Отключиться от %s" -#: src/trayicon.py:139 -msgid "Enable Networking" -msgstr "Включить сеть" - -#: src/trayicon.py:143 -msgid "Disable Networking" +#: NetworkMgr/trayicon.py:287 +#, fuzzy +msgid "Restart Networking" msgstr "Отключить сеть" -#: src/trayicon.py:148 +#: NetworkMgr/trayicon.py:290 msgid "Close Network Manager" msgstr "Закрыть Network Manager" -#: src/trayicon.py:156 +#: NetworkMgr/trayicon.py:312 +#, python-format +msgid "%s (WPA3 only)" +msgstr "" + +#: NetworkMgr/trayicon.py:349 msgid "Available Connections" msgstr "Доступные подключения" -#: src/trayicon.py:364 +#: NetworkMgr/trayicon.py:367 +#, fuzzy +msgid "Forget Network" +msgstr "Сеть Ethernet" + +#: NetworkMgr/trayicon.py:835 +#, python-format +msgid "" +"%(card)s did not associate with %(ssid)s before timing out.\n" +"\n" +"A weak signal is the usual cause." +msgstr "" + +#: NetworkMgr/trayicon.py:839 +#, python-format +msgid "wpa_supplicant did not accept the request on %s." +msgstr "" + +#: NetworkMgr/trayicon.py:842 +#, python-format +msgid "Could not connect to %s" +msgstr "" + +#: NetworkMgr/trayicon.py:873 msgid "Wi-Fi Network Authentication Required" msgstr "Требуется региcтрация в сети WiFi" -#: src/trayicon.py:376 -#, python-brace-format -msgid "{ssid_info[0]} Wi-Fi Network Authentication failed" -msgstr "Ошибка при регистрации в сети WiFi {ssid_info[0]}" +#: NetworkMgr/trayicon.py:886 +#, python-format +msgid "Could not connect to %s. Try again." +msgstr "" -#: src/trayicon.py:378 -#, python-brace-format -msgid "Authentication required by {ssid_info[0]} Wi-Fi Network" +#: NetworkMgr/trayicon.py:888 +#, fuzzy, python-format +msgid "Authentication required by %s Wi-Fi Network" msgstr "Требуется региcтрация в сети WiFi {ssid_info[0]}" -#: src/trayicon.py:381 +#: NetworkMgr/trayicon.py:890 NetworkMgr/trayicon.py:1086 msgid "Password:" msgstr "Пароль:" -#: src/trayicon.py:384 +#: NetworkMgr/trayicon.py:893 msgid "Show password" msgstr "Показать пароль" + +#: NetworkMgr/trayicon.py:1014 +msgid "Enterprise Wi-Fi Authentication" +msgstr "" + +#: NetworkMgr/trayicon.py:1023 +#, fuzzy, python-format +msgid "%s Enterprise Authentication Failed" +msgstr "Требуется региcтрация в сети WiFi" + +#: NetworkMgr/trayicon.py:1025 +#, python-format +msgid "Enterprise Authentication for %s" +msgstr "" + +#: NetworkMgr/trayicon.py:1029 +#, python-format +msgid "Security: %s" +msgstr "" + +#: NetworkMgr/trayicon.py:1041 +msgid "EAP Method:" +msgstr "" + +#: NetworkMgr/trayicon.py:1054 +msgid "Inner Auth:" +msgstr "" + +#: NetworkMgr/trayicon.py:1067 +msgid "Username:" +msgstr "" + +#: NetworkMgr/trayicon.py:1076 +msgid "Anonymous ID:" +msgstr "" + +#: NetworkMgr/trayicon.py:1080 +msgid "Optional - for privacy" +msgstr "" + +#: NetworkMgr/trayicon.py:1094 +msgid "Show" +msgstr "" + +#: NetworkMgr/trayicon.py:1103 +msgid "CA Certificate:" +msgstr "" + +#: NetworkMgr/trayicon.py:1108 +msgid "Select CA Certificate" +msgstr "" + +#: NetworkMgr/trayicon.py:1116 +msgid "Certificates (*.pem, *.crt, *.cer)" +msgstr "" + +#: NetworkMgr/trayicon.py:1128 +msgid "Client Cert:" +msgstr "" + +#: NetworkMgr/trayicon.py:1132 +msgid "Select Client Certificate" +msgstr "" + +#: NetworkMgr/trayicon.py:1143 +msgid "Private Key:" +msgstr "" + +#: NetworkMgr/trayicon.py:1147 +msgid "Select Private Key" +msgstr "" + +#: NetworkMgr/trayicon.py:1151 +msgid "Key files (*.pem, *.key, *.p12)" +msgstr "" + +#: NetworkMgr/trayicon.py:1163 +#, fuzzy +msgid "Key Password:" +msgstr "Пароль:" + +#~ msgid "Enable Networking" +#~ msgstr "Включить сеть" + +#, python-brace-format +#~ msgid "{ssid_info[0]} Wi-Fi Network Authentication failed" +#~ msgstr "Ошибка при регистрации в сети WiFi {ssid_info[0]}" diff --git a/po/sv.po b/po/sv.po index 2060351..42c08b3 100644 --- a/po/sv.po +++ b/po/sv.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-12-14 17:25+0000\n" +"POT-Creation-Date: 2026-09-11 21:33-0300\n" "PO-Revision-Date: 2023-12-14 17:25+0000\n" "Last-Translator: Joshua Hoffmann \n" "Language-Team: Language locale/sv\n" @@ -17,97 +17,236 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: src/trayicon.py:67 +#: NetworkMgr/trayicon.py:173 +msgid "WireGuard VPN" +msgstr "" + +#: NetworkMgr/trayicon.py:182 +#, fuzzy, python-format +msgid "%s Connected" +msgstr "WiFi %s ansluten" + +#: NetworkMgr/trayicon.py:185 NetworkMgr/trayicon.py:219 +#: NetworkMgr/trayicon.py:230 +#, fuzzy, python-format +msgid "Disable %s" +msgstr "Inaktivera" + +#: NetworkMgr/trayicon.py:189 +#, fuzzy, python-format +msgid "%s Disconnected" +msgstr "WiFi %s har kopplats bort" + +#: NetworkMgr/trayicon.py:192 NetworkMgr/trayicon.py:212 +msgid "Enable" +msgstr "Aktivera" + +#: NetworkMgr/trayicon.py:198 msgid "Ethernet Network" msgstr "Ethernet-nätverk" -#: src/trayicon.py:78 +#: NetworkMgr/trayicon.py:209 +#, fuzzy, python-format +msgid "Wired %s Disabled" +msgstr "WiFi %s inaktiverat" + +#: NetworkMgr/trayicon.py:216 #, python-format msgid "Wired %s Connected" msgstr "trådbunden anslutning %s ansluten" -#: src/trayicon.py:81 -msgid "Disable" -msgstr "Inaktivera" +#: NetworkMgr/trayicon.py:223 NetworkMgr/trayicon.py:234 +#: NetworkMgr/trayicon.py:281 +#, python-format +msgid "Configure %s" +msgstr "" -#: src/trayicon.py:86 +#: NetworkMgr/trayicon.py:227 #, python-format msgid "Wired %s Disconnected" msgstr "Kabel frånkopplad från %s" -#: src/trayicon.py:89 -msgid "Enable" -msgstr "Aktivera" - -#: src/trayicon.py:93 +#: NetworkMgr/trayicon.py:238 #, python-format msgid "Wired %s Unplug" msgstr "Kabel borttagen från %s" -#: src/trayicon.py:101 +#: NetworkMgr/trayicon.py:246 #, python-format msgid "WiFi %s Disabled" msgstr "WiFi %s inaktiverat" -#: src/trayicon.py:104 +#: NetworkMgr/trayicon.py:249 #, python-format msgid "Enable Wifi %s" msgstr "Aktivera WiFi %s" -#: src/trayicon.py:109 +#: NetworkMgr/trayicon.py:254 #, python-format msgid "WiFi %s Disconnected" msgstr "WiFi %s har kopplats bort" -#: src/trayicon.py:113 src/trayicon.py:131 +#: NetworkMgr/trayicon.py:259 NetworkMgr/trayicon.py:278 #, python-format msgid "Disable Wifi %s" msgstr "Inaktivera WiFi %s" -#: src/trayicon.py:119 +#: NetworkMgr/trayicon.py:265 #, python-format msgid "WiFi %s Connected" msgstr "WiFi %s ansluten" -#: src/trayicon.py:125 +#: NetworkMgr/trayicon.py:271 #, python-format msgid "Disconnect from %s" msgstr "Koppla bort %s" -#: src/trayicon.py:139 -msgid "Enable Networking" -msgstr "Aktivera nätverk" - -#: src/trayicon.py:143 -msgid "Disable Networking" +#: NetworkMgr/trayicon.py:287 +#, fuzzy +msgid "Restart Networking" msgstr "Avaktivera nätverksanslutningar" -#: src/trayicon.py:148 +#: NetworkMgr/trayicon.py:290 msgid "Close Network Manager" msgstr "Stäng Network Manager" -#: src/trayicon.py:156 +#: NetworkMgr/trayicon.py:312 +#, python-format +msgid "%s (WPA3 only)" +msgstr "" + +#: NetworkMgr/trayicon.py:349 msgid "Available Connections" msgstr "Tillgängliga anslutningar" -#: src/trayicon.py:364 +#: NetworkMgr/trayicon.py:367 +#, fuzzy +msgid "Forget Network" +msgstr "Ethernet-nätverk" + +#: NetworkMgr/trayicon.py:835 +#, python-format +msgid "" +"%(card)s did not associate with %(ssid)s before timing out.\n" +"\n" +"A weak signal is the usual cause." +msgstr "" + +#: NetworkMgr/trayicon.py:839 +#, python-format +msgid "wpa_supplicant did not accept the request on %s." +msgstr "" + +#: NetworkMgr/trayicon.py:842 +#, python-format +msgid "Could not connect to %s" +msgstr "" + +#: NetworkMgr/trayicon.py:873 msgid "Wi-Fi Network Authentication Required" msgstr "Wi-Fi-nätverksautentisering krävs" -#: src/trayicon.py:376 -#, python-brace-format -msgid "{ssid_info[0]} Wi-Fi Network Authentication failed" -msgstr "Wi-Fi-nätverksautentisering för {ssid_info[0]} misslyckades" +#: NetworkMgr/trayicon.py:886 +#, python-format +msgid "Could not connect to %s. Try again." +msgstr "" -#: src/trayicon.py:378 -#, python-brace-format -msgid "Authentication required by {ssid_info[0]} Wi-Fi Network" +#: NetworkMgr/trayicon.py:888 +#, fuzzy, python-format +msgid "Authentication required by %s Wi-Fi Network" msgstr "Autentisering krävs av Wi-Fi-nätverk {ssid_info[0]}" -#: src/trayicon.py:381 +#: NetworkMgr/trayicon.py:890 NetworkMgr/trayicon.py:1086 msgid "Password:" msgstr "Lösenord:" -#: src/trayicon.py:384 +#: NetworkMgr/trayicon.py:893 msgid "Show password" msgstr "Visa lösenord" + +#: NetworkMgr/trayicon.py:1014 +msgid "Enterprise Wi-Fi Authentication" +msgstr "" + +#: NetworkMgr/trayicon.py:1023 +#, fuzzy, python-format +msgid "%s Enterprise Authentication Failed" +msgstr "Wi-Fi-nätverksautentisering krävs" + +#: NetworkMgr/trayicon.py:1025 +#, python-format +msgid "Enterprise Authentication for %s" +msgstr "" + +#: NetworkMgr/trayicon.py:1029 +#, python-format +msgid "Security: %s" +msgstr "" + +#: NetworkMgr/trayicon.py:1041 +msgid "EAP Method:" +msgstr "" + +#: NetworkMgr/trayicon.py:1054 +msgid "Inner Auth:" +msgstr "" + +#: NetworkMgr/trayicon.py:1067 +msgid "Username:" +msgstr "" + +#: NetworkMgr/trayicon.py:1076 +msgid "Anonymous ID:" +msgstr "" + +#: NetworkMgr/trayicon.py:1080 +msgid "Optional - for privacy" +msgstr "" + +#: NetworkMgr/trayicon.py:1094 +msgid "Show" +msgstr "" + +#: NetworkMgr/trayicon.py:1103 +msgid "CA Certificate:" +msgstr "" + +#: NetworkMgr/trayicon.py:1108 +msgid "Select CA Certificate" +msgstr "" + +#: NetworkMgr/trayicon.py:1116 +msgid "Certificates (*.pem, *.crt, *.cer)" +msgstr "" + +#: NetworkMgr/trayicon.py:1128 +msgid "Client Cert:" +msgstr "" + +#: NetworkMgr/trayicon.py:1132 +msgid "Select Client Certificate" +msgstr "" + +#: NetworkMgr/trayicon.py:1143 +msgid "Private Key:" +msgstr "" + +#: NetworkMgr/trayicon.py:1147 +msgid "Select Private Key" +msgstr "" + +#: NetworkMgr/trayicon.py:1151 +msgid "Key files (*.pem, *.key, *.p12)" +msgstr "" + +#: NetworkMgr/trayicon.py:1163 +#, fuzzy +msgid "Key Password:" +msgstr "Lösenord:" + +#~ msgid "Enable Networking" +#~ msgstr "Aktivera nätverk" + +#, python-brace-format +#~ msgid "{ssid_info[0]} Wi-Fi Network Authentication failed" +#~ msgstr "Wi-Fi-nätverksautentisering för {ssid_info[0]} misslyckades" diff --git a/po/zh_CN.po b/po/zh_CN.po index e0fb0b3..cdd871c 100644 --- a/po/zh_CN.po +++ b/po/zh_CN.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: networkmgr\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-08-29 15:54+0800\n" +"POT-Creation-Date: 2026-09-11 21:33-0300\n" "PO-Revision-Date: 2025-09-07 10:33+0800\n" "Last-Translator: ykla \n" "Language-Team: ykla \n" @@ -18,97 +18,236 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "X-Generator: Poedit 3.7\n" -#: src/trayicon.py:67 +#: NetworkMgr/trayicon.py:173 +msgid "WireGuard VPN" +msgstr "" + +#: NetworkMgr/trayicon.py:182 +#, fuzzy, python-format +msgid "%s Connected" +msgstr "WiFi %s 已连接" + +#: NetworkMgr/trayicon.py:185 NetworkMgr/trayicon.py:219 +#: NetworkMgr/trayicon.py:230 +#, fuzzy, python-format +msgid "Disable %s" +msgstr "禁用" + +#: NetworkMgr/trayicon.py:189 +#, fuzzy, python-format +msgid "%s Disconnected" +msgstr "WiFi %s 已断开" + +#: NetworkMgr/trayicon.py:192 NetworkMgr/trayicon.py:212 +msgid "Enable" +msgstr "启用" + +#: NetworkMgr/trayicon.py:198 msgid "Ethernet Network" msgstr "以太网" -#: src/trayicon.py:78 +#: NetworkMgr/trayicon.py:209 +#, fuzzy, python-format +msgid "Wired %s Disabled" +msgstr "WiFi %s 已禁用" + +#: NetworkMgr/trayicon.py:216 #, python-format msgid "Wired %s Connected" msgstr "有线 %s 已连接" -#: src/trayicon.py:81 -msgid "Disable" -msgstr "禁用" +#: NetworkMgr/trayicon.py:223 NetworkMgr/trayicon.py:234 +#: NetworkMgr/trayicon.py:281 +#, python-format +msgid "Configure %s" +msgstr "" -#: src/trayicon.py:86 +#: NetworkMgr/trayicon.py:227 #, python-format msgid "Wired %s Disconnected" msgstr "有线 %s 已断开" -#: src/trayicon.py:89 -msgid "Enable" -msgstr "启用" - -#: src/trayicon.py:93 +#: NetworkMgr/trayicon.py:238 #, python-format msgid "Wired %s Unplug" msgstr "已拔出有线 %s" -#: src/trayicon.py:101 +#: NetworkMgr/trayicon.py:246 #, python-format msgid "WiFi %s Disabled" msgstr "WiFi %s 已禁用" -#: src/trayicon.py:104 +#: NetworkMgr/trayicon.py:249 #, python-format msgid "Enable Wifi %s" msgstr "启用 WiFi %s" -#: src/trayicon.py:109 +#: NetworkMgr/trayicon.py:254 #, python-format msgid "WiFi %s Disconnected" msgstr "WiFi %s 已断开" -#: src/trayicon.py:113 src/trayicon.py:131 +#: NetworkMgr/trayicon.py:259 NetworkMgr/trayicon.py:278 #, python-format msgid "Disable Wifi %s" msgstr "禁用 WiFi %s" -#: src/trayicon.py:119 +#: NetworkMgr/trayicon.py:265 #, python-format msgid "WiFi %s Connected" msgstr "WiFi %s 已连接" -#: src/trayicon.py:125 +#: NetworkMgr/trayicon.py:271 #, python-format msgid "Disconnect from %s" msgstr "已与 %s 断开连接" -#: src/trayicon.py:139 -msgid "Enable Networking" -msgstr "启用网络" - -#: src/trayicon.py:143 -msgid "Disable Networking" +#: NetworkMgr/trayicon.py:287 +#, fuzzy +msgid "Restart Networking" msgstr "禁用网络" -#: src/trayicon.py:148 +#: NetworkMgr/trayicon.py:290 msgid "Close Network Manager" msgstr "退出网络管理器" -#: src/trayicon.py:156 +#: NetworkMgr/trayicon.py:312 +#, python-format +msgid "%s (WPA3 only)" +msgstr "" + +#: NetworkMgr/trayicon.py:349 msgid "Available Connections" msgstr "可用连接" -#: src/trayicon.py:364 +#: NetworkMgr/trayicon.py:367 +#, fuzzy +msgid "Forget Network" +msgstr "以太网" + +#: NetworkMgr/trayicon.py:835 +#, python-format +msgid "" +"%(card)s did not associate with %(ssid)s before timing out.\n" +"\n" +"A weak signal is the usual cause." +msgstr "" + +#: NetworkMgr/trayicon.py:839 +#, python-format +msgid "wpa_supplicant did not accept the request on %s." +msgstr "" + +#: NetworkMgr/trayicon.py:842 +#, python-format +msgid "Could not connect to %s" +msgstr "" + +#: NetworkMgr/trayicon.py:873 msgid "Wi-Fi Network Authentication Required" msgstr "需要无线网络身份验证" -#: src/trayicon.py:376 -#, python-brace-format -msgid "{ssid_info[0]} Wi-Fi Network Authentication failed" -msgstr "无线网络 {ssid_info[0]} 身份验证失败" +#: NetworkMgr/trayicon.py:886 +#, python-format +msgid "Could not connect to %s. Try again." +msgstr "" -#: src/trayicon.py:378 -#, python-brace-format -msgid "Authentication required by {ssid_info[0]} Wi-Fi Network" +#: NetworkMgr/trayicon.py:888 +#, fuzzy, python-format +msgid "Authentication required by %s Wi-Fi Network" msgstr "无线网络 {ssid_info[0]} 需要身份验证" -#: src/trayicon.py:381 +#: NetworkMgr/trayicon.py:890 NetworkMgr/trayicon.py:1086 msgid "Password:" msgstr "密码:" -#: src/trayicon.py:384 +#: NetworkMgr/trayicon.py:893 msgid "Show password" msgstr "显示密码" + +#: NetworkMgr/trayicon.py:1014 +msgid "Enterprise Wi-Fi Authentication" +msgstr "" + +#: NetworkMgr/trayicon.py:1023 +#, fuzzy, python-format +msgid "%s Enterprise Authentication Failed" +msgstr "需要无线网络身份验证" + +#: NetworkMgr/trayicon.py:1025 +#, python-format +msgid "Enterprise Authentication for %s" +msgstr "" + +#: NetworkMgr/trayicon.py:1029 +#, python-format +msgid "Security: %s" +msgstr "" + +#: NetworkMgr/trayicon.py:1041 +msgid "EAP Method:" +msgstr "" + +#: NetworkMgr/trayicon.py:1054 +msgid "Inner Auth:" +msgstr "" + +#: NetworkMgr/trayicon.py:1067 +msgid "Username:" +msgstr "" + +#: NetworkMgr/trayicon.py:1076 +msgid "Anonymous ID:" +msgstr "" + +#: NetworkMgr/trayicon.py:1080 +msgid "Optional - for privacy" +msgstr "" + +#: NetworkMgr/trayicon.py:1094 +msgid "Show" +msgstr "" + +#: NetworkMgr/trayicon.py:1103 +msgid "CA Certificate:" +msgstr "" + +#: NetworkMgr/trayicon.py:1108 +msgid "Select CA Certificate" +msgstr "" + +#: NetworkMgr/trayicon.py:1116 +msgid "Certificates (*.pem, *.crt, *.cer)" +msgstr "" + +#: NetworkMgr/trayicon.py:1128 +msgid "Client Cert:" +msgstr "" + +#: NetworkMgr/trayicon.py:1132 +msgid "Select Client Certificate" +msgstr "" + +#: NetworkMgr/trayicon.py:1143 +msgid "Private Key:" +msgstr "" + +#: NetworkMgr/trayicon.py:1147 +msgid "Select Private Key" +msgstr "" + +#: NetworkMgr/trayicon.py:1151 +msgid "Key files (*.pem, *.key, *.p12)" +msgstr "" + +#: NetworkMgr/trayicon.py:1163 +#, fuzzy +msgid "Key Password:" +msgstr "密码:" + +#~ msgid "Enable Networking" +#~ msgstr "启用网络" + +#, python-brace-format +#~ msgid "{ssid_info[0]} Wi-Fi Network Authentication failed" +#~ msgstr "无线网络 {ssid_info[0]} 身份验证失败" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 02a3efe..0000000 --- a/requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -attrs==21.2.0 -coverage==6.2 -iniconfig==1.1.1 -packaging==21.3 -pluggy==1.0.0 -py==1.11.0 -pyparsing==3.0.6 -pytest==9.0.3 -pytest-cov==3.0.0 -Tkinter==0.0.0 -toml==0.10.2 -tomli==2.0.0 diff --git a/setup.py b/setup.py index 79afc46..57acea8 100755 --- a/setup.py +++ b/setup.py @@ -1,6 +1,12 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +"""Distutils/setuptools packaging for NetworkMgr. + +Installs the NetworkMgr package, the two entry scripts, the devd action +scripts, the devd and sudoers fragments, the icons and the translations. +""" + import os import sys from platform import system @@ -10,19 +16,27 @@ from DistUtilsExtra.command.build_i18n import build_i18n from DistUtilsExtra.command.clean_i18n import clean_i18n -__VERSION__ = '6.9' +__VERSION__ = '7.0' PROGRAM_VERSION = __VERSION__ -prefix = '/usr/local' if system() == 'FreeBSD' else sys.prefix +PREFIX = '/usr/local' if system() == 'FreeBSD' else sys.prefix def datafilelist(installbase, sourcebase): - datafileList = [] - for root, subFolders, files in os.walk(sourcebase): - fileList = [] - for f in files: - fileList.append(os.path.join(root, f)) - datafileList.append((root.replace(sourcebase, installbase), fileList)) - return datafileList + """Map a source directory tree onto its install destination. + + Args: + installbase (str): Directory the tree is installed under. + sourcebase (str): Directory the tree is read from. + + Returns: + list[tuple[str, list[str]]]: A (destination, files) pair per directory, + in the form setup()'s data_files expects. + """ + data_file_list = [] + for root, _, files in os.walk(sourcebase): + file_list = [os.path.join(root, name) for name in files] + data_file_list.append((root.replace(sourcebase, installbase), file_list)) + return data_file_list class UpdateTranslationsCommand(Command): """Custom command to extract messages and update .po files.""" @@ -31,18 +45,19 @@ class UpdateTranslationsCommand(Command): user_options = [] # No custom options def initialize_options(self): - pass + """Set up the command's options. This command takes none.""" def finalize_options(self): - pass + """Validate the command's options. This command takes none.""" def run(self): + """Extract messages into the .pot file and merge them into every .po.""" # Define paths pot_file = 'po/networkmgr.pot' po_files = glob.glob('po/*.po') # Step 1: Extract messages to .pot file print("Extracting messages to .pot file...") - os.system(f'xgettext --from-code=UTF-8 -L Python -o {pot_file} networkmgr/*.py networkmgr') + os.system(f'xgettext --from-code=UTF-8 -L Python -o {pot_file} NetworkMgr/*.py networkmgr') # Step 2: Update .po files with the new .pot file print("Updating .po files with new translations...") for po_file in po_files: @@ -59,13 +74,20 @@ class CreateTranslationCommand(Command): ] def initialize_options(self): - self.locale = None # Initialize the locale option to None + """Set the locale option to its unset default.""" + self.locale = None def finalize_options(self): + """Check that a locale was given. + + Raises: + ValueError: If --locale was not passed on the command line. + """ if self.locale is None: - raise Exception("You must specify the locale code (e.g., --locale=fr)") + raise ValueError("You must specify the locale code (e.g., --locale=fr)") def run(self): + """Create a .po file for the requested locale, extracting .pot first.""" # Define paths pot_file = 'po/networkmgr.pot' po_dir = 'po' @@ -73,7 +95,7 @@ def run(self): # Check if the .pot file exists if not os.path.exists(pot_file): print("Extracting messages to .pot file...") - os.system(f'xgettext --from-code=UTF-8 -L Python -o {pot_file} networkmgr/*.py networkmgr') + os.system(f'xgettext --from-code=UTF-8 -L Python -o {pot_file} NetworkMgr/*.py networkmgr') # Create the new .po file if not os.path.exists(po_file): print(f"Creating new {po_file} for locale '{self.locale}'...") @@ -86,18 +108,19 @@ def run(self): networkmgr_share = [ 'src/auto-switch.py', 'src/link-up.py', - 'src/setup-nic.py' + 'src/setup-nic.py', + 'src/wpa_supplicant.conf' ] data_files = [ - (f'{prefix}/etc/xdg/autostart', ['src/networkmgr.desktop']), - (f'{prefix}/share/networkmgr', networkmgr_share), - (f'{prefix}/etc/sudoers.d', ['src/sudoers.d/networkmgr']), - (f'{prefix}/etc/devd', ['src/networkmgr.conf']) + (f'{PREFIX}/etc/xdg/autostart', ['src/networkmgr.desktop']), + (f'{PREFIX}/share/networkmgr', networkmgr_share), + (f'{PREFIX}/etc/sudoers.d', ['src/sudoers.d/networkmgr']), + (f'{PREFIX}/etc/devd', ['src/networkmgr.conf']) ] -data_files.extend(datafilelist(f'{prefix}/share/icons/hicolor', 'src/icons')) -data_files.extend(datafilelist(f'{prefix}/share/locale', 'build/mo')) +data_files.extend(datafilelist(f'{PREFIX}/share/icons/hicolor', 'src/icons')) +data_files.extend(datafilelist(f'{PREFIX}/share/locale', 'build/mo')) setup( @@ -121,4 +144,4 @@ def run(self): } ) -run('gtk-update-icon-cache -f /usr/local/share/icons/hicolor', shell=True) +run('gtk-update-icon-cache -f /usr/local/share/icons/hicolor', shell=True, check=False) diff --git a/src/auto-switch.py b/src/auto-switch.py index cc0ad04..1e2246e 100755 --- a/src/auto-switch.py +++ b/src/auto-switch.py @@ -9,46 +9,53 @@ args = sys.argv if len(args) != 2: - exit() + sys.exit() nic = args[1] -not_nics_regex = r"(enc|lo|fwe|fwip|tap|plip|pfsync|pflog|ipfw|tun|sl|faith|" \ +NOT_NICS_REGEX = r"(enc|lo|fwe|fwip|tap|plip|pfsync|pflog|ipfw|tun|sl|faith|" \ r"ppp|bridge|wg)[0-9]|vm-[a-z]" -default_nic = run( - 'netstat -rn | grep default', - stdout=PIPE, - shell=True, - universal_newlines=True -).stdout +# The default route lines, picked out here rather than through a shell pipe. +DEFAULT_NIC = "\n".join( + line for line in run( + ['netstat', '-rn'], + stdout=PIPE, + universal_newlines=True, + check=False + ).stdout.splitlines() + if 'default' in line +) nics = run( ['ifconfig', '-l', 'ether'], stdout=PIPE, - universal_newlines=True + universal_newlines=True, + check=False ) nics_left_over = nics.stdout.replace(nic, '').strip() -nic_list = sorted(re.sub(not_nics_regex, '', nics_left_over).strip().split()) +nic_list = sorted(re.sub(NOT_NICS_REGEX, '', nics_left_over).strip().split()) # Stop the script if the nic is not valid or not in the default route. -if re.search(not_nics_regex, nic): - exit(0) -elif nic not in default_nic: - exit(0) +if re.search(NOT_NICS_REGEX, nic): + sys.exit(0) +elif nic not in DEFAULT_NIC: + sys.exit(0) elif not nic_list: - exit(0) + sys.exit(0) nic_ifconfig = run( ['ifconfig', nic], stdout=PIPE, - universal_newlines=True + universal_newlines=True, + check=False ).stdout dhcp = run( ['sysrc', '-n', f'ifconfig_{nic}'], stdout=PIPE, - universal_newlines=True + universal_newlines=True, + check=False ).stdout active_status = ( @@ -61,16 +68,18 @@ # Restarting routing adds and nic if there is another one that is active # or associated. if not any(active_status): - run(['service', 'netif', 'stop', nic]) - # Create a marker file for link-up.py to detect runtime state change vs boot - with open(f'/tmp/link-down-{nic}', 'w') as f: + run(['service', 'netif', 'stop', nic], check=False) + # Create a marker file for link-up.py to detect runtime state change vs boot. + # /var/run: root-only, and cleanvar empties it at every boot. + with open(f'/var/run/link-down-{nic}', 'w', encoding='utf-8') as f: f.write('down') if dhcp.strip() == 'DHCP': for current_nic in nic_list: output = run( ['ifconfig', current_nic], stdout=PIPE, - universal_newlines=True + universal_newlines=True, + check=False ) nic_ifconfig = output.stdout status_types = [ @@ -80,7 +89,7 @@ found_status = re.search(f"status: ({'|'.join(status_types)})", nic_ifconfig) found_inet = re.search(r"inet(\s|6)", nic_ifconfig) if found_status and found_inet: - run(['service', 'dhclient', 'restart', current_nic]) + run(['service', 'dhclient', 'restart', current_nic], check=False) break else: - run(['service', 'routing', 'restart']) + run(['service', 'routing', 'restart'], check=False) diff --git a/src/link-up.py b/src/link-up.py index d1021f8..6da7831 100755 --- a/src/link-up.py +++ b/src/link-up.py @@ -1,5 +1,12 @@ #!/usr/local/bin/python3 +"""devd action for IFNET LINK_UP events on ethernet interfaces. + +Invoked by /usr/local/etc/devd/networkmgr.conf with the interface name as its +only argument. Restores DHCP on an interface that auto-switch.py previously +marked as down, or otherwise starts dhclient quietly. +""" + import os import re import sys @@ -7,30 +14,31 @@ args = sys.argv if len(args) != 2: - exit(1) + sys.exit(1) nic = args[1] -not_nics_regex = r"(enc|lo|fwe|fwip|tap|plip|pfsync|pflog|ipfw|tun|sl|faith|" \ +NOT_NICS_REGEX = r"(enc|lo|fwe|fwip|tap|plip|pfsync|pflog|ipfw|tun|sl|faith|" \ r"ppp|bridge|wg|wlan)[0-9]+|vm-[a-z]+" # Stop the script if the nic is not valid. -if re.search(not_nics_regex, nic): - exit(0) +if re.search(NOT_NICS_REGEX, nic): + sys.exit(0) # This marker file is created by auto-switch.py when the nic is down. -if os.path.exists(f'/tmp/link-down-{nic}'): +if os.path.exists(f'/var/run/link-down-{nic}'): nic_ifconfig = run( ['ifconfig', nic], stdout=PIPE, - universal_newlines=True + universal_newlines=True, + check=False ).stdout if 'inet ' not in nic_ifconfig: - run(['service', 'netif', 'start', nic]) + run(['service', 'netif', 'start', nic], check=False) - run(['service', 'routing', 'restart']) - run(['service', 'dhclient', 'restart', nic]) + run(['service', 'routing', 'restart'], check=False) + run(['service', 'dhclient', 'restart', nic], check=False) # Clean up marker file - os.remove(f'/tmp/link-down-{nic}') + os.remove(f'/var/run/link-down-{nic}') else: - run(['service', 'dhclient', 'quietstart', nic]) + run(['service', 'dhclient', 'quietstart', nic], check=False) diff --git a/src/setup-nic.py b/src/setup-nic.py index ee57618..e71743c 100755 --- a/src/setup-nic.py +++ b/src/setup-nic.py @@ -1,5 +1,13 @@ #!/usr/local/bin/python3 +"""devd action for IFNET ATTACH events and wireless driver attachments. + +Invoked by /usr/local/etc/devd/networkmgr.conf with the interface or device +name as its only argument. Declares the interface in rc.conf if it is not +declared yet, installs or tops up /etc/wpa_supplicant.conf for a wireless +device, and brings the interface up through /etc/pccard_ether. +""" + import os import re import shutil @@ -9,6 +17,14 @@ def file_content(paths): + """Read several files and return their contents joined together. + + Args: + paths (list[pathlib.Path]): Files to read, in order. + + Returns: + str: The concatenated contents of every path. + """ buffers = [] for path in paths: with path.open('r') as file: @@ -16,46 +32,82 @@ def file_content(paths): return "".join(buffers) +def setting_lines(text): + """Yield the key and the raw line for every key=value line. + + Blank lines and comments are skipped. + + Args: + text (str): Contents of a wpa_supplicant.conf style file. + + Yields: + tuple[str, str]: The setting name and the line it came from. + """ + for line in text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith('#') or '=' not in stripped: + continue + yield stripped.split('=', 1)[0].strip(), line + + +def add_missing_settings(conf, template): + """Prepend the template globals that a config file does not declare yet. + + Args: + conf (pathlib.Path): Config file to update in place. + template (pathlib.Path): Template holding the expected globals. + """ + current = conf.read_text() + present = {key for key, _ in setting_lines(current)} + missing = [line for key, line in setting_lines(template.read_text()) + if key not in present] + if missing: + conf.write_text("\n".join(missing) + "\n" + current) + + args = sys.argv if len(args) != 2: - exit(1) + sys.exit(1) nic = args[1] etc = Path(os.sep, "etc") rc_conf = etc / "rc.conf" rc_conf_local = etc / "rc.conf.local" wpa_supplicant = etc / "wpa_supplicant.conf" +wpa_supplicant_template = Path("/usr/local/share/networkmgr/wpa_supplicant.conf") rc_conf_paths = [rc_conf] if rc_conf_local.exists(): rc_conf_paths.append(rc_conf_local) -rc_conf_content = file_content(rc_conf_paths) +RC_CONF_CONTENT = file_content(rc_conf_paths) -not_nics_regex = r"(enc|lo|fwe|fwip|tap|plip|pfsync|pflog|ipfw|tun|sl|faith|" \ +NOT_NICS_REGEX = r"(enc|lo|fwe|fwip|tap|plip|pfsync|pflog|ipfw|tun|sl|faith|" \ r"ppp|bridge|wg|wlan)[0-9]+|vm-[a-z]+" -# wifi_driver_regex is taken from devd.conf wifi-driver-regex -wifi_driver_regex = "(ath|ath[0-9]+k|bwi|bwn|ipw|iwlwifi|iwi|iwm|iwn|malo|mwl|mt79|otus|" \ +# WIFI_DRIVER_REGEX is taken from devd.conf wifi-driver-regex +WIFI_DRIVER_REGEX = "(ath|ath[0-9]+k|bwi|bwn|ipw|iwlwifi|iwi|iwm|iwn|malo|mwl|mt79|otus|" \ "ral|rsu|rtw|rtwn|rum|run|uath|upgt|ural|urtw|wpi|wtap|zyd)[0-9]+" -if re.search(not_nics_regex, nic): - exit(0) +if re.search(NOT_NICS_REGEX, nic): + sys.exit(0) -if re.search(wifi_driver_regex, nic): +if re.search(WIFI_DRIVER_REGEX, nic): if not wpa_supplicant.exists(): - wpa_supplicant.touch() + shutil.copyfile(wpa_supplicant_template, wpa_supplicant) shutil.chown(wpa_supplicant, user="root", group="wheel") wpa_supplicant.chmod(0o600) # Secure: root-only, contains passwords - if f'wlans_{nic}=' not in rc_conf_content: - for wlanNum in range(9): - if f'wlan{wlanNum}' not in rc_conf_content: - run(['sysrc', f'wlans_{nic}=wlan{wlanNum}']) - run(['sysrc', f'ifconfig_wlan{wlanNum}=WPA DHCP']) + else: + add_missing_settings(wpa_supplicant, wpa_supplicant_template) + if f'wlans_{nic}=' not in RC_CONF_CONTENT: + for wlan_number in range(9): + if f'wlan{wlan_number}' not in RC_CONF_CONTENT: + run(['sysrc', f'wlans_{nic}=wlan{wlan_number}'], check=False) + run(['sysrc', f'ifconfig_wlan{wlan_number}=WPA DHCP'], check=False) break - run(['/etc/pccard_ether', nic, 'startchildren']) + run(['/etc/pccard_ether', nic, 'startchildren'], check=False) else: - if f'ifconfig_{nic}=' not in rc_conf_content: - run(['sysrc', f'ifconfig_{nic}=DHCP']) - run(['/etc/pccard_ether', nic, 'start']) + if f'ifconfig_{nic}=' not in RC_CONF_CONTENT: + run(['sysrc', f'ifconfig_{nic}=DHCP'], check=False) + run(['/etc/pccard_ether', nic, 'start'], check=False) diff --git a/src/sudoers.d/networkmgr b/src/sudoers.d/networkmgr index 3d9bf4c..63383d6 100644 --- a/src/sudoers.d/networkmgr +++ b/src/sudoers.d/networkmgr @@ -1 +1 @@ -%wheel ALL=(ALL) NOPASSWD: /usr/local/bin/networkmgr +%operator ALL=(ALL) NOPASSWD: /usr/local/bin/networkmgr diff --git a/src/wpa_supplicant.conf b/src/wpa_supplicant.conf new file mode 100644 index 0000000..53badc2 --- /dev/null +++ b/src/wpa_supplicant.conf @@ -0,0 +1,7 @@ +# Global settings installed by NetworkMgr. Network blocks are appended below +# by NetworkMgr; setup-nic.py adds any of these lines that are missing. +ctrl_interface=/var/run/wpa_supplicant +ctrl_interface_group=operator +update_config=1 +pmf=1 +autoscan=exponential:3:300 \ No newline at end of file diff --git a/tests/README.md b/tests/README.md deleted file mode 100644 index 754b387..0000000 --- a/tests/README.md +++ /dev/null @@ -1,74 +0,0 @@ -# Testing networkmgr modules - -Testing networkmgr modules with pytest - -1. Create a virtual environment using python 3.8 -2. Activate the venv -3. Install pytest and pytest coverage -4. Execute pip list to verify your environment. - -```bash -python3.8 -m venv venv38 - -source venv38/bin/activate - -pip install pytest pytest-cov - -networkmgr-fork/tests on  unit_tests [!?] via 🐍 v3.8.12 (venv38) -❯ pip list -Package Version ----------- ------- -attrs 21.2.0 -coverage 6.2 -iniconfig 1.1.1 -packaging 21.3 -pip 21.3.1 -pluggy 1.0.0 -py 1.11.0 -pyparsing 3.0.6 -pytest 6.2.5 -pytest-cov 3.0.0 -setuptools 60.1.0 -sqlite3 0.0.0 -Tkinter 0.0.0 -toml 0.10.2 -tomli 2.0.0 -``` - -To run the tests navigate to the tests directory and enter the following command, - -```bash -pytest -lv --cov src unit/ -``` - -The result will be similar to this, - -```bash -❯ pytest -lv --cov src unit/ -================================================== test session starts =================================================== -platform freebsd13 -- Python 3.8.12, pytest-6.2.5, py-1.11.0, pluggy-1.0.0 -- /usr/home//venv38/bin/python3.8 -cachedir: .pytest_cache -rootdir: /usr/home/rgeorgia/PycharmProjects/networkmgr-fork -plugins: cov-3.0.0 -collected 7 items - -unit/test_net_api.py::test_default_card_returns_str PASSED [ 14%] -unit/test_net_api.py::test_card_online PASSED [ 28%] -unit/test_net_api.py::test_card_not_online PASSED [ 42%] -unit/test_net_api.py::test_connection_status_card_is_none PASSED [ 57%] -unit/test_net_api.py::test_connection_status_card_is_default PASSED [ 71%] -unit/test_net_api.py::test_connection_status_card_is_wlan_not_connected PASSED [ 85%] -unit/test_net_api.py::test_connection_status_card_is_wlan_connected PASSED [100%] - --------- coverage: platform freebsd13, python 3.8.12-final-0 --------- -Name Stmts Miss Cover ---------------------------------------------------------------------------------------- -/usr/home//networkmgr-fork/src/net_api.py 199 119 40% ---------------------------------------------------------------------------------------- -TOTAL 199 119 40% - - -=================================================== 7 passed in 0.11s ==================================================== - - -``` diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/unit/test_enterprise_wpa.py b/tests/unit/test_enterprise_wpa.py deleted file mode 100644 index acf4716..0000000 --- a/tests/unit/test_enterprise_wpa.py +++ /dev/null @@ -1,227 +0,0 @@ -""" -Unit tests for Enterprise WPA (802.1X/EAP) functionality. -""" -import sys -import os -import tempfile -from pathlib import Path - -import pytest - -# Add project root to path -top_dir = str(Path(__file__).absolute().parent.parent.parent) -sys.path.insert(0, top_dir) - -from NetworkMgr.net_api import ( - EAP_METHODS, - PHASE2_METHODS, - DEFAULT_CA_CERT, - is_enterprise_network, - get_security_type, - validate_certificate, - get_system_ca_certificates, - generate_eap_config, -) - - -class TestEnterpriseDetection: - """Tests for enterprise network detection.""" - - def test_is_enterprise_network_with_eap(self): - """Test detection of EAP indicator.""" - assert is_enterprise_network("RSN-EAP WPA2-EAP") is True - assert is_enterprise_network("WPA2-EAP") is True - assert is_enterprise_network("EAP") is True - - def test_is_enterprise_network_with_8021x(self): - """Test detection of 802.1X indicator.""" - assert is_enterprise_network("RSN 802.1X") is True - assert is_enterprise_network("802.1X WPA2") is True - - def test_is_enterprise_network_psk(self): - """Test that PSK networks are not detected as enterprise.""" - assert is_enterprise_network("RSN WPA2-PSK") is False - assert is_enterprise_network("WPA-PSK") is False - assert is_enterprise_network("RSN HTCAP WME") is False - - def test_is_enterprise_network_open(self): - """Test that open networks are not detected as enterprise.""" - assert is_enterprise_network("") is False - assert is_enterprise_network("ESS") is False - - def test_is_enterprise_network_case_insensitive(self): - """Test case-insensitive detection.""" - assert is_enterprise_network("wpa2-eap") is True - assert is_enterprise_network("Eap") is True - - -class TestSecurityTypeDetection: - """Tests for security type detection.""" - - def test_get_security_type_wpa2_eap(self): - """Test WPA2-EAP detection.""" - assert get_security_type("RSN WPA2-EAP") == "WPA2-EAP" - assert get_security_type("RSN EAP") == "WPA2-EAP" - - def test_get_security_type_wpa_eap(self): - """Test WPA-EAP detection.""" - assert get_security_type("WPA EAP") == "WPA-EAP" - - def test_get_security_type_wpa2_psk(self): - """Test WPA2-PSK detection.""" - assert get_security_type("RSN HTCAP WME") == "WPA2-PSK" - # Bare RSN without consumer features is classified as enterprise - assert get_security_type("RSN") == "WPA2-EAP" - - def test_get_security_type_wpa_psk(self): - """Test WPA-PSK detection.""" - assert get_security_type("WPA HTCAP") == "WPA-PSK" - - def test_get_security_type_wep(self): - """Test WEP detection.""" - assert get_security_type("WEP") == "WEP" - assert get_security_type("PRIVACY") == "WEP" - - def test_get_security_type_open(self): - """Test open network detection.""" - assert get_security_type("") == "OPEN" - assert get_security_type("ESS") == "OPEN" - - -class TestCertificateValidation: - """Tests for certificate validation.""" - - def test_validate_certificate_empty_path(self): - """Test validation with empty path.""" - is_valid, error = validate_certificate("") - assert is_valid is False - assert "No certificate path" in error - - def test_validate_certificate_none_path(self): - """Test validation with None path.""" - is_valid, error = validate_certificate(None) - assert is_valid is False - - def test_validate_certificate_nonexistent(self): - """Test validation with nonexistent file.""" - is_valid, error = validate_certificate("/nonexistent/cert.pem") - assert is_valid is False - assert "not found" in error - - def test_validate_certificate_directory(self): - """Test validation with directory instead of file.""" - is_valid, error = validate_certificate("/tmp") - assert is_valid is False - assert "Not a file" in error - - def test_validate_certificate_valid_file(self): - """Test validation with valid readable file.""" - with tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as f: - f.write(b"test certificate content") - temp_path = f.name - try: - is_valid, error = validate_certificate(temp_path) - assert is_valid is True - assert error is None - finally: - os.unlink(temp_path) - - -class TestEapConfigGeneration: - """Tests for EAP configuration generation.""" - - def test_generate_eap_config_peap(self): - """Test PEAP configuration generation.""" - config = { - 'eap_method': 'PEAP', - 'identity': 'testuser', - 'password': 'testpass', - 'phase2': 'MSCHAPV2', - } - result = generate_eap_config("TestNetwork", config) - - assert 'ssid="TestNetwork"' in result - assert 'key_mgmt=WPA-EAP' in result - assert 'eap=PEAP' in result - assert 'identity="testuser"' in result - assert 'password="testpass"' in result - assert 'phase2="auth=MSCHAPV2"' in result - - def test_generate_eap_config_ttls(self): - """Test TTLS configuration generation.""" - config = { - 'eap_method': 'TTLS', - 'identity': 'user@domain.com', - 'password': 'secret', - 'phase2': 'PAP', - 'ca_cert': '/etc/ssl/certs/ca.pem', - } - result = generate_eap_config("CorpWifi", config) - - assert 'eap=TTLS' in result - assert 'identity="user@domain.com"' in result - assert 'phase2="auth=PAP"' in result - assert 'ca_cert="/etc/ssl/certs/ca.pem"' in result - - def test_generate_eap_config_tls(self): - """Test TLS (certificate-based) configuration generation.""" - config = { - 'eap_method': 'TLS', - 'identity': 'client@corp.com', - 'client_cert': '/etc/ssl/client.pem', - 'private_key': '/etc/ssl/client.key', - 'private_key_passwd': 'keypass', - 'ca_cert': '/etc/ssl/ca.pem', - } - result = generate_eap_config("SecureNet", config) - - assert 'eap=TLS' in result - assert 'identity="client@corp.com"' in result - assert 'client_cert="/etc/ssl/client.pem"' in result - assert 'private_key="/etc/ssl/client.key"' in result - assert 'private_key_passwd="keypass"' in result - assert 'password=' not in result # TLS doesn't use password - - def test_generate_eap_config_with_anonymous_identity(self): - """Test configuration with anonymous identity.""" - config = { - 'eap_method': 'PEAP', - 'identity': 'realuser', - 'password': 'pass', - 'anonymous_identity': 'anonymous@domain.com', - } - result = generate_eap_config("AnonNet", config) - - assert 'anonymous_identity="anonymous@domain.com"' in result - - def test_generate_eap_config_with_domain_match(self): - """Test configuration with domain suffix match.""" - config = { - 'eap_method': 'PEAP', - 'identity': 'user', - 'password': 'pass', - 'domain_suffix_match': 'radius.company.com', - } - result = generate_eap_config("SecNet", config) - - assert 'domain_suffix_match="radius.company.com"' in result - - -class TestEapConstants: - """Tests for EAP-related constants.""" - - def test_eap_methods_defined(self): - """Test that EAP methods are defined.""" - assert 'PEAP' in EAP_METHODS - assert 'TTLS' in EAP_METHODS - assert 'TLS' in EAP_METHODS - - def test_phase2_methods_defined(self): - """Test that Phase 2 methods are defined.""" - assert 'MSCHAPV2' in PHASE2_METHODS - assert 'GTC' in PHASE2_METHODS - assert 'PAP' in PHASE2_METHODS - - def test_default_ca_cert_path(self): - """Test default CA certificate path is set.""" - assert DEFAULT_CA_CERT == '/etc/ssl/certs/ca-root-nss.crt' diff --git a/tests/unit/test_net_api.py b/tests/unit/test_net_api.py deleted file mode 100644 index b042af6..0000000 --- a/tests/unit/test_net_api.py +++ /dev/null @@ -1,105 +0,0 @@ -import sys -from pathlib import Path -from subprocess import Popen -import subprocess - -import pytest - -top_dir = str(Path(__file__).absolute().parent.parent.parent) - -try: - from src.net_api import ( - card_online, - connectionStatus, - connectToSsid, - defaultcard, - delete_ssid_wpa_supplicant_config, - disableWifi, - enableWifi, - networkdictionary, - openrc, - startallnetwork, - startnetworkcard, - stopallnetwork, - stopnetworkcard, - wifiDisconnection, - wlan_status, - ) - import src.net_api -except ImportError: - sys.path.append(top_dir) - from src.net_api import ( - card_online, - connectionStatus, - connectToSsid, - defaultcard, - delete_ssid_wpa_supplicant_config, - disableWifi, - enableWifi, - networkdictionary, - openrc, - startallnetwork, - startnetworkcard, - stopallnetwork, - stopnetworkcard, - wifiDisconnection, - wlan_status, - ) - import src.net_api - - -def test_default_card_returns_str(): - """test for src.net_api.defaultcard""" - result = defaultcard() - assert isinstance(result, str) - - -# TODO: mock subprocess to return empty list - -def test_card_online(): - net_card = defaultcard() - result = card_online(net_card) - assert result - - -def test_card_not_online(): - net_card = "em99" - result = card_online(net_card) - assert not result - - -def test_connection_status_card_is_none(): - """test for src.net_api.connectionStatus""" - card = None - result = connectionStatus(card) - assert isinstance(result, str) - assert "Network card is not enabled" == result - - -def test_connection_status_card_is_default(): - """test for src.net_api.connectionStatus""" - card = defaultcard() - result = connectionStatus(card) - assert isinstance(result, str) - assert "inet" in result - assert "netmask" in result - assert "broadcast" in result - - -def test_connection_status_card_is_wlan_not_connected(): - """test for src.net_api.connectionStatus""" - card = 'wlan99' - result = connectionStatus(card) - assert isinstance(result, str) - assert f"WiFi {card} not connected" in result - - -def test_connection_status_card_is_wlan_connected(): - """test for src.net_api.connectionStatus""" - card = 'wlan0' - result = connectionStatus(card) - assert isinstance(result, str) - assert "inet" in result - assert "ssid" in result - assert "netmask" in result - assert "broadcast" in result From 66e8140eef93654d475df463a7cdd475f7bb1fd1 Mon Sep 17 00:00:00 2001 From: ericbsd Date: Fri, 2 Oct 2026 07:37:37 -0300 Subject: [PATCH 2/2] Bring the wireless interface up on first setup On a fresh system wpa_supplicant has no saved network. driver_bsd downs the interface while it initializes and only raises it again to scan or associate, which it never does without an enabled network. rc does not run ifconfig up on WPA interfaces either, so the card stayed down and wpa_supplicant sat in INTERFACE_DISABLED, refusing scan requests with FAIL-BUSY. When setup-nic.py declares the wlan in rc.conf for the first time, it now marks the interface up after pccard_ether starts it, so wpa_supplicant sees the interface enabled and starts scanning. --- src/setup-nic.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/setup-nic.py b/src/setup-nic.py index e71743c..2d6fdd1 100755 --- a/src/setup-nic.py +++ b/src/setup-nic.py @@ -5,7 +5,9 @@ Invoked by /usr/local/etc/devd/networkmgr.conf with the interface or device name as its only argument. Declares the interface in rc.conf if it is not declared yet, installs or tops up /etc/wpa_supplicant.conf for a wireless -device, and brings the interface up through /etc/pccard_ether. +device, and brings the interface up through /etc/pccard_ether. Wireless +children are then marked up with ifconfig, since wpa_supplicant leaves them +down when it has no network to join. """ import os @@ -106,7 +108,11 @@ def add_missing_settings(conf, template): run(['sysrc', f'wlans_{nic}=wlan{wlan_number}'], check=False) run(['sysrc', f'ifconfig_wlan{wlan_number}=WPA DHCP'], check=False) break - run(['/etc/pccard_ether', nic, 'startchildren'], check=False) + run(['/etc/pccard_ether', nic, 'startchildren'], check=False) + # On the first setup wlan is down + run(['ifconfig', nic, 'up'], check=False) + else: + run(['/etc/pccard_ether', nic, 'startchildren'], check=False) else: if f'ifconfig_{nic}=' not in RC_CONF_CONTENT: run(['sysrc', f'ifconfig_{nic}=DHCP'], check=False)