diff --git a/.vscode/launch.json b/.vscode/launch.json index fbd05eb..fa35cf8 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -14,7 +14,9 @@ "wlanpi_core.asgi:app", "--reload", "--host", - "0.0.0.0" + "0.0.0.0", + "--port", + "31415" ], "jinja": true, "sudo": true diff --git a/debian/changelog b/debian/changelog index eb066d2..56bdc2e 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +wlanpi-core (1.0.1) UNRELEASED; urgency=medium + + * Add Network APIs for managing wlan0 via DBUS + + -- Ben Toner <ben@numerousnetworks.co.uk> Wed, 11 Sep 2024 10:32:40 -0500 + wlanpi-core (1.0.0-7) unstable; urgency=medium * Add "wlanpi-grafana-wispy-*" and "wlanpi-grafana-wipry-*" to allowlist. diff --git a/debian/control b/debian/control index 97c7591..d2e750f 100644 --- a/debian/control +++ b/debian/control @@ -15,7 +15,10 @@ Build-Depends: debhelper (>= 11), dbus, libdbus-1-dev, libdbus-glib-1-dev, - libglib2.0-dev + libglib2.0-dev, + libcairo2-dev, + libgirepository1.0-dev, + libffi-dev Standards-Version: 4.6.0 X-Python3-Version: >= 3.9 Homepage: https://github.com/WLAN-Pi/wlanpi-core diff --git a/debian/rules b/debian/rules index b8ec9d2..50dfb43 100755 --- a/debian/rules +++ b/debian/rules @@ -21,6 +21,10 @@ SDIST_DIR=debian/$(PACKAGE)-$(VERSION) .PHONY: override_dh_virtualenv override_dh_installexamples +# ensure that the systemd services are handled by systemd. +override_dh_installsystemd: + dh_installsystemd --name=wpa_supplicant@wlan0 wpa_supplicant@wlan0.service + # we don't really want to strip the symbols from our object files. override_dh_strip: diff --git a/debian/wlanpi-core.install b/debian/wlanpi-core.install index da48490..751fea3 100644 --- a/debian/wlanpi-core.install +++ b/debian/wlanpi-core.install @@ -1,4 +1,5 @@ /install/etc/wlanpi-core/nginx/nginx-sample.conf /etc/wlanpi-core/nginx /install/etc/wlanpi-core/nginx/wlanpi_core.conf /etc/wlanpi-core/nginx/sites-enabled /install/etc/wlanpi-core/nginx/link.sh /etc/wlanpi-core/scripts -/install/etc/wlanpi-core/nginx/unlink.sh /etc/wlanpi-core/scripts \ No newline at end of file +/install/etc/wlanpi-core/nginx/unlink.sh /etc/wlanpi-core/scripts +/install/etc/wpa_supplicant/wpa_supplicant-wlan0.conf /etc/wpa_supplicant \ No newline at end of file diff --git a/debian/wlanpi-core.substvars b/debian/wlanpi-core.substvars index dd461a1..27d015a 100644 --- a/debian/wlanpi-core.substvars +++ b/debian/wlanpi-core.substvars @@ -1,3 +1,3 @@ -shlibs:Depends=libc6 (>= 2.17), libdbus-1-3 (>= 1.9.14), libgcc-s1 (>= 4.2), libglib2.0-0 (>= 2.16.0) +shlibs:Depends=libc6 (>= 2.17), libcairo-gobject2 (>= 1.12.16), libcairo2 (>= 1.15.12), libdbus-1-3 (>= 1.9.14), libffi7 (>= 3.3~20180313), libgcc-s1 (>= 4.2), libgirepository-1.0-1 (>= 1.62.0-4~), libglib2.0-0 (>= 2.53.1) misc:Depends= misc:Pre-Depends= diff --git a/debian/wpa_supplicant@wlan0.service b/debian/wpa_supplicant@wlan0.service new file mode 100644 index 0000000..c456a38 --- /dev/null +++ b/debian/wpa_supplicant@wlan0.service @@ -0,0 +1,20 @@ +#DBUS Managed WPA_Supplicant Service on WLAN0 + +[Unit] +Description=WPA supplicant daemon (interface-specific version) +Requires=sys-subsystem-net-devices-%i.device +After=sys-subsystem-net-devices-%i.device +Before=network.target +Wants=network.target + +# NetworkManager users will probably want the dbus version instead. + +[Service] +Type=simple +#ExecStart=/sbin/wpa_supplicant -c/etc/wpa_supplicant/wpa_supplicant-%I.conf -i%I +ExecStart=/sbin/wpa_supplicant -u -s -O /run/wpa_supplicant -c/etc/wpa_supplicant/wpa_supplicant-%I.conf -i%I +ExecReload=/bin/kill -HUP \$MAINPID + +[Install] +WantedBy=multi-user.target +Alias=dbus-fi.w1.wpa_supplicant1.service \ No newline at end of file diff --git a/install/etc/wpa_supplicant/wpa_supplicant-wlan0.conf b/install/etc/wpa_supplicant/wpa_supplicant-wlan0.conf new file mode 100644 index 0000000..f4a9471 --- /dev/null +++ b/install/etc/wpa_supplicant/wpa_supplicant-wlan0.conf @@ -0,0 +1,64 @@ +ctrl_interface=DIR=/run/wpa_supplicant +ap_scan=1 +p2p_disabled=1 + +####################################################################################### +# NOTE: to use the templates below, remove the hash symbols at the start of each line +####################################################################################### + +# WPA2 PSK Network sample (highest priority - joined first) +#network={ +# ssid="enter SSID Name" +# psk="enter key" +# priority=10 +#} + +# WPA2 PSK Network sample (next priority - joined if first priority not available) - don't unhash this line + +#network={ +# ssid="enter SSID Name" +# psk="enter key" +# priority=3 +#} + +# WPA2 PEAP example (next priority - joined if second priority not available) - don't unhash this line + +#network={ +# ssid="enter SSID Name" +# key_mgmt=WPA-EAP +# eap=PEAP +# anonymous_identity="anonymous" +# identity="enter your username" +# password="enter your password" +# phase2="autheap=MSCHAPV2" +# priority=2 +#} + +# Open network example (lowest priority, only joined other 3 networks not available) - don't unhash this line + +#network={ +# ssid="enter SSID Name" +# key_mgmt=NONE +# priority=1 +#} + +# SAE mechanism for PWE derivation +# 0 = hunting-and-pecking (HNP) loop only (default without password identifier) +# 1 = hash-to-element (H2E) only (default with password identifier) +# 2 = both hunting-and-pecking loop and hash-to-element enabled +# Note: The default value is likely to change from 0 to 2 once the new +# hash-to-element mechanism has received more interoperability testing. +# When using SAE password identifier, the hash-to-element mechanism is used +# regardless of the sae_pwe parameter value. +# +#sae_pwe=0 <--- default value, change to 1 or 2 if AP forces H2E. + +# WPA3 PSK network sample for 6 GHz (note SAE and PMF is required) - don't unhash this line + +#network={ +# ssid="6 GHz SSID" +# psk="password" +# priority=10 +# key_mgmt=SAE +# ieee80211w=2 +#} diff --git a/requirements.in b/requirements.in index 7e0257f..e146a71 100644 --- a/requirements.in +++ b/requirements.in @@ -11,4 +11,5 @@ requests # endpoint requires psutil -dbus-python \ No newline at end of file +dbus-python +PyGObject diff --git a/requirements.txt b/requirements.txt index 3d0f831..1094a16 100644 --- a/requirements.txt +++ b/requirements.txt @@ -52,6 +52,8 @@ packaging==23.1 # via gunicorn psutil==5.9.5 # via -r requirements.in +pycairo==1.25.1 + # via pygobject pydantic==2.3.0 # via # fastapi @@ -60,6 +62,8 @@ pydantic-core==2.6.3 # via pydantic pydantic-settings==2.0.3 # via -r requirements.in +pygobject==3.46.0 + # via -r requirements.in python-dotenv==1.0.0 # via # -r requirements.in diff --git a/wlanpi_core/__main__.py b/wlanpi_core/__main__.py index 4a0f819..5dbc987 100644 --- a/wlanpi_core/__main__.py +++ b/wlanpi_core/__main__.py @@ -26,6 +26,23 @@ from .__version__ import __version__ +def port(port) -> int: + """Check if the provided port is valid""" + try: + # make sure port is an int + port = int(port) + except ValueError: + raise ValueError("%s is not a number") + + port_ranges = [(1024, 65353)] + + for _range in port_ranges: + if _range[0] <= port <= _range[1]: + return port + + raise ValueError("%s not a valid. Pick a port between %s.", port, port_ranges) + + def setup_parser() -> argparse.ArgumentParser: """Set default values and handle arg parser""" parser = argparse.ArgumentParser( @@ -35,6 +52,8 @@ def setup_parser() -> argparse.ArgumentParser: parser.add_argument( "--reload", dest="livereload", action="store_true", default=False ) + parser.add_argument("--port", "-p", dest="port", type=port, default=8000) + parser.add_argument( "--version", "-V", "-v", action="version", version=f"{__version__}" ) @@ -68,7 +87,7 @@ def main() -> None: if lets_go: uvicorn.run( "wlanpi_core.asgi:app", - port=8000, + port=args.port, host="0.0.0.0", reload=args.livereload, log_level="debug", diff --git a/wlanpi_core/__version__.py b/wlanpi_core/__version__.py index 9520c5e..afb6c20 100644 --- a/wlanpi_core/__version__.py +++ b/wlanpi_core/__version__.py @@ -10,7 +10,7 @@ __url__ = "https://github.com/wlan-pi/wlanpi-core" __author__ = "Josh Schmelzle" __author_email__ = "josh@joshschmelzle.com" -__version__ = "1.0.0" +__version__ = "1.0.1" __status__ = "alpha" __license__ = "BSD-3-Clause" __license_url__ = "https://opensource.org/licenses/BSD-3-Clause" diff --git a/wlanpi_core/api/api_v1/endpoints/network_api.py b/wlanpi_core/api/api_v1/endpoints/network_api.py index aa9377d..765ed2c 100644 --- a/wlanpi_core/api/api_v1/endpoints/network_api.py +++ b/wlanpi_core/api/api_v1/endpoints/network_api.py @@ -1,7 +1,92 @@ import logging -from fastapi import APIRouter +from fastapi import APIRouter, Response + +from wlanpi_core.models.validation_error import ValidationError +from wlanpi_core.schemas import network +from wlanpi_core.services import network_service router = APIRouter() +API_DEFAULT_TIMEOUT = 20 + log = logging.getLogger("uvicorn") + + +@router.get("/wlan/getInterfaces", response_model=network.Interfaces) +async def get_a_systemd_network_interfaces(timeout: int = API_DEFAULT_TIMEOUT): + """ + Queries systemd via dbus to get the details of the currently connected network. + """ + + try: + return await network_service.get_systemd_network_interfaces(timeout) + except ValidationError as ve: + return Response(content=ve.error_msg, status_code=ve.status_code) + except Exception as ex: + log.error(ex) + return Response(content="Internal Server Error", status_code=500) + + +@router.get( + "/wlan/scan", response_model=network.ScanResults, response_model_exclude_none=True +) +async def get_a_systemd_network_scan( + type: str, interface: str, timeout: int = API_DEFAULT_TIMEOUT +): + """ + Queries systemd via dbus to get a scan of the available networks. + """ + + try: + # return await network_service.get_systemd_network_scan(type) + return await network_service.get_async_systemd_network_scan( + type, interface, timeout + ) + except ValidationError as ve: + return Response(content=ve.error_msg, status_code=ve.status_code) + except Exception as ex: + log.error(ex) + return Response(content="Internal Server Error", status_code=500) + + +@router.post("/wlan/set", response_model=network.NetworkSetupStatus) +async def set_a_systemd_network( + setup: network.WlanInterfaceSetup, timeout: int = API_DEFAULT_TIMEOUT +): + """ + Queries systemd via dbus to set a single network. + """ + + try: + return await network_service.set_systemd_network_addNetwork( + setup.interface, setup.netConfig, setup.removeAllFirst, timeout + ) + except ValidationError as ve: + return Response(content=ve.error_msg, status_code=ve.status_code) + except Exception as ex: + log.error(ex) + return Response(content="Internal Server Error", status_code=500) + + +@router.get( + "/wlan/getConnected", + response_model=network.ConnectedNetwork, + response_model_exclude_none=True, +) +async def get_a_systemd_currentNetwork_details( + interface: str, timeout: int = API_DEFAULT_TIMEOUT +): + """ + Queries systemd via dbus to get the details of the currently connected network. + """ + + try: + return await network_service.get_systemd_network_currentNetwork_details( + interface, timeout + ) + except ValidationError as ve: + return Response(content=ve.error_msg, status_code=ve.status_code) + except Exception as ex: + log.error(ex) + return Response(content="Internal Server Error", status_code=500) diff --git a/wlanpi_core/core/__init__.py b/wlanpi_core/core/__init__.py index e69de29..f40cc53 100644 --- a/wlanpi_core/core/__init__.py +++ b/wlanpi_core/core/__init__.py @@ -0,0 +1,3 @@ +from dbus.mainloop.glib import DBusGMainLoop + +DBusGMainLoop(set_as_default=True) diff --git a/wlanpi_core/schemas/network/__init__.py b/wlanpi_core/schemas/network/__init__.py index 5f92637..ce9f921 100644 --- a/wlanpi_core/schemas/network/__init__.py +++ b/wlanpi_core/schemas/network/__init__.py @@ -1 +1,13 @@ -from .network import PublicIP +from .network import ( + APIConfig, + ConnectedNetwork, + Interface, + Interfaces, + NetworkEvent, + NetworkSetupLog, + NetworkSetupStatus, + PublicIP, + ScanResults, + WlanConfig, + WlanInterfaceSetup, +) diff --git a/wlanpi_core/schemas/network/network.py b/wlanpi_core/schemas/network/network.py index 419524d..0184862 100644 --- a/wlanpi_core/schemas/network/network.py +++ b/wlanpi_core/schemas/network/network.py @@ -1,3 +1,5 @@ +from typing import List, Union + from pydantic import BaseModel, Field @@ -13,3 +15,64 @@ class PublicIP(BaseModel): asn: str = Field(example="AS12345") asn_org: str = Field(example="INTERNET") hostname: str = Field(example="d-192-168-1-50.paw.cpe.chicagoisp.net") + + +class ScanItem(BaseModel): + ssid: str = Field(example="A Network") + bssid: str = Field(example="11:22:33:44:55") + key_mgmt: str = Field(example="wpa-psk") + signal: int = Field(example=-65) + freq: int = Field(example=5650) + minrate: int = Field(example=1000000) + + +class ScanResults(BaseModel): + nets: List[ScanItem] + + +class WlanConfig(BaseModel): + ssid: str = Field(example="SSID Name") + psk: Union[str, None] = None + proto: Union[str, None] = None + key_mgmt: str = Field(example="NONE, SAE") + ieee80211w: Union[int, None] = None + + +class WlanInterfaceSetup(BaseModel): + interface: str = Field(example="wlan0") + netConfig: WlanConfig + removeAllFirst: bool + + +class NetworkEvent(BaseModel): + event: str = Field(example="authenticated") + time: str = Field(example="2024-09-01 03:52:31.232828") + + +class NetworkSetupLog(BaseModel): + selectErr: str = Field(example="fi.w1.wpa_supplicant1.NetworkUnknown") + eventLog: List[NetworkEvent] + + +class NetworkSetupStatus(BaseModel): + status: str = Field(example="connected") + response: NetworkSetupLog + connectedNet: ScanItem + input: str + + +class ConnectedNetwork(BaseModel): + connectedStatus: bool = Field(example=True) + connectedNet: Union[ScanItem, None] + + +class Interface(BaseModel): + interface: str = Field(example="wlan0") + + +class Interfaces(BaseModel): + interfaces: List[Interface] + + +class APIConfig(BaseModel): + timeout: int = Field(example=20) diff --git a/wlanpi_core/services/network_service.py b/wlanpi_core/services/network_service.py index e69de29..5eb4bce 100644 --- a/wlanpi_core/services/network_service.py +++ b/wlanpi_core/services/network_service.py @@ -0,0 +1,695 @@ +import subprocess +import time +from datetime import datetime + +import dbus +from dbus import Interface +from dbus.exceptions import DBusException +from gi.repository import GLib + +from wlanpi_core.models.validation_error import ValidationError +from wlanpi_core.schemas import network + +# For running locally (not in API) +# import asyncio + +WPAS_DBUS_SERVICE = "fi.w1.wpa_supplicant1" +WPAS_DBUS_INTERFACE = "fi.w1.wpa_supplicant1" +WPAS_DBUS_OPATH = "/fi/w1/wpa_supplicant1" +WPAS_DBUS_INTERFACES_INTERFACE = "fi.w1.wpa_supplicant1.Interface" +WPAS_DBUS_INTERFACES_OPATH = "/fi/w1/wpa_supplicant1/Interfaces" +WPAS_DBUS_BSS_INTERFACE = "fi.w1.wpa_supplicant1.BSS" +WPAS_DBUS_NETWORK_INTERFACE = "fi.w1.wpa_supplicant1.Network" + +API_TIMEOUT = 20 + +# Define a global debug level variable +DEBUG_LEVEL = 1 +# Debug Level 0: No messages are printed. +# Debug Level 1: Only low-level messages (level 1) are printed. +# Debug Level 2: Low-level and medium-level messages (levels 1 and 2) are printed. +# Debug Level 3: All messages (levels 1, 2, and 3) are printed. + + +def set_debug_level(level): + """ + Sets the global debug level. + + :param level: The desired debug level (0 for no debug, higher values for more verbosity). + """ + global DEBUG_LEVEL + DEBUG_LEVEL = level + + +def debug_print(message, level): + """ + Prints a message to the console based on the global debug level. + + :param message: The message to be printed. + :param level: The level of the message (e.g., 1 for low, 2 for medium, 3 for high). + """ + if level <= DEBUG_LEVEL: + print(message) + + +allowed_scan_types = [ + "active", + "passive", +] + + +def is_allowed_scan_type(scan: str): + for allowed_scan_type in allowed_scan_types: + if scan == allowed_scan_type: + return True + return False + + +def is_allowed_interface(interface: str, wpas_obj): + available_interfaces = fetch_interfaces(wpas_obj) + for allowed_interface in available_interfaces: + if interface == allowed_interface: + return True + return False + + +def byte_array_to_string(s): + r = "" + for c in s: + if c >= 32 and c < 127: + r += "%c" % c + else: + r += " " + # r += urllib.quote(chr(c)) + return r + + +def renew_dhcp(interface): + """ + Uses dhclient to release and request a new DHCP lease + """ + try: + # Release the current DHCP lease + subprocess.run(["sudo", "dhclient", "-r", interface], check=True) + time.sleep(3) + # Obtain a new DHCP lease + subprocess.run(["sudo", "dhclient", interface], check=True) + except subprocess.CalledProcessError as spe: + debug_print(f"Failed to renew DHCP. Error {spe}", 1) + + +def get_ip_address(interface): + """ + Extract the IP Address from the linux ip add show <if> command + """ + try: + # Run the command to get details for a specific interface + result = subprocess.run( + ["ip", "addr", "show", interface], + capture_output=True, + text=True, + check=True, + ) + + # Process the output to find the inet line which contains the IPv4 address + for line in result.stdout.split("\n"): + if "inet " in line: + # Extract the IP address from the line + ip_address = line.strip().split(" ")[1].split("/")[0] + return ip_address + except subprocess.CalledProcessError as spe: + debug_print("Failed to get IP address. Error {spe}", 1) + return None + + +def getBss(bss): + """ + Queries DBUS_BSS_INTERFACE through dbus for a BSS Path + + Example path: /fi/w1/wpa_supplicant1/Interfaces/0/BSSs/567 + """ + + try: + net_obj = bus.get_object(WPAS_DBUS_SERVICE, bss) + # dbus.Interface(net_obj, WPAS_DBUS_BSS_INTERFACE) + # Convert the byte-array to printable strings + + # Get the BSSID from the byte array + val = net_obj.Get( + WPAS_DBUS_BSS_INTERFACE, "BSSID", dbus_interface=dbus.PROPERTIES_IFACE + ) + bssid = "" + for item in val: + bssid = bssid + ":%02x" % item + bssid = bssid[1:] + + # Get the SSID from the byte array + val = net_obj.Get( + WPAS_DBUS_BSS_INTERFACE, "SSID", dbus_interface=dbus.PROPERTIES_IFACE + ) + ssid = byte_array_to_string(val) + + # Get the WPA Type from the byte array + val = net_obj.Get( + WPAS_DBUS_BSS_INTERFACE, "WPA", dbus_interface=dbus.PROPERTIES_IFACE + ) + if len(val["KeyMgmt"]) > 0: + pass + + # Get the RSN Info from the byte array + val = net_obj.Get( + WPAS_DBUS_BSS_INTERFACE, "RSN", dbus_interface=dbus.PROPERTIES_IFACE + ) + key_mgmt = "/".join([str(r) for r in val["KeyMgmt"]]) + + # Get the Frequency from the byte array + freq = net_obj.Get( + WPAS_DBUS_BSS_INTERFACE, "Frequency", dbus_interface=dbus.PROPERTIES_IFACE + ) + + # Get the RSSI from the byte array + signal = net_obj.Get( + WPAS_DBUS_BSS_INTERFACE, "Signal", dbus_interface=dbus.PROPERTIES_IFACE + ) + + # Get the Phy Rates from the byte array + rates = net_obj.Get( + WPAS_DBUS_BSS_INTERFACE, "Rates", dbus_interface=dbus.PROPERTIES_IFACE + ) + + minrate = 0 + if len(rates) > 0: + minrate = rates[-1] + + # Get the IEs from the byte array + IEs = net_obj.Get( + WPAS_DBUS_BSS_INTERFACE, "IEs", dbus_interface=dbus.PROPERTIES_IFACE + ) + debug_print(f"IEs: {IEs}", 3) + + return { + "ssid": ssid, + "bssid": bssid, + "key_mgmt": key_mgmt, + "signal": signal, + "freq": freq, + "minrate": minrate, + } + + except DBusException: + return None + except ValueError as error: + raise ValidationError(f"{error}", status_code=400) + + +def pretty_print_BSS(BSSPath): + BSSDetails = getBss(BSSPath) + if BSSDetails: + ssid = BSSDetails["ssid"] if BSSDetails["ssid"] else "<hidden>" + bssid = BSSDetails["bssid"] + freq = BSSDetails["freq"] + rssi = BSSDetails["signal"] + key_mgmt = BSSDetails["key_mgmt"] + minrate = BSSDetails["minrate"] + + result = f"[{bssid}] {freq}, {rssi}dBm, {minrate} | {ssid} [{key_mgmt}] " + return result + else: + return f"BSS Path {BSSPath} could not be resolved" + + +def fetch_interfaces(wpas_obj): + available_interfaces = [] + ifaces = wpas_obj.Get( + WPAS_DBUS_INTERFACE, "Interfaces", dbus_interface=dbus.PROPERTIES_IFACE + ) + debug_print("InterfacesRequested: %s" % (ifaces), 2) + # time.sleep(3) + for path in ifaces: + debug_print("Resolving Interface Path: %s" % (path), 2) + if_obj = bus.get_object(WPAS_DBUS_SERVICE, path) + ifname = if_obj.Get( + WPAS_DBUS_INTERFACES_INTERFACE, + "Ifname", + dbus_interface=dbus.PROPERTIES_IFACE, + ) + available_interfaces.append({"interface": ifname}) + debug_print(f"Found interface : {ifname}", 2) + return available_interfaces + + +def fetch_currentBSS(interface): + # Refresh the path to the adapter and read back the current BSSID + bssid = "" + + try: + path = wpas.GetInterface(interface) + except dbus.DBusException as exc: + if not str(exc).startswith("fi.w1.wpa_supplicant1.InterfaceUnknown:"): + raise ValidationError(f"Interface unknown : {exc}", status_code=400) + try: + path = wpas.CreateInterface({"Ifname": interface, "Driver": "test"}) + time.sleep(1) + except dbus.DBusException as exc: + if not str(exc).startswith("fi.w1.wpa_supplicant1.InterfaceExists:"): + raise ValidationError( + f"Interface cannot be created : {exc}", status_code=400 + ) + + time.sleep(1) + + if_obj = bus.get_object(WPAS_DBUS_SERVICE, path) + # time.sleep(2) + currentBssPath = if_obj.Get( + WPAS_DBUS_INTERFACES_INTERFACE, + "CurrentBSS", + dbus_interface=dbus.PROPERTIES_IFACE, + ) + debug_print("Checking BSS", 2) + if currentBssPath != "/": + currentBssPath.split("/")[-1] + bssid = getBss(currentBssPath) + debug_print(currentBssPath, 2) + return bssid + + +""" +Call back functions from GLib +""" + + +def scanDone(success): + debug_print(f"Scan done: success={success}", 1) + global scan + local_scan = [] + res = if_obj.Get( + WPAS_DBUS_INTERFACES_INTERFACE, "BSSs", dbus_interface=dbus.PROPERTIES_IFACE + ) + debug_print("Scanned wireless networks:", 1) + for opath in res: + bss = getBss(opath) + if bss: + local_scan.append(bss) + scan = local_scan + scancount = len(scan) + debug_print(f"A Scan has completed with {scancount} results", 1) + debug_print(scan, 3) + + +def networkSelected(network): + # returns the current selected network path + debug_print(f"Network Selected (Signal) : {network}", 1) + selectedNetworkSSID.append(network) + + +def propertiesChanged(properties): + debug_print(f"PropertiesChanged: {properties}", 2) + if properties.get("State") is not None: + state = properties["State"] + + if state == "completed": + time.sleep(2) + if currentInterface: + renew_dhcp(currentInterface) + ipaddr = get_ip_address(currentInterface) + connectionEvents.append( + network.NetworkEvent( + event=f"IP Address {ipaddr} on {currentInterface}", + time=f"{datetime.now()}", + ) + ) + supplicantState.append(state) + debug_print(f"Connection Completed: State: {state}", 1) + # elif state == "scanning": + debug_print("SCAN---", 3) + elif state == "associating": + debug_print(f"PropertiesChanged: {state}", 1) + elif state == "authenticating": + # scanning = properties["Scanning"] + debug_print(f"PropertiesChanged: {state}", 1) + elif state == "4way_handshake": + debug_print(f"PropertiesChanged: {state}", 1) + if properties.get("CurrentBSS"): + bssidpath = properties["CurrentBSS"] + debug_print(f"Handshake attempt to: {pretty_print_BSS(bssidpath)}", 1) + else: + debug_print(f"PropertiesChanged: State: {state}", 1) + connectionEvents.append( + network.NetworkEvent(event=f"{state}", time=f"{datetime.now()}") + ) + elif properties.get("DisconnectReason") is not None: + disconnectReason = properties["DisconnectReason"] + debug_print(f"Disconnect Reason: {disconnectReason}", 1) + if disconnectReason != 0: + if disconnectReason == 3 or disconnectReason == -3: + connectionEvents.append( + network.NetworkEvent( + event="Station is Leaving", time=f"{datetime.now()}" + ) + ) + elif disconnectReason == 15: + connectionEvents.append( + network.NetworkEvent( + event="4-Way Handshake Fail (check password)", + time=f"{datetime.now()}", + ) + ) + supplicantState.append("authentication error") + else: + connectionEvents.append( + network.NetworkEvent( + event=f"Error: Disconnected [{disconnectReason}]", + time=f"{datetime.now()}", + ) + ) + supplicantState.append("disconnected") + + # For debugging purposes only + # if properties.get("BSSs") is not None: + # print("Supplicant has found the following BSSs") + # for BSS in properties["BSSs"]: + # if len(BSS) > 0: + # print(pretty_print_BSS(BSS)) + + if properties.get("CurrentAuthMode") is not None: + currentAuthMode = properties["CurrentAuthMode"] + debug_print(f"Current Auth Mode is {currentAuthMode}", 1) + + if properties.get("AuthStatusCode") is not None: + authStatus = properties["AuthStatusCode"] + debug_print(f"Auth Status: {authStatus}", 1) + if authStatus == 0: + connectionEvents.append( + network.NetworkEvent(event="authenticated", time=f"{datetime.now()}") + ) + else: + connectionEvents.append( + network.NetworkEvent( + event=f"authentication failed with code {authStatus}", + time=f"{datetime.now()}", + ) + ) + supplicantState.append(f"authentication fail {authStatus}") + + +def setup_DBus_Supplicant_Access(interface): + global bus + global if_obj + global iface + global wpas + global currentInterface + + bus = dbus.SystemBus() + proxy = bus.get_object(WPAS_DBUS_SERVICE, WPAS_DBUS_OPATH) + wpas = Interface(proxy, WPAS_DBUS_INTERFACE) + + try: + path = wpas.GetInterface(interface) + currentInterface = interface + except dbus.DBusException as exc: + if not str(exc).startswith("fi.w1.wpa_supplicant1.InterfaceUnknown:"): + raise ValidationError(f"Interface unknown : {exc}", status_code=400) + try: + path = wpas.CreateInterface({"Ifname": interface, "Driver": "test"}) + time.sleep(1) + except dbus.DBusException as exc: + if not str(exc).startswith("fi.w1.wpa_supplicant1.InterfaceExists:"): + raise ValidationError( + f"Interface cannot be created : {exc}", status_code=400 + ) + time.sleep(1) + debug_print(path, 3) + if_obj = bus.get_object(WPAS_DBUS_SERVICE, path) + # time.sleep(1) + iface = dbus.Interface(if_obj, WPAS_DBUS_INTERFACES_INTERFACE) + + +""" +These are the functions used to deliver the API +""" + + +async def get_systemd_network_interfaces(timeout: network.APIConfig): + """ + Queries systemd via dbus to get a list of the available interfaces. + """ + global bus + bus = dbus.SystemBus() + wpas_obj = bus.get_object(WPAS_DBUS_SERVICE, WPAS_DBUS_OPATH) + debug_print("Checking available interfaces", 3) + available_interfaces = fetch_interfaces(wpas_obj) + debug_print(f"Available interfaces: {available_interfaces}", 3) + return {"interfaces": available_interfaces} + + +async def get_async_systemd_network_scan( + type: str, interface: network.Interface, timeout: network.APIConfig +): + """ + Queries systemd via dbus to get a scan of the available networks. + """ + API_TIMEOUT = timeout + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + + type = type.strip().lower() + if is_allowed_scan_type(type): + + try: + setup_DBus_Supplicant_Access(interface) + + global scan + scan = [] + scanConfig = dbus.Dictionary({"Type": type}, signature="sv") + + scan_handler = bus.add_signal_receiver( + scanDone, + dbus_interface=WPAS_DBUS_INTERFACES_INTERFACE, + signal_name="ScanDone", + ) + + iface.Scan(scanConfig) + + main_context = GLib.MainContext.default() + timeout_check = 0 + while scan == [] and timeout_check <= API_TIMEOUT: + time.sleep(1) + timeout_check += 1 + debug_print( + f"Scan request timeout state: {timeout_check} / {API_TIMEOUT}", 2 + ) + main_context.iteration(False) + + scan_handler.remove() + + # scan = [{"ssid": "A Network", "bssid": "11:22:33:44:55", "wpa": "no", "wpa2": "yes", "signal": -65, "freq": 5650}] + return {"nets": scan} + except DBusException as de: + debug_print(f"DBUS Error State: {de}", 0) + except ValueError as error: + raise ValidationError(f"{error}", status_code=400) + raise ValidationError(f"{type} is not a valid scan type", status_code=400) + + +async def set_systemd_network_addNetwork( + interface: network.Interface, + netConfig: network.WlanConfig, + removeAllFirst: bool, + timeout: network.APIConfig, +): + """ + Uses wpa_supplicant to connect to a WLAN network. + """ + global selectedNetworkSSID + selectedNetworkSSID = [] + global supplicantState + supplicantState = [] + global connectionEvents + connectionEvents = [] + + API_TIMEOUT = timeout + + debug_print("Setting up supplicant access", 3) + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + setup_DBus_Supplicant_Access(interface) + + selectErr = None + status = "uninitialised" + bssid = { + "ssid": "", + "bssid": "", + "key_mgmt": "", + "signal": 0, + "freq": 0, + "minrate": 0, + } + response = network.NetworkSetupLog(selectErr="", eventLog=[]) + + try: + debug_print("Configuring DBUS", 3) + network_change_handler = bus.add_signal_receiver( + networkSelected, + dbus_interface=WPAS_DBUS_INTERFACES_INTERFACE, + signal_name="NetworkSelected", + ) + + properties_change_handler = bus.add_signal_receiver( + propertiesChanged, + dbus_interface=WPAS_DBUS_INTERFACES_INTERFACE, + signal_name="PropertiesChanged", + ) + + # Remove all configured networks and apply the new network + if removeAllFirst: + debug_print("Removing existing connections", 2) + netw = iface.RemoveAllNetworks() + + netConfig_cleaned = {k: v for k, v in netConfig if v is not None} + netConfig_DBUS = dbus.Dictionary(netConfig_cleaned, signature="sv") + netw = iface.AddNetwork(netConfig_DBUS) + + if netw != "/": + debug_print("Valid network entry received", 2) + # A valid network entry has been created - get the Index + netw.split("/")[-1] + + # Select this network using its full path name + selectErr = iface.SelectNetwork(netw) + # time.sleep(10) + debug_print(f"Network selected with result: {selectErr}", 2) + + if selectErr == None: + # The network selection has been successsfully applied (does not mean a network is selected) + main_context = GLib.MainContext.default() + timeout_check = 0 + while supplicantState == [] and timeout_check <= API_TIMEOUT: + time.sleep(1) + timeout_check += 1 + debug_print( + f"Select request timeout: {timeout_check} / {API_TIMEOUT}", 2 + ) + main_context.iteration(False) + + if supplicantState != []: + if supplicantState[0] == "completed": + # Check the current BSSID post connection + bssidPath = if_obj.Get( + WPAS_DBUS_INTERFACES_INTERFACE, + "CurrentBSS", + dbus_interface=dbus.PROPERTIES_IFACE, + ) + if bssidPath != "/": + bssidresolution = getBss(bssidPath) + if bssidresolution: + bssid = bssidresolution + debug_print(f"Logged Events: {connectionEvents}", 2) + debug_print("Connected", 1) + status = "connected" + else: + debug_print(f"select error: {selectErr}", 2) + debug_print(f"Logged Events: {connectionEvents}", 2) + debug_print( + "Connection failed. Post connection check returned no network", + 1, + ) + status = "connection_lost" + else: + debug_print(f"select error: {selectErr}", 2) + debug_print(f"Logged Events: {connectionEvents}", 2) + debug_print("Connection failed. Aborting", 1) + status = "connection_lost" + + elif supplicantState[0] == "fail": + debug_print(f"select error: {selectErr}", 2) + debug_print(f"Logged Events: {connectionEvents}", 2) + debug_print("Connection failed. Aborting", 1) + status = f"connection_failed:{supplicantState[0]}" + else: + debug_print(f"select error: {selectErr}", 2) + debug_print(f"Logged Events: {connectionEvents}", 2) + debug_print("Connection failed. Aborting", 1) + status = f"connection failed:{supplicantState[0]}" + else: + debug_print(f"select error: {selectErr}", 2) + debug_print(f"Logged Events: {connectionEvents}", 2) + debug_print(f"No connection", 1) + status = "Network_not_found" + + else: + debug_print(f"select error: {selectErr}", 2) + debug_print(f"Logged Events: {connectionEvents}", 2) + if timeout_check >= API_TIMEOUT: + status = "Connection Timeout" + debug_print("Connection Timeout", 1) + else: + status = "Connection Err" + debug_print("Connection Err", 1) + + except DBusException as de: + debug_print(f"DBUS Error State: {de}", 0) + except ValueError as error: + raise ValidationError(f"{error}", status_code=400) + + network_change_handler.remove() + properties_change_handler.remove() + + response.eventLog = connectionEvents + if selectErr != None: + response.selectErr = str(selectErr) + else: + response.selectErr = "" + + return { + "status": status, + "response": response, + "connectedNet": bssid, + "input": netConfig.ssid, + } + + +async def get_systemd_network_currentNetwork_details( + interface: network.Interface, timeout: network.APIConfig +): + """ + Queries systemd via dbus to get a scan of the available networks. + """ + try: + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + res = "" + setup_DBus_Supplicant_Access(interface) + time.sleep(1) + + # res = fetch_currentBSS(interface) + bssidPath = if_obj.Get( + WPAS_DBUS_INTERFACES_INTERFACE, + "CurrentBSS", + dbus_interface=dbus.PROPERTIES_IFACE, + ) + + if bssidPath != "/": + res = getBss(bssidPath) + return {"connectedStatus": True, "connectedNet": res} + else: + return {"connectedStatus": False, "connectedNet": None} + except DBusException: + debug_print("DBUS Error State: {de}", 0) + except ValueError as error: + raise ValidationError(f"{error}", status_code=400) + + +# async def main(): +# await get_async_systemd_network_scan('passive', 'wlan0') +# # testnet = '{"ssid":"PiAP_6","psk":"wlanpieea","key_mgmt":"SAE","ieee80211w":2}' +# # await set_systemd_network_addNetwork('wlan0',testnet,True) + +# if __name__ == "__main__": +# asyncio.run(main()) + +# ### -- Test for printing out the connected network ### +# if_obj = bus.get_object(WPAS_DBUS_SERVICE, path) +# res = if_obj.Get(WPAS_DBUS_INTERFACES_INTERFACE, 'CurrentNetwork', dbus_interface=dbus.PROPERTIES_IFACE) +# # showNetwork(res) + +# ### -- Test for printing out the connected network ### +# if_obj = bus.get_object(WPAS_DBUS_SERVICE, path) +# res = if_obj.Get(WPAS_DBUS_INTERFACES_INTERFACE, 'CurrentBSS', dbus_interface=dbus.PROPERTIES_IFACE) +# print(getBss(res)) diff --git a/wlanpi_core/services/system_service.py b/wlanpi_core/services/system_service.py index 7ce23d4..8331aa3 100644 --- a/wlanpi_core/services/system_service.py +++ b/wlanpi_core/services/system_service.py @@ -30,6 +30,8 @@ "wlanpi-grafana-wipry-lp-5", "wlanpi-grafana-wipry-lp-6", "wlanpi-grafana-wipry-lp-stop", + "wpa_supplicant", + "wpa_supplicant@wlan0", ] diff --git a/wlanpi_core/static/img/apple-icon-144x144.png b/wlanpi_core/static/img/apple-icon-144x144.png index 16c573f..ce85419 100644 Binary files a/wlanpi_core/static/img/apple-icon-144x144.png and b/wlanpi_core/static/img/apple-icon-144x144.png differ