diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ed2d2aa2b..11b5f5a42 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -16,7 +16,7 @@ code-quality: stage: test needs: [] script: - - python3 -m flake8 --exclude actions/networks container plinth actions/* + - python3 -m flake8 container plinth actions/* unit-tests: stage: test diff --git a/actions/networks b/actions/networks deleted file mode 100755 index 7616e598a..000000000 --- a/actions/networks +++ /dev/null @@ -1,161 +0,0 @@ -#!/bin/bash - -set -e - -# Configure networking for all wired and wireless devices. -# -# Creates network-manager connections. - -function get-interfaces { - WIRED_IFACES=$(nmcli --terse --fields type,device device | grep "^ethernet:" | cut -d: -f2 | sort -V) - NO_OF_WIRED_IFACES=$(echo $WIRED_IFACES | wc -w) - - WIRELESS_IFACES=$(nmcli --terse --fields type,device device | grep "^wifi:" | cut -d: -f2 | sort -V) - NO_OF_WIRELESS_IFACES=$(echo $WIRELESS_IFACES | wc -w) -} - -function add-connection { - local connection_name="$1" - shift - local interface="$1" - shift - local remaining_arguments="$@" - - already_exists=$(nmcli --terse --fields name,device con show | grep "$connection_name:$interface" || true) - if [ -n "$already_exists" ]; then - echo "Connection '$connection_name' already exists for device '$interface', not adding." - else - nmcli con add con-name "$connection_name" ifname "$interface" $remaining_arguments - fi -} - -function activate-connection { - connection_name="$1" - nohup nmcli con up "$connection_name" &>/dev/null & -} - -function configure-regular-interface { - local interface="$1" - local zone="$2" - local connection_name="FreedomBox WAN" - - # Create n-m connection for a regular interface - add-connection "$connection_name" "$interface" type ethernet - nmcli con modify "$connection_name" connection.autoconnect TRUE - nmcli con modify "$connection_name" connection.zone "$zone" - activate-connection "$connection_name" - - echo "Configured interface '$interface' for '$zone' use as '$connection_name'." -} - -function configure-shared-interface { - local interface="$1" - local connection_name="FreedomBox LAN $interface" - - # Create n-m connection for eth1 - add-connection "$connection_name" "$interface" type ethernet - nmcli con modify "$connection_name" connection.autoconnect TRUE - nmcli con modify "$connection_name" connection.zone internal - - # Configure this interface to be shared with other computers. - # - Self-assign an address and network - # - Start and manage DNS server (dnsmasq) - # - Start and manage DHCP server (dnsmasq) - # - Register address with mDNS - # - Add firewall rules for NATing from this interface - nmcli con modify "$connection_name" ipv4.method shared - - activate-connection "$connection_name" - - echo "Configured interface '$interface' for shared use as '$connection_name'." -} - -function configure-wireless-interface { - local interface="$1" - local connection_name="FreedomBox $interface" - local ssid="FreedomBox$interface" - local secret="freedombox123" - - add-connection "$connection_name" "$interface" type wifi ssid "$ssid" - nmcli con modify "$connection_name" connection.autoconnect TRUE - nmcli con modify "$connection_name" connection.zone internal - nmcli con modify "$connection_name" ipv4.method shared - nmcli con modify "$connection_name" wifi.mode ap - nmcli con modify "$connection_name" wifi-sec.key-mgmt wpa-psk - nmcli con modify "$connection_name" wifi-sec.psk "$secret" - activate-connection "$connection_name" - - echo "Configured interface '$interface' for shared use as '$connection_name'." -} - -function multi-wired-setup { - local first_interface="$1" - shift - local remaining_interfaces="$@" - - configure-regular-interface "$first_interface" external - - for interface in $remaining_interfaces - do - configure-shared-interface "$interface" - done -} - -function one-wired-setup { - local interface="$1" - - case $NO_OF_WIRELESS_IFACES in - "0") - configure-regular-interface "$interface" internal - ;; - *) - configure-regular-interface "$interface" external - ;; - esac -} - -function wireless-setup { - local interfaces="$@" - - for interface in $interfaces - do - configure-wireless-interface "$interface" - done -} - -function setup { - echo "Setting up network configuration..." - get-interfaces - - case $NO_OF_WIRED_IFACES in - "0") - echo "No wired interfaces detected." - ;; - "1") - one-wired-setup $WIRED_IFACES - ;; - *) - multi-wired-setup $WIRED_IFACES - esac - - wireless-setup $WIRELESS_IFACES - - echo "Done setting up network configuration." -} - -# -# For a user who installed using freedombox-setup Debian package, when -# FreedomBox Service (Plinth) is run for the first time, don't run network -# setup. This is ensured by checking for the file -# /var/lib/freedombox/is-freedombox-disk-image which will not exist. -# -# For a user who installed using FreedomBox disk image, when FreedomBox Service -# (Plinth) runs for the first time, setup process executes and triggers the -# script due networks module being an essential module. -# -if [ -f "/var/lib/freedombox/is-freedombox-disk-image" ] -then - setup -else - echo "Not a FreedomBox disk image. Skipping network configuration." -fi diff --git a/plinth/modules/networks/__init__.py b/plinth/modules/networks/__init__.py index 3e7252428..6bda322df 100644 --- a/plinth/modules/networks/__init__.py +++ b/plinth/modules/networks/__init__.py @@ -1,18 +1,17 @@ # SPDX-License-Identifier: AGPL-3.0-or-later -""" -FreedomBox app to interface with network-manager. -""" +"""FreedomBox app to interface with network-manager.""" import subprocess from logging import Logger from django.utils.translation import gettext_lazy as _ -from plinth import actions from plinth import app as app_module from plinth import daemon, kvstore, menu, network from plinth.package import Packages +from . import privileged + first_boot_steps = [ { 'id': 'network_topology_wizard', @@ -83,7 +82,7 @@ class NetworksApp(app_module.App): def setup(self, old_version): """Install and configure the app.""" super().setup(old_version) - actions.superuser_run('networks') + privileged.setup() self.enable() diff --git a/plinth/modules/networks/privileged.py b/plinth/modules/networks/privileged.py new file mode 100644 index 000000000..2ea77518e --- /dev/null +++ b/plinth/modules/networks/privileged.py @@ -0,0 +1,189 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Configure network manager. + +During initial setup, configure networking for all wired and wireless devices +by creating network manager connections. +""" + +import collections +import itertools +import logging +import re +import subprocess + +from plinth import action_utils +from plinth.actions import privileged + + +def _sort_interfaces(interfaces: list[str]) -> list[str]: + """Sort interfaces in a well-defined way: eth0, eth1, eth2, ... eth10.""" + + def key_func(interface): + parts = re.findall(r'(\D*)(\d*)', interface) + parts = [(string, int(number) if number else number) + for string, number in parts] + return list(itertools.chain(parts)) + + return sorted(interfaces, key=key_func) + + +def _get_interfaces() -> dict[str, list[str]]: + """Return all network interfaces by their type.""" + output = subprocess.check_output( + ['nmcli', '--terse', '--fields', 'type,device', 'device']) + interfaces = collections.defaultdict(list) + for line in output.decode().splitlines(): + type_, _, interface = line.partition(':') + interfaces[type_].append(interface) + + for type_ in interfaces: + interfaces[type_] = _sort_interfaces(interfaces[type_]) + + return interfaces + + +def _add_connection(connection_name: str, interface: str, + remaining_arguments: list[str]): + """Add an Ethernet/Wi-Fi connection of type regular or shared.""" + output = subprocess.check_output( + ['nmcli', '--terse', '--fields', 'name,device', 'con', 'show']) + lines = output.decode().splitlines() + if f'{connection_name}:{interface}' in lines: + logging.info('Connection %s already exists for device %s, not adding.', + connection_name, interface) + else: + subprocess.run([ + 'nmcli', 'con', 'add', 'con-name', connection_name, 'ifname', + interface + ] + remaining_arguments, check=True) + + +def _activate_connection(connection_name: str): + """Activate a network connection in a background process.""" + subprocess.Popen(['nohup', 'nmcli', 'con', 'up', connection_name], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def _configure_regular_interface(interface: str, zone: str): + """Create a connection that is not a shared connection.""" + connection_name = 'FreedomBox WAN' + properties = {'connection.autoconnect': 'TRUE', 'connection.zone': zone} + + # Create n-m connection for a regular interface + _add_connection(connection_name, interface, ['type', 'ethernet']) + _set_connection_properties(connection_name, properties) + _activate_connection(connection_name) + + logging.info('Configured interface %s for %s use as %s.', interface, zone, + connection_name) + + +def _configure_shared_interface(interface: str): + """Create a shared connection that has traffic forwarding enabled. + + Shared connection means: + - Self-assign an address and network + - Start and manage DNS server (dnsmasq) + - Start and manage DHCP server (dnsmasq) + - Register address with mDNS + - Add firewall rules for NATing from this interface + """ + connection_name = f'FreedomBox LAN {interface}' + properties = { + 'connection.autoconnect': 'TRUE', + 'connection.zone': 'internal', + 'ipv4.method': 'shared' + } + + # Create n-m connection for eth1 + _add_connection(connection_name, interface, ['type', 'ethernet']) + _set_connection_properties(connection_name, properties) + _activate_connection(connection_name) + + logging.info('Configured interface %s for shared use as %s.', interface, + connection_name) + + +def _set_connection_properties(connection_name: str, properties: dict[str, + str]): + """Configure property key/values on a connection.""" + for key, value in properties.items(): + subprocess.run(['nmcli', 'con', 'modify', connection_name, key, value], + check=True) + + +def _configure_wireless_interface(interface: str): + """Configure a wireless access point.""" + connection_name = f'FreedomBox {interface}' + ssid = f'FreedomBox{interface}' + secret = 'freedombox123' + properties = { + 'connection.autoconnect': 'TRUE', + 'connection.zone': 'internal', + 'ipv4.method': 'shared', + 'wifi.mode': 'ap', + 'wifi-sec.key-mgmt': 'wpa-psk', + 'wifi-sec.psk': secret + } + + _add_connection(connection_name, interface, ['type', 'wifi', 'ssid', ssid]) + _set_connection_properties(connection_name, properties) + _activate_connection(connection_name) + + logging.info('Configured interface %s for shared use as %s', interface, + connection_name) + + +def _multi_wired_setup(interfaces: list[str]): + """Configure all Ethernet connections on a system with many of them.""" + _configure_regular_interface(interfaces[0], 'external') + + for interface in interfaces[1:]: + _configure_shared_interface(interface) + + +def _one_wired_setup(interface: str, interfaces: dict[str, list[str]]): + """Configure an Ethernet connection on a system with only one.""" + if not len(interfaces['wifi']): + _configure_regular_interface(interface, 'internal') + else: + _configure_regular_interface(interface, 'external') + + +def _wireless_setup(interfaces: list[str]): + """Configure all wireless access points.""" + for interface in interfaces: + _configure_wireless_interface(interface) + + +@privileged +def setup(): + """Create network manager connections. + + For a user who installed using freedombox-setup Debian package, when + FreedomBox Service (Plinth) is run for the first time, don't run network + setup. This is ensured by checking for the file + /var/lib/freedombox/is-freedombox-disk-image which will not exist. + + For a user who installed using FreedomBox disk image, when FreedomBox + Service (Plinth) runs for the first time, setup process executes and + triggers the script due networks module being an essential module. + """ + if not action_utils.is_disk_image(): + logging.info( + 'Not a FreedomBox disk image. Skipping network configuration.') + return + + logging.info('Setting up network configuration...') + interfaces = _get_interfaces() + + if len(interfaces['ethernet']) == 0: + logging.info('No wired interfaces detected.') + elif len(interfaces['ethernet']) == 1: + _one_wired_setup(interfaces['ethernet'][0], interfaces) + else: + _multi_wired_setup(interfaces['ethernet']) + + _wireless_setup(interfaces['wifi']) + + logging.info('Done setting up network configuration.') diff --git a/plinth/modules/networks/tests/test_privileged.py b/plinth/modules/networks/tests/test_privileged.py new file mode 100644 index 000000000..b18de501e --- /dev/null +++ b/plinth/modules/networks/tests/test_privileged.py @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Test privileged method for networks app.""" + +from unittest.mock import patch + +from .. import privileged + + +@patch('subprocess.check_output') +def test_get_interfaces(check_output): + """Test returning list of network interfaces in sorted order.""" + check_output.return_value = '\n'.join([ + 'ethernet:ve-fbx-testing', + 'ethernet:enp39s0', + 'ethernet:enp32s1', + 'ethernet:enp4s1', + 'bridge:virbr0', + 'wifi:wlp41s0', + 'loopback:lo', + ]).encode() + + interfaces = privileged._get_interfaces() + assert interfaces == { + 'ethernet': ['enp4s1', 'enp32s1', 'enp39s0', 've-fbx-testing'], + 'bridge': ['virbr0'], + 'wifi': ['wlp41s0'], + 'loopback': ['lo'] + }