diff --git a/core/services/wifi/main.py b/core/services/wifi/main.py index 3d286283cf..b68a447b99 100755 --- a/core/services/wifi/main.py +++ b/core/services/wifi/main.py @@ -3,9 +3,6 @@ import argparse import asyncio import logging -import os -import stat -import sys from pathlib import Path from typing import Any, List, Optional @@ -29,7 +26,9 @@ ScannedWifiNetwork, WifiCredentials, ) -from WifiManager import WifiManager +from wifi_handlers.AbstractWifiHandler import AbstractWifiManager +from wifi_handlers.networkmanager.networkmanager import NetworkManagerWifi +from wifi_handlers.wpa_supplicant.WifiManager import WifiManager FRONTEND_FOLDER = Path.joinpath(Path(__file__).parent.absolute(), "frontend") SERVICE_NAME = "wifi-manager" @@ -38,7 +37,9 @@ init_logger(SERVICE_NAME) logger.info("Starting Wifi Manager.") -wifi_manager = WifiManager() +wpa_manager = WifiManager() +network_manager = NetworkManagerWifi() +wifi_manager: Optional[AbstractWifiManager] = None app = FastAPI( @@ -52,9 +53,9 @@ @app.get("/status", summary="Retrieve status of wifi manager.") @version(1, 0) async def network_status() -> Any: + assert wifi_manager is not None wifi_status = await wifi_manager.status() - logger.info("Status:") - for line in tabulate(list(wifi_status.items())).splitlines(): + for line in tabulate(list(vars(wifi_status).items())).splitlines(): logger.info(line) return wifi_status @@ -62,12 +63,9 @@ async def network_status() -> Any: @app.get("/scan", response_model=List[ScannedWifiNetwork], summary="Retrieve available wifi networks.") @version(1, 0) async def scan() -> Any: - logger.info("Trying to perform network scan.") + assert wifi_manager is not None try: available_networks = await wifi_manager.get_wifi_available() - logger.info("Available networks:") - for line in tabulate([network.dict() for network in available_networks], headers="keys").splitlines(): - logger.info(line) return available_networks except BusyError as error: raise StackedHTTPException(status_code=status.HTTP_425_TOO_EARLY, error=error) from error @@ -76,86 +74,25 @@ async def scan() -> Any: @app.get("/saved", response_model=List[SavedWifiNetwork], summary="Retrieve saved wifi networks.") @version(1, 0) async def saved() -> Any: - logger.info("Trying to fetch saved networks.") + assert wifi_manager is not None saved_networks = await wifi_manager.get_saved_wifi_network() - logger.info("Saved networks:") - for line in tabulate([network.dict() for network in saved_networks], headers="keys").splitlines(): - logger.info(line) return saved_networks @app.post("/connect", summary="Connect to wifi network.") @version(1, 0) async def connect(credentials: WifiCredentials, hidden: bool = False) -> Any: - logger.info(f"Trying to connect to '{credentials.ssid}'.") - - network_id: Optional[int] = None - is_new_network = False - try: - saved_networks = await wifi_manager.get_saved_wifi_network() - match_network = next(filter(lambda network: network.ssid == credentials.ssid, saved_networks)) - network_id = match_network.networkid - logger.info(f"Network is already known, id={network_id}.") - except StopIteration: - logger.info("Network is not known.") - is_new_network = True - - is_secure = False - try: - available_networks = await wifi_manager.get_wifi_available() - scanned_network = next(filter(lambda network: network.ssid == credentials.ssid, available_networks)) - flags_for_passwords = ["WPA", "WEP", "WSN"] - for candidate in flags_for_passwords: - if candidate in scanned_network.flags: - is_secure = True - break - except StopIteration: - logger.info("Could not find wifi network around.") - - if credentials.password == "" and network_id is None and is_secure: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="No password received and network not found among saved ones.", - ) - - try: - # Update known network if password is not necessary anymore - if network_id is not None and not is_secure and credentials.password == "": - logger.info(f"Removing old entry for known network, id={network_id}.") - await wifi_manager.remove_network(network_id) - network_id = await wifi_manager.add_network(credentials, hidden) - logger.info(f"Network entry updated, id={network_id}.") - - if network_id is None: - network_id = await wifi_manager.add_network(credentials, hidden) - logger.info(f"Saving new network entry, id={network_id}.") - - logger.info("Performing network connection.") - if network_id is None: - raise ValueError("Missing 'network_id' for network connection.") - await wifi_manager.connect_to_network(network_id, timeout=40) - except ConnectionError as error: - if is_new_network and network_id is not None: - logger.info("Removing new network entry since connection failed.") - await wifi_manager.remove_network(network_id) - raise error - logger.info(f"Successfully connected to '{credentials.ssid}'.") + assert wifi_manager is not None + await wifi_manager.try_connect_to_network(credentials, hidden) @app.post("/remove", summary="Remove saved wifi network.") @version(1, 0) async def remove(ssid: str) -> Any: - logger.info(f"Trying to remove network '{ssid}'.") + assert wifi_manager is not None + logger.info(f"Processing remove request for SSID: {ssid}") try: - saved_networks = await wifi_manager.get_saved_wifi_network() - # Here we get all networks that match the ssid - # and get a list where the biggest networkid comes first. - # If we remove the lowest numbers first, it'll change the highest values to -1 - # TODO: We should move the entire wifi framestack to work with bssid - match_networks = [network for network in saved_networks if network.ssid == ssid] - match_networks = sorted(match_networks, key=lambda network: network.networkid, reverse=True) - for match_network in match_networks: - await wifi_manager.remove_network(match_network.networkid) + await wifi_manager.remove_network(ssid) except StopIteration as error: logger.info(f"Network '{ssid}' is unknown.") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Network '{ssid}' not saved.") from error @@ -165,7 +102,7 @@ async def remove(ssid: str) -> Any: @app.get("/disconnect", summary="Disconnect from wifi network.") @version(1, 0) async def disconnect() -> Any: - logger.info("Trying to disconnect from current network.") + assert wifi_manager is not None await wifi_manager.disconnect() logger.info("Successfully disconnected from network.") @@ -173,27 +110,32 @@ async def disconnect() -> Any: @app.get("/hotspot", summary="Get hotspot state.") @version(1, 0) def hotspot_state() -> Any: - return wifi_manager.hotspot.is_running() + assert wifi_manager is not None + return wifi_manager.hotspot_is_running() @app.get("/hotspot_extended_status", summary="Get extended hotspot status.") @version(1, 0) -def hotspot_extended_state() -> HotspotStatus: - return HotspotStatus(supported=wifi_manager.hotspot.supports_hotspot, enabled=wifi_manager.hotspot.is_running()) +async def hotspot_extended_state() -> HotspotStatus: + assert wifi_manager is not None + return HotspotStatus( + supported=await wifi_manager.supports_hotspot(), enabled=await wifi_manager.hotspot_is_running() + ) @app.post("/hotspot", summary="Enable/disable hotspot.") @version(1, 0) -def toggle_hotspot(enable: bool) -> Any: +async def toggle_hotspot(enable: bool) -> Any: + assert wifi_manager is not None if enable: - wifi_manager.enable_hotspot() - return - wifi_manager.disable_hotspot() + return await wifi_manager.enable_hotspot() + return await wifi_manager.disable_hotspot() @app.post("/smart_hotspot", summary="Enable/disable smart-hotspot.") @version(1, 0) def toggle_smart_hotspot(enable: bool) -> Any: + assert wifi_manager is not None if enable: wifi_manager.enable_smart_hotspot() return @@ -203,18 +145,21 @@ def toggle_smart_hotspot(enable: bool) -> Any: @app.get("/smart_hotspot", summary="Check if smart-hotspot is enabled.") @version(1, 0) def check_smart_hotspot() -> Any: + assert wifi_manager is not None return wifi_manager.is_smart_hotspot_enabled() @app.post("/hotspot_credentials", summary="Update hotspot credentials.") @version(1, 0) -def set_hotspot_credentials(credentials: WifiCredentials) -> Any: - wifi_manager.set_hotspot_credentials(credentials) +async def set_hotspot_credentials(credentials: WifiCredentials) -> Any: + assert wifi_manager is not None + await wifi_manager.set_hotspot_credentials(credentials) @app.get("/hotspot_credentials", summary="Get hotspot credentials.") @version(1, 0) def get_hotspot_credentials() -> Any: + assert wifi_manager is not None return wifi_manager.hotspot_credentials() @@ -222,67 +167,31 @@ def get_hotspot_credentials() -> Any: app.mount("/", StaticFiles(directory=str(FRONTEND_FOLDER), html=True)) -if __name__ == "__main__": - if os.geteuid() != 0: - logger.error("You need root privileges to run this script.\nPlease try again using **sudo**. Exiting.") - sys.exit(1) - +async def async_start() -> None: + # pylint: disable=global-statement + global wifi_manager parser = argparse.ArgumentParser(description="Abstraction CLI for WifiManager configuration.") - parser.add_argument( - "--socket", - dest="socket_name", - type=str, - help="Name of the WPA Supplicant socket. Usually 'wlan0' or 'wlp4s0'.", - ) - args = parser.parse_args() + candidates = [wpa_manager, network_manager] + for implementation in candidates: + implementation.add_arguments(parser) + # we need to configure all arguments before parsing them, hence two loops + for implementation in candidates: + implementation.configure(parser.parse_args()) + async_loop = asyncio.get_event_loop() + # Running uvicorn with log disabled so loguru can handle it + config = Config(app=app, loop=async_loop, host="0.0.0.0", port=9000, log_config=None) + server = Server(config) + for implementation in candidates: + can_work = await implementation.can_work() + logger.info(f"{implementation} can work: {can_work}") + if can_work: + logger.info(f"Using {implementation} as wifi manager.") + await implementation.start() + wifi_manager = implementation + break + await server.serve() - wpa_socket_folder = "/var/run/wpa_supplicant/" - try: - if args.socket_name: - logger.info("Connecting via provided socket.") - socket_name = args.socket_name - else: - logger.info("Connecting via default socket.") - - def is_socket(file_path: str) -> bool: - try: - mode = os.stat(file_path).st_mode - return stat.S_ISSOCK(mode) - except Exception as error: - logger.warning(f"Could not check if '{file_path}' is a socket: {error}") - return False - - # We are going to sort and get the latest file, since this in theory will be an external interface - # added by the user - entries = os.scandir(wpa_socket_folder) - available_sockets = sorted( - [ - entry.path - for entry in entries - if entry.name.startswith(("wlan", "wifi", "wlp")) and is_socket(entry.path) - ] - ) - if not available_sockets: - raise RuntimeError("No wifi sockets available.") - socket_name = available_sockets[-1] - logger.info(f"Going to use {socket_name} file") - WLAN_SOCKET = os.path.join(wpa_socket_folder, socket_name) - wifi_manager.connect(WLAN_SOCKET) - except Exception as socket_connection_error: - logger.warning(f"Could not connect with wifi socket. {socket_connection_error}") - logger.info("Connecting via internet wifi socket.") - try: - wifi_manager.connect(("127.0.0.1", 6664)) - except Exception as udp_connection_error: - logger.error(f"Could not connect with internet socket: {udp_connection_error}. Exiting.") - sys.exit(1) +if __name__ == "__main__": loop = asyncio.new_event_loop() - - # # Running uvicorn with log disabled so loguru can handle it - config = Config(app=app, loop=loop, host="0.0.0.0", port=9000, log_config=None) - server = Server(config) - - loop.create_task(wifi_manager.auto_reconnect(60)) - loop.create_task(wifi_manager.start_hotspot_watchdog()) - loop.run_until_complete(server.serve()) + loop.run_until_complete(async_start()) diff --git a/core/services/wifi/setup.py b/core/services/wifi/setup.py index 2adfc1b746..b759ed904e 100644 --- a/core/services/wifi/setup.py +++ b/core/services/wifi/setup.py @@ -1,7 +1,23 @@ #!/usr/bin/env python3 +import os +import urllib.request + import setuptools + +def download_script(url: str, dest: str) -> None: + urllib.request.urlretrieve(url, dest) + os.chmod(dest, 0o755) + + +CREATE_AP_COMMIT = "2cedd27e324ac7b9cffd1537ef0b6c9e8564e9a3" + +download_script( + f"https://raw.githubusercontent.com/lakinduakash/linux-wifi-hotspot/{CREATE_AP_COMMIT}/src/scripts/create_ap", + "/usr/bin/create_ap", +) + setuptools.setup( name="wifi_service", version="0.1.0", diff --git a/core/services/wifi/typedefs.py b/core/services/wifi/typedefs.py index aa90e01c51..db6060c80a 100644 --- a/core/services/wifi/typedefs.py +++ b/core/services/wifi/typedefs.py @@ -9,6 +9,26 @@ class HotspotStatus(BaseModel): enabled: bool +class WifiStatus(BaseModel): + bssid: Optional[str] + freq: Optional[str] + ssid: Optional[str] + id: Optional[str] + mode: Optional[str] + wifi_generation: Optional[str] + pairwise_cipher: Optional[str] + group_cipher: Optional[str] + key_mgmt: Optional[str] + wpa_state: Optional[str] + ip_address: Optional[str] + p2p_device_address: Optional[str] + address: Optional[str] + uuid: Optional[str] + ieee80211ac: Optional[str] + state: Optional[str] + disabled: Optional[str] + + class ScannedWifiNetwork(BaseModel): ssid: Optional[str] bssid: str @@ -20,8 +40,9 @@ class ScannedWifiNetwork(BaseModel): class SavedWifiNetwork(BaseModel): networkid: int ssid: str - bssid: str + bssid: Optional[str] flags: Optional[str] + nm_id: Optional[str] class WifiCredentials(BaseModel): diff --git a/core/services/wifi/wifi_handlers/AbstractWifiHandler.py b/core/services/wifi/wifi_handlers/AbstractWifiHandler.py new file mode 100644 index 0000000000..31e997a1a5 --- /dev/null +++ b/core/services/wifi/wifi_handlers/AbstractWifiHandler.py @@ -0,0 +1,112 @@ +import abc +from argparse import ArgumentParser, Namespace +from typing import List, Optional + +from commonwealth.settings.manager import Manager + +from settings import SettingsV1 +from typedefs import SavedWifiNetwork, ScannedWifiNetwork, WifiCredentials, WifiStatus +from wifi_handlers.wpa_supplicant.wpa_supplicant import WPASupplicant + + +class AbstractWifiManager: + wpa = WPASupplicant() + + def __init__(self) -> None: + self._settings_manager = Manager("wifi-manager", SettingsV1) + self._settings_manager.load() + + @abc.abstractmethod + async def can_work(self) -> bool: + """Check if the given wifi manager has the necessary tools/interfaces to work""" + return True + + @abc.abstractmethod + async def get_wifi_available(self) -> List[ScannedWifiNetwork]: + """Get a dict from the wifi signals available""" + raise NotImplementedError + + @abc.abstractmethod + async def get_saved_wifi_network(self) -> List[SavedWifiNetwork]: + """Get a list of saved wifi networks""" + raise NotImplementedError + + @abc.abstractmethod + async def get_current_network(self) -> Optional[SavedWifiNetwork]: + """Get current network, if connected.""" + raise NotImplementedError + + @abc.abstractmethod + async def hotspot_is_running(self) -> bool: + """Check if the hotspot is running.""" + raise NotImplementedError + + @abc.abstractmethod + async def supports_hotspot(self) -> bool: + """Check if the wifi manager supports hotspot.""" + raise NotImplementedError + + @abc.abstractmethod + async def set_hotspot_credentials(self, _credentials: WifiCredentials) -> None: + """Set the hotspot credentials.""" + raise NotImplementedError + + @abc.abstractmethod + async def try_connect_to_network(self, credentials: WifiCredentials, hidden: bool = False) -> None: + """Try to connect to a network""" + raise NotImplementedError + + @abc.abstractmethod + async def status(self) -> WifiStatus: + """Check wpa_supplicant status""" + raise NotImplementedError + + async def disconnect(self) -> None: + """Reconfigure wpa_supplicant + This will force the reevaluation of the conf file + """ + + @abc.abstractmethod + async def remove_network(self, ssid: str) -> None: + """Remove a network from the wpa_supplicant conf file""" + raise NotImplementedError + + @abc.abstractmethod + def hotspot_credentials(self) -> WifiCredentials: + """Get the hotspot credentials.""" + raise NotImplementedError + + @abc.abstractmethod + async def enable_hotspot(self, _save_settings: bool = True) -> bool: + """Enable the hotspot.""" + raise NotImplementedError + + @abc.abstractmethod + async def disable_hotspot(self, _save_settings: bool = True) -> None: + """Disable the hotspot.""" + raise NotImplementedError + + @abc.abstractmethod + def enable_smart_hotspot(self) -> None: + """Enables the hotspot if there's no internet connection.""" + raise NotImplementedError + + @abc.abstractmethod + def disable_smart_hotspot(self) -> None: + """Disable the smart hotspot.""" + raise NotImplementedError + + @abc.abstractmethod + def is_smart_hotspot_enabled(self) -> bool: + return self._settings_manager.settings.smart_hotspot_enabled is True + + @abc.abstractmethod + async def start(self) -> None: + """Start the wifi manager""" + raise NotImplementedError + + def configure(self, _parser: Namespace) -> None: + """Configure the wifi manager""" + + def add_arguments(self, _parser: ArgumentParser) -> None: + """Add arguments to the parser""" diff --git a/core/services/wifi/wifi_handlers/__init__.py b/core/services/wifi/wifi_handlers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/core/services/wifi/wifi_handlers/networkmanager/networkmanager.py b/core/services/wifi/wifi_handlers/networkmanager/networkmanager.py new file mode 100644 index 0000000000..e80d7006cd --- /dev/null +++ b/core/services/wifi/wifi_handlers/networkmanager/networkmanager.py @@ -0,0 +1,593 @@ +import asyncio +import hashlib +import select +import signal +import subprocess +from concurrent.futures import CancelledError +from typing import Any, List, Optional + +import sdbus +from commonwealth.utils.general import device_id +from loguru import logger +from sdbus_async.networkmanager import ( + AccessPoint, + DeviceState, + DeviceType, + IPv4Config, + NetworkConnectionSettings, + NetworkDeviceWireless, + NetworkManager, + NetworkManagerSettings, +) +from sdbus_async.networkmanager.enums import AccessPointCapabilities, WpaSecurityFlags + +from typedefs import SavedWifiNetwork, ScannedWifiNetwork, WifiCredentials, WifiStatus +from wifi_handlers.AbstractWifiHandler import AbstractWifiManager + + +class CreateAPException(Exception): + pass + + +class InvalidConfigurationError(Exception): + pass + + +class NetworkManagerWifi(AbstractWifiManager): + """NetworkManager implementation of the WiFi manager interface. + + This class provides WiFi management functionality using NetworkManager and supports + both client and access point (hotspot) modes. + """ + + def __init__(self) -> None: + """Initialize NetworkManager WiFi handler.""" + super().__init__() + self._bus = sdbus.sd_bus_open_system() + self._nm: Optional[NetworkManager] = None + self._nm_settings: Optional[NetworkManagerSettings] = None + self._device_path: Optional[str] = None + self._create_ap_process: Optional[subprocess.Popen[str]] = None + self._ap_interface = "uap0" + self._tasks: List[asyncio.Task[Any]] = [] + self._nm = NetworkManager(self._bus) + self._nm_settings = NetworkManagerSettings(self._bus) + logger.info("NetworkManagerWifi initialized") + + async def _create_virtual_interface(self) -> bool: + """Create virtual AP interface using iw""" + try: + # Check if interface already exists + existing = subprocess.run(["ip", "link", "show", self._ap_interface], capture_output=True, check=False) + if existing.returncode == 0: + logger.info(f"Interface {self._ap_interface} already exists") + return True + + # Get physical interface name + device = NetworkDeviceWireless(self._device_path, self._bus) + phys_name = await device.interface + + # Create virtual interface + subprocess.run(["iw", "dev", phys_name, "interface", "add", self._ap_interface, "type", "__ap"], check=True) + logger.info(f"Created virtual AP interface {self._ap_interface}") + + # Set interface up + subprocess.run(["ip", "link", "set", self._ap_interface, "up"], check=True) + + # Disable power save on both interfaces + subprocess.run(["iw", phys_name, "set", "power_save", "off"], check=True) + subprocess.run(["iw", self._ap_interface, "set", "power_save", "off"], check=True) + + return True + + except subprocess.CalledProcessError as e: + logger.error(f"Failed to create virtual interface: {e}") + return False + + async def _cleanup_virtual_interface(self) -> None: + """Remove virtual AP interface""" + try: + # Check if interface exists + existing = subprocess.run(["ip", "link", "show", self._ap_interface], capture_output=True, check=True) + + if existing.returncode != 0: + return + + # Remove interface + subprocess.run(["iw", "dev", self._ap_interface, "del"], check=True) + logger.info(f"Removed virtual AP interface {self._ap_interface}") + + except subprocess.CalledProcessError as e: + logger.error(f"Failed to remove virtual interface: {e}") + + async def start(self) -> None: + """Start NetworkManagerWifi with signal handlers""" + # Set up signal handlers + loop = asyncio.get_running_loop() + for sig in (signal.SIGTERM, signal.SIGINT): + loop.add_signal_handler(sig, lambda s=sig: asyncio.create_task(self.handle_shutdown(s))) + + # Find WiFi device + assert self._nm is not None + devices = await self._nm.get_devices() + for device_path in devices: + device = NetworkDeviceWireless(device_path, self._bus) + if await device.device_type == DeviceType.WIFI: + self._device_path = device_path + break + + # Create virtual AP interface if needed + await self._create_virtual_interface() + self._tasks.append(asyncio.get_event_loop().create_task(self._autoscan())) + self._tasks.append(asyncio.get_event_loop().create_task(self.hotspot_watchdog())) + + async def _autoscan(self) -> None: + + while True: + device = NetworkDeviceWireless(self._device_path, self._bus) + if await device.last_scan > 10000: + await device.request_scan({}) + logger.info("Requested WiFi scan") + await asyncio.sleep(10) + + async def get_wifi_available(self) -> List[ScannedWifiNetwork]: + if not self._device_path or not self._nm: + return [] + + try: + device = NetworkDeviceWireless(self._device_path, bus=self._bus) + networks: List[ScannedWifiNetwork] = [] + + ap_paths = await device.get_all_access_points() + for ap_path in ap_paths: + ap = AccessPoint(ap_path, self._bus) + freq = await ap.frequency.get_async() + ssid = (await ap.ssid.get_async()).decode("utf-8") + + # Get raw flag values + wpa_flags = await ap.wpa_flags.get_async() + rsn_flags = await ap.rsn_flags.get_async() + flags = await ap.flags.get_async() + + security_flags = [] + + # Check flag bits + if flags & AccessPointCapabilities.PRIVACY: + security_flags.append("WEP") + + if wpa_flags: + if wpa_flags & WpaSecurityFlags.AUTH_PSK: + security_flags.append("WPA-PSK") + if wpa_flags & WpaSecurityFlags.BROADCAST_TKIP: + security_flags.append("TKIP") + if wpa_flags & WpaSecurityFlags.BROADCAST_CCMP: + security_flags.append("CCMP") + + if rsn_flags: + if rsn_flags & WpaSecurityFlags.AUTH_PSK: + security_flags.append("WPA2-PSK") + if rsn_flags & WpaSecurityFlags.BROADCAST_TKIP: + security_flags.append("TKIP") + if rsn_flags & WpaSecurityFlags.BROADCAST_CCMP: + security_flags.append("CCMP") + + flag_str = f"[{'-'.join(set(security_flags))}]" if security_flags else "" + + networks.append( + ScannedWifiNetwork( + ssid=ssid, + signal_strength=(await ap.strength.get_async()), + frequency=freq, + bssid=(await ap.hw_address.get_async()), + flags=flag_str, + signallevel=(await ap.strength.get_async()), + ) + ) + + return networks + + except Exception as e: + logger.error(f"Error getting available networks: {e}") + return [] + + async def try_connect_to_network(self, credentials: WifiCredentials, hidden: bool = False) -> None: + # Check for existing connection + assert self._nm is not None + assert self._nm_settings is not None + + async def wait_for_connection(timeout: int = 30) -> bool: + start_time = asyncio.get_event_loop().time() + while asyncio.get_event_loop().time() - start_time < timeout: + status = await self.status() + if status.state == "connected": + return True + await asyncio.sleep(1) + return False + + existing_connection = await self._find_existing_connection(credentials) + if existing_connection: + logger.info(f"Using existing connection for {credentials.ssid}") + await self._nm.activate_connection(existing_connection, self._device_path, "/") + + # If hotspot was running, restart it + if not await wait_for_connection(): + logger.error(f"Connection timeout for {credentials.ssid}") + raise TimeoutError(f"Failed to connect to {credentials.ssid} within 30 seconds") + + if self._settings_manager.settings.hotspot_enabled and not await self.hotspot_is_running(): + await self.enable_hotspot() + return + + # If no existing connection, create a new one + connection = { + "connection": { + "type": ("s", "802-11-wireless"), + "id": ("s", credentials.ssid), + "interface-name": ("s", "wlan0"), + "autoconnect": ("b", True), + }, + "802-11-wireless": { + "ssid": ("ay", credentials.ssid.encode()), + "mode": ("s", "infrastructure"), + "hidden": ("b", hidden), + }, + } + + if credentials.password: + connection["802-11-wireless-security"] = {"key-mgmt": ("s", "wpa-psk"), "psk": ("s", credentials.password)} + connection["802-11-wireless"]["security"] = ("s", "802-11-wireless-security") + + # Add and activate connection + conn_path = await self._nm_settings.add_connection(connection) + await self._nm.activate_connection(conn_path, self._device_path, "/") + + # If hotspot was running, restart it + if not await wait_for_connection(): + logger.error(f"Connection timeout for {credentials.ssid}") + raise TimeoutError(f"Failed to connect to {credentials.ssid} within 30 seconds") + + if self._settings_manager.settings.hotspot_enabled: + await self.enable_hotspot() + + async def _find_existing_connection(self, credentials: WifiCredentials) -> Optional[str]: + """Find existing connection for given SSID, checking password if provided""" + try: + if not self._nm_settings: + return None + + for conn_path in await self._nm_settings.connections: + try: + settings = NetworkConnectionSettings(conn_path, self._bus) + profile = await settings.get_profile() + + if not profile.wireless or not profile.wireless.ssid: + continue + + if profile.wireless.ssid.decode("utf-8") != credentials.ssid: + continue + + # If no password provided, we can use any existing connection + if not credentials.password: + logger.info(f"Found existing connection for {credentials.ssid} (no password check)") + return str(conn_path) + + # If password provided, check if it matches + if profile.wireless_security and profile.wireless_security.psk == credentials.password: + logger.info(f"Found existing connection for {credentials.ssid} with matching password") + return str(conn_path) + + logger.debug(f"Found connection for {credentials.ssid} but password doesn't match") + + except Exception as e: + logger.error(f"Error checking connection {conn_path}: {e}") + continue + + return None + + except Exception as e: + logger.error(f"Error finding existing connection: {e}") + return None + + # pylint: disable=too-many-branches + async def enable_hotspot(self, save_settings: bool = True) -> bool: + if not await self._create_virtual_interface(): + logger.error("Failed to create virtual interface for AP") + return False + + credentials = self.hotspot_credentials() + + # Build create_ap command + cmd = [ + "create_ap", + "-n", # self._ap_interface, # Uncomment for routing internet (seemed to cause communication issues when starting hotspot) + "uap0", # Use physical interface for internet + "--redirect-to-localhost", # Redirect all traffic to localhost, captive-portal style + credentials.ssid, + credentials.password, + ] + + # Start create_ap process with pipe for output + try: + if self._create_ap_process: + logger.info("create_ap process already running, cleaning up...") + self._create_ap_process.terminate() + self._create_ap_process.wait() + logger.info("Stopped existing create_ap process") + # pylint: disable=consider-using-with + self._create_ap_process = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True, bufsize=1 + ) + # pylint: enable=consider-using-with + + # Wait for "Done" or "ERROR" in output + success = False + start_time = asyncio.get_event_loop().time() + timeout = 30 # 30 second timeout + assert self._create_ap_process is not None + while True: + if self._create_ap_process.stdout is not None: + line = self._create_ap_process.stdout.readline() + if not line and self._create_ap_process.poll() is not None: + break + + line = line.strip() + if line: + logger.info(f"create_ap: {line}") + if "Done" in line or "AP-ENABLED" in line: + success = True + break + if "ERROR" in line: + logger.error(f"create_ap error: {line}") + raise CreateAPException(f"Failed to start create_ap: {line}") + + # Check timeout + if asyncio.get_event_loop().time() - start_time > timeout: + logger.error("Timeout waiting for create_ap to start") + return success + + # Give other tasks a chance to run + await asyncio.sleep(0.1) + + if not success: + logger.error("Failed to start create_ap") + return success + + logger.info(f"Successfully started create_ap with PID {self._create_ap_process.pid}") + + # Start background task to monitor output + self._tasks.append( + asyncio.get_event_loop().create_task(self._monitor_create_ap_output(self._create_ap_process)) + ) + + if save_settings: + self._settings_manager.settings.hotspot_enabled = True + self._settings_manager.save() + logger.info("Hotspot enabled") + + except CreateAPException as e: + raise e + except Exception as e: + logger.error(f"Error starting create_ap: {e}") + return success + + async def _monitor_create_ap_output(self, process: subprocess.Popen[str]) -> None: + """Monitor create_ap process output non-blockingly using select""" + try: + while True: + # Check if output is ready to be read + if select.select([process.stdout], [], [], 0)[0]: + assert process.stdout is not None + if line := process.stdout.readline().strip(): + logger.info(f"create_ap: {line}") + + if process.poll() is not None: + break + + await asyncio.sleep(0.1) + except Exception as e: + logger.error(f"Error monitoring create_ap output: {e}") + finally: + logger.info("create_ap process monitoring ended") + if process == self._create_ap_process: + self._create_ap_process = None + + async def disable_hotspot(self, save_settings: bool = True) -> None: + if self._create_ap_process: + self._create_ap_process.terminate() + self._create_ap_process.wait() + self._create_ap_process = None + logger.info("Stopped create_ap process") + + # Cleanup virtual interface + await self._cleanup_virtual_interface() + + if save_settings: + self._settings_manager.settings.hotspot_enabled = False + self._settings_manager.save() + + async def hotspot_is_running(self) -> bool: + return self._create_ap_process is not None and self._create_ap_process.poll() is None + + async def supports_hotspot(self) -> bool: + return True + + async def status(self) -> WifiStatus: + if not self._device_path: + return WifiStatus(state="disconnected") + + device = NetworkDeviceWireless(self._device_path, self._bus) + state = await device.state + + if state == DeviceState.ACTIVATED: + try: + ap = AccessPoint(await device.active_access_point, self._bus) + ssid: bytes = await ap.ssid + ip4_conf_path = await device.ip4_config + + status = { + "state": "connected", + "ssid": ssid.decode("utf-8"), + "wpa_state": "COMPLETED", + "key_mgmt": "WPA-PSK", + } + + if ip4_conf_path and ip4_conf_path != "/": + ip4_conf = IPv4Config(ip4_conf_path, self._bus) + address_data = await ip4_conf.address_data + if address_data: + status["ip_address"] = address_data[0]["address"][1] + + return WifiStatus(**status) + except Exception as e: + logger.error(f"Error getting status: {e}") + return WifiStatus(state="disconnected") + return WifiStatus(state="disconnected") + + async def get_saved_wifi_network(self) -> List[SavedWifiNetwork]: + if not self._nm_settings: + return [] + + saved_networks: List[SavedWifiNetwork] = [] + try: + for conn_path in await self._nm_settings.connections: + try: + settings = NetworkConnectionSettings(conn_path, self._bus) + profile = await settings.get_profile() + + if not profile.wireless or not profile.wireless.ssid: + continue + + ssid = profile.wireless.ssid.decode("utf-8") + saved_networks.append( + SavedWifiNetwork(networkid=0, ssid=ssid, bssid=profile.wireless.bssid, nm_id=conn_path) + ) + except Exception as e: + logger.error(f"Error processing connection {conn_path}: {e}") + continue + except Exception as e: + logger.error(f"Error getting saved networks: {e}") + return [] + return saved_networks + + # Hotspot related methods remain the same as they use _settings_manager + async def set_hotspot_credentials(self, credentials: WifiCredentials) -> None: + self._settings_manager.settings.hotspot_ssid = credentials.ssid + self._settings_manager.settings.hotspot_password = credentials.password + self._settings_manager.save() + + def hotspot_credentials(self) -> WifiCredentials: + try: + dev_id = device_id() + except Exception: + dev_id = "000000" + hashed_id = hashlib.md5(dev_id.encode()).hexdigest()[:6] + + return WifiCredentials( + ssid=self._settings_manager.settings.hotspot_ssid or f"BlueOS ({hashed_id})", + password=self._settings_manager.settings.hotspot_password or "blueosap", + ) + + async def cleanup(self) -> None: + """Clean up resources when shutting down.""" + logger.info("Starting NetworkManagerWifi cleanup") + + # Disable hotspot first to stop any running processes + await self.disable_hotspot(save_settings=False) + + # Cancel all background tasks + for task in self._tasks: + if not task.done(): + task.cancel() + try: + await task + except (asyncio.CancelledError, CancelledError): + pass + except Exception as e: + logger.error(f"Error while cancelling task: {e}") + self._tasks.clear() + + # Cleanup virtual interface + await self._cleanup_virtual_interface() + + # Close bus connection + if self._bus: + self._bus.close() + + # cleanup create_ap + if self._create_ap_process: + self._create_ap_process.terminate() + try: + self._create_ap_process.wait(timeout=5) + except subprocess.TimeoutExpired: + self._create_ap_process.kill() + self._create_ap_process.wait() + self._create_ap_process = None + + logger.info("NetworkManagerWifi cleanup completed") + + async def handle_shutdown(self, sig: signal.Signals) -> None: + """Handle shutdown signals gracefully""" + logger.info(f"Received shutdown signal {sig.name}") + await self.cleanup() + + async def disconnect(self) -> None: + """Disconnect from current wifi network.""" + assert self._nm is not None + try: + # Get active connection + active_connection = await NetworkDeviceWireless(self._device_path, self._bus).active_connection + if active_connection: + # Deactivate the connection + await self._nm.deactivate_connection(active_connection) + logger.info("Successfully disconnected from network") + else: + logger.info("No active connection to disconnect from") + except Exception as e: + logger.error(f"Failed to disconnect: {e}") + raise + + async def can_work(self) -> bool: + try: + NetworkManager(self._bus) + except Exception as e: + logger.info(f"NetworkManager not available: {e}") + return False + return True + + def disable_smart_hotspot(self) -> None: + """Disable the smart hotspot feature.""" + self._settings_manager.settings.smart_hotspot_enabled = False + self._settings_manager.save() + logger.info("Smart hotspot disabled") + + def enable_smart_hotspot(self) -> None: + """Enable the smart hotspot feature.""" + self._settings_manager.settings.smart_hotspot_enabled = True + self._settings_manager.save() + logger.info("Smart hotspot enabled") + + async def get_current_network(self) -> Optional[SavedWifiNetwork]: + raise NotImplementedError + + def is_smart_hotspot_enabled(self) -> bool: + return self._settings_manager.settings.smart_hotspot_enabled is True + + async def remove_network(self, _network_id: str) -> None: + raise NotImplementedError + + async def hotspot_watchdog(self) -> None: + """ + This takes care of the smart-hotspot feature. It will enable the hotspot if we stay disconnected for longer than 30 seconds + """ + while True: + await asyncio.sleep(30) + if not self._settings_manager.settings.smart_hotspot_enabled: + continue + + state = await self.status() + if state.state == "connected": + continue + + if not await self.hotspot_is_running(): + logger.info("No network connection detected, enabling hotspot") + await self.enable_hotspot() diff --git a/core/services/wifi/Hotspot.py b/core/services/wifi/wifi_handlers/wpa_supplicant/Hotspot.py similarity index 100% rename from core/services/wifi/Hotspot.py rename to core/services/wifi/wifi_handlers/wpa_supplicant/Hotspot.py diff --git a/core/services/wifi/WifiManager.py b/core/services/wifi/wifi_handlers/wpa_supplicant/WifiManager.py similarity index 68% rename from core/services/wifi/WifiManager.py rename to core/services/wifi/wifi_handlers/wpa_supplicant/WifiManager.py index cf8af3eabe..212661ee08 100644 --- a/core/services/wifi/WifiManager.py +++ b/core/services/wifi/wifi_handlers/wpa_supplicant/WifiManager.py @@ -1,28 +1,94 @@ import asyncio +import os +import stat import subprocess import time +from argparse import ArgumentParser, Namespace +from http.client import HTTPException from ipaddress import IPv4Address from typing import Any, Dict, List, Optional -from commonwealth.settings.manager import Manager +from commonwealth.utils.general import HostOs, get_host_os +from fastapi import status from loguru import logger from exceptions import FetchError, ParseError -from Hotspot import HotspotManager -from settings import SettingsV1 from typedefs import ( ConnectionStatus, SavedWifiNetwork, ScannedWifiNetwork, WifiCredentials, + WifiStatus, ) -from wpa_supplicant import WPASupplicant +from wifi_handlers.AbstractWifiHandler import AbstractWifiManager +from wifi_handlers.wpa_supplicant.Hotspot import HotspotManager +from wifi_handlers.wpa_supplicant.wpa_supplicant import WPASupplicant -class WifiManager: +# pylint: disable=too-many-instance-attributes +class WifiManager(AbstractWifiManager): wpa = WPASupplicant() + wpa_path: Optional[str] = None - def connect(self, path: Any) -> None: + async def can_work(self) -> bool: + return bool(get_host_os() == HostOs.Bullseye) + + async def try_connect_to_network(self, credentials: WifiCredentials, hidden: bool = False) -> Any: + logger.info(f"Trying to connect to '{credentials.ssid}'.") + + network_id: Optional[int] = None + is_new_network = False + try: + saved_networks = await self.get_saved_wifi_network() + match_network = next(filter(lambda network: network.ssid == credentials.ssid, saved_networks)) + network_id = match_network.networkid + logger.info(f"Network is already known, id={network_id}.") + except StopIteration: + logger.info("Network is not known.") + is_new_network = True + + is_secure = False + try: + available_networks = await self.get_wifi_available() + scanned_network = next(filter(lambda network: network.ssid == credentials.ssid, available_networks)) + flags_for_passwords = ["WPA", "WEP", "WSN"] + for candidate in flags_for_passwords: + if candidate in scanned_network.flags: + is_secure = True + break + except StopIteration: + logger.info("Could not find wifi network around.") + + if credentials.password == "" and network_id is None and is_secure: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No password received and network not found among saved ones.", + ) + + try: + # Update known network if password is not necessary anymore + if network_id is not None and not is_secure and credentials.password == "": + logger.info(f"Removing old entry for known network, id={network_id}.") + await self.remove_network_by_id(network_id) + network_id = await self.add_network(credentials, hidden) + logger.info(f"Network entry updated, id={network_id}.") + + if network_id is None: + network_id = await self.add_network(credentials, hidden) + logger.info(f"Saving new network entry, id={network_id}.") + + logger.info("Performing network connection.") + if network_id is None: + raise ValueError("Missing 'network_id' for network connection.") + await self.connect_to_network(network_id, timeout=40) + except ConnectionError as error: + if is_new_network and network_id is not None: + logger.info("Removing new network entry since connection failed.") + await self.remove_network_by_id(network_id) + raise error + logger.info(f"Successfully connected to '{credentials.ssid}'.") + + async def connect(self, path: Any) -> None: """Does the connection with wpa_supplicant service Arguments: @@ -36,10 +102,8 @@ def connect(self, path: Any) -> None: self._time_last_scan = 0.0 # Perform first scan so the wlan interface gets configured (for just-flashed-images) - asyncio.run(self.get_wifi_available()) + await self.get_wifi_available() - self._settings_manager = Manager("wifi-manager", SettingsV1) - self._settings_manager.load() try: self._hotspot: Optional[HotspotManager] = None ssid, password = ( @@ -47,10 +111,10 @@ def connect(self, path: Any) -> None: self._settings_manager.settings.hotspot_password, ) if ssid is not None and password is not None: - self.set_hotspot_credentials(WifiCredentials(ssid=ssid, password=password)) + await self.set_hotspot_credentials(WifiCredentials(ssid=ssid, password=password)) if self.hotspot.supports_hotspot and self._settings_manager.settings.hotspot_enabled in [True, None]: time.sleep(5) - self.enable_hotspot() + await self.enable_hotspot() except Exception: logger.exception("Could not load previous hotspot settings.") @@ -195,16 +259,11 @@ async def add_network(self, credentials: WifiCredentials, hidden: bool = False) await self.wpa.send_command_set_network(network_number, "scan_ssid", "1") await self.wpa.send_command_save_config() await self.wpa.send_command_reconfigure() - return network_number + return int(network_number) except Exception as error: raise ConnectionError("Failed to set new network.") from error - async def remove_network(self, network_id: int) -> None: - """Remove saved wifi network - - Arguments: - network_id {int} -- Network ID as it comes from WPA Supplicant list of saved networks - """ + async def remove_network_by_id(self, network_id: int) -> None: try: await self.wpa.send_command_remove_network(network_id) await self.wpa.send_command_save_config() @@ -212,6 +271,23 @@ async def remove_network(self, network_id: int) -> None: except Exception as error: raise ConnectionError("Failed to remove existing network.") from error + async def remove_network(self, ssid: str) -> None: + """Remove saved wifi network + + Arguments: + network_id {int} -- Network ID as it comes from WPA Supplicant list of saved networks + """ + saved_networks = await self.get_saved_wifi_network() + # Here we get all networks that match the ssid + # and get a list where the biggest networkid comes first. + # If we remove the lowest numbers first, it'll change the highest values to -1 + # TODO: We should move the entire wifi framestack to work with bssid + match_networks = [network for network in saved_networks if network.ssid == ssid] + match_networks = sorted(match_networks, key=lambda network: network.networkid, reverse=True) + for match_network in match_networks: + logger.info(f"removing (networkid={match_network.networkid})") + await self.remove_network_by_id(match_network.networkid) + async def connect_to_network(self, network_id: int, timeout: float = 20.0) -> None: """Connect to wifi network @@ -222,15 +298,15 @@ async def connect_to_network(self, network_id: int, timeout: float = 20.0) -> No was_hotspot_enabled = self.hotspot.is_running() try: if was_hotspot_enabled: - self.disable_hotspot(save_settings=False) + await self.disable_hotspot(save_settings=False) await self.wpa.send_command_select_network(network_id) await self.wpa.send_command_save_config() await self.wpa.send_command_reconfigure() await self.wpa.send_command_reconnect() start_time = time.time() while True: - status = await self.status() - is_connected = status.get("wpa_state") == "COMPLETED" + wpa_status = await self.status() + is_connected = wpa_status.wpa_state == "COMPLETED" if is_connected: current_network = await self.get_current_network() if current_network and current_network.networkid == network_id: @@ -252,13 +328,14 @@ async def connect_to_network(self, network_id: int, timeout: float = 20.0) -> No raise ConnectionError(f"Failed to connect to network. {error}") from error finally: if was_hotspot_enabled: - self.enable_hotspot(save_settings=False) + await self.enable_hotspot(save_settings=False) - async def status(self) -> Dict[str, Any]: + async def status(self) -> WifiStatus: """Check wpa_supplicant status""" try: data = await self.wpa.send_command_status() - return WifiManager.__dict_from_list(data) + # pylint: disable=too-many-function-args + return WifiStatus(**WifiManager.__dict_from_list(data)) except Exception as error: raise FetchError("Failed to get status from wifi manager.") from error @@ -344,7 +421,7 @@ async def auto_reconnect(self, seconds_before_reconnecting: float) -> None: is_connected = await self.get_current_network() is not None - if is_connected and "ip_address" not in await self.status(): + if is_connected and (await self.status()).ip_address is None: # we are connected but have no ip addres? lets ask cable-guy for a new ip self.trigger_dhcp_client() @@ -374,7 +451,7 @@ async def auto_reconnect(self, seconds_before_reconnecting: float) -> None: try: if self._settings_manager.settings.smart_hotspot_enabled in [None, True]: logger.debug("Starting smart-hotspot.") - self.enable_hotspot() + await self.enable_hotspot() except Exception: logger.exception("Could not start smart-hotspot.") networks_reenabled = True @@ -389,11 +466,11 @@ async def start_hotspot_watchdog(self) -> None: try: if self._settings_manager.settings.hotspot_enabled and not self.hotspot.is_running(): logger.warning("Hotspot should be working but is not. Restarting it.") - self.enable_hotspot() + await self.enable_hotspot() except Exception: logger.exception("Could not start hotspot from the watchdog routine.") - def set_hotspot_credentials(self, credentials: WifiCredentials) -> None: + async def set_hotspot_credentials(self, credentials: WifiCredentials) -> None: self._settings_manager.settings.hotspot_ssid = credentials.ssid self._settings_manager.settings.hotspot_password = credentials.password self._settings_manager.save() @@ -401,24 +478,25 @@ def set_hotspot_credentials(self, credentials: WifiCredentials) -> None: self.hotspot.set_credentials(credentials) if self.hotspot.is_running(): - self.disable_hotspot(save_settings=False) + await self.disable_hotspot(save_settings=False) time.sleep(5) - self.enable_hotspot(save_settings=False) + await self.enable_hotspot(save_settings=False) def hotspot_credentials(self) -> WifiCredentials: - return self.hotspot.credentials + credentials: WifiCredentials = self.hotspot.credentials + return credentials - def enable_hotspot(self, save_settings: bool = True) -> None: + async def enable_hotspot(self, save_settings: bool = True) -> bool: if save_settings: self._settings_manager.settings.hotspot_enabled = True self._settings_manager.save() if self.hotspot.is_running(): logger.warning("Hotspot already running. No need to enable it again.") - return self.hotspot.start() + return True - def disable_hotspot(self, save_settings: bool = True) -> None: + async def disable_hotspot(self, save_settings: bool = True) -> None: if save_settings: self._settings_manager.settings.hotspot_enabled = False self._settings_manager.save() @@ -435,3 +513,69 @@ def disable_smart_hotspot(self) -> None: def is_smart_hotspot_enabled(self) -> bool: return self._settings_manager.settings.smart_hotspot_enabled is True + + def add_arguments(self, parser: ArgumentParser) -> None: + """ + adds custom entries to argparser + """ + parser.add_argument( + "--socket", + dest="socket_name", + type=str, + help="Name of the WPA Supplicant socket. Usually 'wlan0' or 'wlp4s0'.", + ) + + def configure(self, args: Namespace) -> None: + self.args = args + + async def start(self) -> None: + wpa_socket_folder = "/var/run/wpa_supplicant/" + try: + if self.args.socket_name: + logger.info("Connecting via provided socket.") + socket_name = self.args.socket_name + else: + logger.info("Connecting via default socket.") + + def is_socket(file_path: str) -> bool: + try: + mode = os.stat(file_path).st_mode + return stat.S_ISSOCK(mode) + except Exception as error: + logger.warning(f"Could not check if '{file_path}' is a socket: {error}") + return False + + # We are going to sort and get the latest file, since this in theory will be an external interface + # added by the user + entries = os.scandir(wpa_socket_folder) + available_sockets = sorted( + [ + entry.path + for entry in entries + if entry.name.startswith(("wlan", "wifi", "wlp")) and is_socket(entry.path) + ] + ) + if not available_sockets: + raise RuntimeError("No wifi sockets available.") + socket_name = available_sockets[-1] + logger.info(f"Going to use {socket_name} file") + WLAN_SOCKET = os.path.join(wpa_socket_folder, socket_name) + self.wpa_path = WLAN_SOCKET + await self.connect(WLAN_SOCKET) + except Exception as socket_connection_error: + logger.warning(f"Could not connect with wifi socket. {socket_connection_error}") + logger.info("Connecting via internet wifi socket.") + try: + await self.connect(("127.0.0.1", 6664)) + except Exception as udp_connection_error: + logger.error(f"Could not connect with internet socket: {udp_connection_error}. Exiting.") + raise udp_connection_error + loop = asyncio.get_event_loop() + loop.create_task(self.auto_reconnect(60)) + loop.create_task(self.start_hotspot_watchdog()) + + async def supports_hotspot(self) -> bool: + return self.hotspot.supports_hotspot + + async def hotspot_is_running(self) -> bool: + return self.hotspot.is_running() diff --git a/core/services/wifi/wifi_handlers/wpa_supplicant/__init__.py b/core/services/wifi/wifi_handlers/wpa_supplicant/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/core/services/wifi/wpa_supplicant.py b/core/services/wifi/wifi_handlers/wpa_supplicant/wpa_supplicant.py similarity index 100% rename from core/services/wifi/wpa_supplicant.py rename to core/services/wifi/wifi_handlers/wpa_supplicant/wpa_supplicant.py diff --git a/core/tools/install-system-tools.sh b/core/tools/install-system-tools.sh index 8f861b3e3d..d5917f4c9f 100755 --- a/core/tools/install-system-tools.sh +++ b/core/tools/install-system-tools.sh @@ -18,4 +18,4 @@ parallel --halt now,fail=1 '/home/pi/tools/{}/bootstrap.sh' ::: "${TOOLS[@]}" # Tools that uses apt to do the installation # APT is terrible like pip and don't know how to handle parallel installation # These should periodically be moved onto the base image -apt update && apt install -y --no-install-recommends dhcpcd5 +apt update && apt install -y --no-install-recommends dhcpcd5 iptables iproute2