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..2d6fdd1 100755 --- a/src/setup-nic.py +++ b/src/setup-nic.py @@ -1,5 +1,15 @@ #!/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. Wireless +children are then marked up with ifconfig, since wpa_supplicant leaves them +down when it has no network to join. +""" + import os import re import shutil @@ -9,6 +19,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 +34,86 @@ 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) + # 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']) - 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