From 66fe38f8df7e935286ef833119689b94099180c8 Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Fri, 25 Oct 2024 15:32:19 -0400 Subject: [PATCH 01/41] start of runcommand refactor --- wlanpi_core/services/network_service.py | 3 ++ wlanpi_core/utils/general.py | 60 ++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/wlanpi_core/services/network_service.py b/wlanpi_core/services/network_service.py index 5eb4bce..fa5fead 100644 --- a/wlanpi_core/services/network_service.py +++ b/wlanpi_core/services/network_service.py @@ -693,3 +693,6 @@ async def get_systemd_network_currentNetwork_details( # 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)) + +if __name__ == "__main__": + print(get_ip_address('eth0')) \ No newline at end of file diff --git a/wlanpi_core/utils/general.py b/wlanpi_core/utils/general.py index fa7786c..8f9f642 100644 --- a/wlanpi_core/utils/general.py +++ b/wlanpi_core/utils/general.py @@ -1,19 +1,75 @@ +import asyncio.subprocess +import logging import subprocess +from asyncio.subprocess import Process +from typing import Union, Optional, TextIO from wlanpi_core.models.command_result import CommandResult from wlanpi_core.models.runcommand_error import RunCommandError -def run_command(cmd: list, shell=False, raise_on_fail=True) -> CommandResult: +def run_command(cmd: Union[list, str], input:Optional[str]=None, stdin:Optional[TextIO]=None, shell=False, raise_on_fail=True) -> CommandResult: """Run a single CLI command with subprocess and returns the output""" - # print("Running command:", cmd) + + # cannot have both input and STDIN, unless stdin is the constant for PIPE or /dev/null + if input and stdin and not isinstance(stdin, int): + raise RunCommandError(error_msg="You cannot use both 'input' and 'stdin' on the same call.", status_code=-1) + + if shell: + cmd: str + logging.getLogger().warning(f"Command {cmd} being run as a shell script. This could present" + f"an injection vulnerability. Consider whether you really need to do this.") + else: + cmd: list[str] cp = subprocess.run( cmd, + input=input, + stdin=stdin, encoding="utf-8", shell=shell, check=False, capture_output=True, ) + if raise_on_fail and cp.returncode != 0: raise RunCommandError(cp.stderr, cp.returncode) return CommandResult(cp.stdout, cp.stderr, cp.returncode) + + +async def run_command_async(cmd: Union[list, str], input:Optional[str]=None, stdin:Optional[TextIO]=None, shell=False, raise_on_fail=True) -> CommandResult: + """Run a single CLI command with asyncio.subprocess and returns the output""" + + # cannot have both input and STDIN, unless stdin is the constant for PIPE or /dev/null + if input and stdin and not isinstance(stdin, int): + raise RunCommandError(error_msg="You cannot use both 'input' and 'stdin' on the same call.", status_code=-1) + + # asyncio.subprocess has different commands for shell and no shell. + # Switch between them to keep a standard interface. + if shell: + cmd: str + logging.getLogger().warning(f"Command {cmd} being run as a shell script. This could present" + f"an injection vulnerability. Consider whether you really need to do this.") + + with asyncio.subprocess.create_subprocess_shell( + cmd, + stdin=asyncio.subprocess.PIPE if input else stdin, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) as proc: + proc: Process + stdout, stderr = await proc.communicate(input=input.encode() if input else None) + else: + cmd: list[str] + with asyncio.subprocess.create_subprocess_exec( + cmd[0], + *cmd[1:], + stdin=asyncio.subprocess.PIPE if input else stdin, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) as proc: + proc: Process + stdout, stderr = await proc.communicate(input=input.encode() if input else None) + + if raise_on_fail and proc.returncode != 0: + raise RunCommandError(error_msg=stderr.decode(), status_code=proc.returncode) + return CommandResult(stdout.decode(), stderr.decode(), proc.returncode) From 1f829604d84e2dc3d5340c4bb5fce32702ce9577 Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Fri, 25 Oct 2024 17:14:52 -0400 Subject: [PATCH 02/41] Refactored run_command usage and added tests --- wlanpi_core/models/command_result.py | 12 +- wlanpi_core/models/network/common.py | 2 +- .../models/network/namespace/namespace.py | 14 +-- wlanpi_core/utils/general.py | 58 +++++---- wlanpi_core/utils/tests/__init__.py | 0 .../utils/tests/run_command_async_tests.py | 118 ++++++++++++++++++ wlanpi_core/utils/tests/run_command_tests.py | 61 +++++++++ 7 files changed, 229 insertions(+), 36 deletions(-) create mode 100644 wlanpi_core/utils/tests/__init__.py create mode 100644 wlanpi_core/utils/tests/run_command_async_tests.py create mode 100644 wlanpi_core/utils/tests/run_command_tests.py diff --git a/wlanpi_core/models/command_result.py b/wlanpi_core/models/command_result.py index 4d8336f..1bdeb84 100644 --- a/wlanpi_core/models/command_result.py +++ b/wlanpi_core/models/command_result.py @@ -6,14 +6,14 @@ class CommandResult: """Returned by run_command""" - def __init__(self, output: str, error: str, status_code: int): - self.output = output - self.error = error - self.status_code = status_code - self.success = self.status_code == 0 + def __init__(self, stdout: str, stderr: str, return_code: int): + self.stdout = stdout + self.stderr = stderr + self.return_code = return_code + self.success = self.return_code == 0 def output_from_json(self) -> Union[dict, list, int, float, str, None]: try: - return json.loads(self.output) + return json.loads(self.stdout) except JSONDecodeError: return None diff --git a/wlanpi_core/models/network/common.py b/wlanpi_core/models/network/common.py index b94cad6..f5a1a45 100644 --- a/wlanpi_core/models/network/common.py +++ b/wlanpi_core/models/network/common.py @@ -21,7 +21,7 @@ def get_interfaces( interface["link_speed"] = int( run_command( ["cat", f"/sys/class/net/{interface['ifname']}/speed"] - ).output + ).stdout ) if custom_filter: diff --git a/wlanpi_core/models/network/namespace/namespace.py b/wlanpi_core/models/network/namespace/namespace.py index 5f66ccd..06ad883 100644 --- a/wlanpi_core/models/network/namespace/namespace.py +++ b/wlanpi_core/models/network/namespace/namespace.py @@ -78,7 +78,7 @@ def list_namespaces() -> list: """ result = run_command("ip -j netns list".split(), raise_on_fail=False) if not result.success: - raise NetworkNamespaceError(f"Error listing namespaces: {result.error}") + raise NetworkNamespaceError(f"Error listing namespaces: {result.stderr}") return result.output_from_json() or [] @staticmethod @@ -153,7 +153,7 @@ def destroy_namespace(namespace_name: str): res = run_command( f"ip netns exec {namespace_name} iw dev {interface['name']} info".split() ) - phynum = re.findall(r"wiphy ([0-9]+)", res.output)[0] + phynum = re.findall(r"wiphy ([0-9]+)", res.stdout)[0] phy = f"phy{phynum}" res = run_command( @@ -162,7 +162,7 @@ def destroy_namespace(namespace_name: str): ) if not res.success: raise NetworkNamespaceError( - f"Failed to move wireless interface {interface['name']} to default namespace: {res.error}" + f"Failed to move wireless interface {interface['name']} to default namespace: {res.stderr}" ) elif interface["name"].startswith("eth"): @@ -172,7 +172,7 @@ def destroy_namespace(namespace_name: str): ) if not res.success: raise NetworkNamespaceError( - f"Failed to move wired interface {interface['name']} to default namespace: {res.error}" + f"Failed to move wired interface {interface['name']} to default namespace: {res.stderr}" ) elif interface["name"].startswith("lo"): @@ -188,7 +188,7 @@ def destroy_namespace(namespace_name: str): res = run_command(f"ip netns del {namespace_name}".split(), raise_on_fail=False) if not res.success: raise NetworkNamespaceError( - f"Unable to destroy namespace {namespace_name} {res.error}" + f"Unable to destroy namespace {namespace_name} {res.stderr}" ) @staticmethod @@ -201,6 +201,6 @@ def processes_using_namespace(namespace_name: str): ) if not result.success: raise NetworkNamespaceError( - f"Error getting namespace processes: {result.error}" + f"Error getting namespace processes: {result.stderr}" ) - return [int(x) for x in filter(None, result.output.split("\n") or [])] + return [int(x) for x in filter(None, result.stdout.split("\n") or [])] diff --git a/wlanpi_core/utils/general.py b/wlanpi_core/utils/general.py index 8f9f642..bb9d7f1 100644 --- a/wlanpi_core/utils/general.py +++ b/wlanpi_core/utils/general.py @@ -2,6 +2,7 @@ import logging import subprocess from asyncio.subprocess import Process +from io import StringIO from typing import Union, Optional, TextIO from wlanpi_core.models.command_result import CommandResult @@ -17,23 +18,28 @@ def run_command(cmd: Union[list, str], input:Optional[str]=None, stdin:Optional[ if shell: cmd: str - logging.getLogger().warning(f"Command {cmd} being run as a shell script. This could present" + logging.getLogger().warning(f"Command {cmd} being run as a shell script. This could present " f"an injection vulnerability. Consider whether you really need to do this.") else: cmd: list[str] - cp = subprocess.run( + with subprocess.Popen( cmd, - input=input, - stdin=stdin, - encoding="utf-8", shell=shell, - check=False, - capture_output=True, - ) + stdin=subprocess.PIPE if input or isinstance(stdin, StringIO) else stdin, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) as proc: + if input: + input_data = input.encode() + elif isinstance(stdin, StringIO): + input_data = stdin.read().encode() + else: + input_data = None + stdout, stderr = proc.communicate(input=input_data) - if raise_on_fail and cp.returncode != 0: - raise RunCommandError(cp.stderr, cp.returncode) - return CommandResult(cp.stdout, cp.stderr, cp.returncode) + if raise_on_fail and proc.returncode != 0: + raise RunCommandError(stderr.decode(), proc.returncode) + return CommandResult(stdout.decode(), stderr.decode(), proc.returncode) async def run_command_async(cmd: Union[list, str], input:Optional[str]=None, stdin:Optional[TextIO]=None, shell=False, raise_on_fail=True) -> CommandResult: @@ -43,32 +49,40 @@ async def run_command_async(cmd: Union[list, str], input:Optional[str]=None, std if input and stdin and not isinstance(stdin, int): raise RunCommandError(error_msg="You cannot use both 'input' and 'stdin' on the same call.", status_code=-1) + # Prepare input data for communicate + if input: + input_data = input.encode() + elif isinstance(stdin, StringIO): + input_data = stdin.read().encode() + else: + input_data = None + # asyncio.subprocess has different commands for shell and no shell. # Switch between them to keep a standard interface. if shell: cmd: str - logging.getLogger().warning(f"Command {cmd} being run as a shell script. This could present" + logging.getLogger().warning(f"Command {cmd} being run as a shell script. This could present " f"an injection vulnerability. Consider whether you really need to do this.") - with asyncio.subprocess.create_subprocess_shell( + proc = await asyncio.subprocess.create_subprocess_shell( cmd, - stdin=asyncio.subprocess.PIPE if input else stdin, + stdin=subprocess.PIPE if input or isinstance(stdin, StringIO) else stdin, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE - ) as proc: - proc: Process - stdout, stderr = await proc.communicate(input=input.encode() if input else None) + ) + proc: Process + stdout, stderr = await proc.communicate(input=input_data) else: cmd: list[str] - with asyncio.subprocess.create_subprocess_exec( + proc = await asyncio.subprocess.create_subprocess_exec( cmd[0], *cmd[1:], - stdin=asyncio.subprocess.PIPE if input else stdin, + stdin=subprocess.PIPE if input or isinstance(stdin, StringIO) else stdin, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE - ) as proc: - proc: Process - stdout, stderr = await proc.communicate(input=input.encode() if input else None) + ) + proc: Process + stdout, stderr = await proc.communicate(input=input_data) if raise_on_fail and proc.returncode != 0: raise RunCommandError(error_msg=stderr.decode(), status_code=proc.returncode) diff --git a/wlanpi_core/utils/tests/__init__.py b/wlanpi_core/utils/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/wlanpi_core/utils/tests/run_command_async_tests.py b/wlanpi_core/utils/tests/run_command_async_tests.py new file mode 100644 index 0000000..daa96ee --- /dev/null +++ b/wlanpi_core/utils/tests/run_command_async_tests.py @@ -0,0 +1,118 @@ +import asyncio +from io import StringIO +import asyncio +import unittest +from unittest import IsolatedAsyncioTestCase +from unittest.mock import AsyncMock, MagicMock, patch +from wlanpi_core.utils.general import run_command_async, CommandResult, RunCommandError + +class MockProcess: + def __init__(self, returncode=0, stdout="success", stderr=""): + self.returncode = returncode + self.stdout = stdout.encode() if isinstance(stdout, str) else stdout + self.stderr = stderr.encode() if isinstance(stderr, str) else stderr + + async def communicate(self, input=None): + return self.stdout, self.stderr + +class TestRunCommandAsync(IsolatedAsyncioTestCase): + + async def test_run_command_async_success(self): + cmd = ["ls", "-l"] + with patch('asyncio.subprocess.create_subprocess_exec', new=AsyncMock(return_value=MockProcess())) as mock_create_subprocess_exec: + result = await run_command_async(cmd) + mock_create_subprocess_exec.assert_called_once_with( + cmd[0], *cmd[1:], stdin=None, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + self.assertEqual(result.stdout, "success") + self.assertEqual(result.stderr, "") + self.assertEqual(result.return_code, 0) + + async def test_run_command_async_success_with_input(self): + cmd = ["cat"] + test_input = "test input" + with patch('asyncio.subprocess.create_subprocess_exec', new=AsyncMock(return_value=MockProcess(stdout=test_input))) as mock_create_subprocess_exec: + result = await run_command_async(cmd, input=test_input) + mock_create_subprocess_exec.assert_called_once_with( + cmd[0], *cmd[1:], stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + self.assertEqual(result.stdout, test_input) + self.assertEqual(result.stderr, "") + self.assertEqual(result.return_code, 0) + + async def test_run_command_async_success_with_stdin(self): + cmd = ["cat"] + test_input = "test input" + stdin = StringIO(test_input) + with patch('asyncio.subprocess.create_subprocess_exec', new=AsyncMock(return_value=MockProcess(stdout=test_input))) as mock_create_subprocess_exec: + result = await run_command_async(cmd, stdin=stdin) + mock_create_subprocess_exec.assert_called_once_with( + cmd[0], *cmd[1:], stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + self.assertEqual(result.stdout, test_input) + self.assertEqual(result.stderr, "") + self.assertEqual(result.return_code, 0) + + async def test_run_command_async_failure(self): + cmd = ["ls", "-z"] + with patch('asyncio.subprocess.create_subprocess_exec', new=AsyncMock(return_value=MockProcess(returncode=2, stderr="error"))) as mock_create_subprocess_exec: + with self.assertRaises(RunCommandError) as context: + await run_command_async(cmd) + mock_create_subprocess_exec.assert_called_once_with( + cmd[0], *cmd[1:], stdin=None, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + self.assertEqual(str(context.exception), "error") + self.assertEqual(context.exception.status_code, 2) + + async def test_run_command_async_failure_no_raise(self): + cmd = ["ls", "-z"] + with patch('asyncio.subprocess.create_subprocess_exec', new=AsyncMock(return_value=MockProcess(returncode=2, stderr="error"))) as mock_create_subprocess_exec: + result = await run_command_async(cmd, raise_on_fail=False) + mock_create_subprocess_exec.assert_called_once_with( + cmd[0], *cmd[1:], stdin=None, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + self.assertEqual(result.stderr, "error") + self.assertEqual(result.return_code, 2) + + async def test_run_command_async_shell_success(self): + cmd = "ls -l" + with patch('asyncio.subprocess.create_subprocess_shell', new=AsyncMock(return_value=MockProcess())) as mock_create_subprocess_shell: + result = await run_command_async(cmd, shell=True) + mock_create_subprocess_shell.assert_called_once_with( + cmd, stdin=None, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + self.assertEqual(result.stdout, "success") + self.assertEqual(result.stderr, "") + self.assertEqual(result.return_code, 0) + + async def test_run_command_async_shell_failure(self): + cmd = "ls -z" + with patch('asyncio.subprocess.create_subprocess_shell', new=AsyncMock(return_value=MockProcess(returncode=2, stderr="error"))) as mock_create_subprocess_shell: + with self.assertRaises(RunCommandError) as context: + await run_command_async(cmd, shell=True) + mock_create_subprocess_shell.assert_called_once_with( + cmd, stdin=None, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + self.assertEqual(str(context.exception), "error") + self.assertEqual(context.exception.status_code, 2) + + async def test_run_command_async_input_and_stdin_error(self): + cmd = ["ls", "-l"] + with self.assertRaises(RunCommandError) as context: + await run_command_async(cmd, input="test input", stdin=StringIO("test input")) + self.assertEqual(str(context.exception), "You cannot use both 'input' and 'stdin' on the same call.") + self.assertEqual(context.exception.status_code, -1) + + async def test_run_command_async_input_and_stdin_pipe_ok(self): + cmd = ["ls", "-l"] + with patch('asyncio.subprocess.create_subprocess_exec', new=AsyncMock(return_value=MockProcess())) as mock_create_subprocess_exec: + result = await run_command_async(cmd, input="test input", stdin=asyncio.subprocess.PIPE) + mock_create_subprocess_exec.assert_called_once_with( + cmd[0], *cmd[1:], stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + self.assertEqual(result.stdout, "success") + self.assertEqual(result.stderr, "") + self.assertEqual(result.return_code, 0) + +if __name__ == '__main__': + unittest.main() diff --git a/wlanpi_core/utils/tests/run_command_tests.py b/wlanpi_core/utils/tests/run_command_tests.py new file mode 100644 index 0000000..9446e4f --- /dev/null +++ b/wlanpi_core/utils/tests/run_command_tests.py @@ -0,0 +1,61 @@ +import unittest +from unittest.mock import patch +from io import StringIO +from wlanpi_core.utils.general import run_command, RunCommandError, CommandResult + +class TestRunCommand(unittest.TestCase): + + def test_run_command_success(self): + # Test a successful command execution + result = run_command(["echo", "test"]) + self.assertEqual(result.stdout, "test\n") + self.assertEqual(result.stderr, "") + self.assertEqual(result.return_code, 0) + + def test_run_command_failure(self): + # Test a failing command execution with raise_on_fail=True + with self.assertRaises(RunCommandError) as context: + run_command(["ls", "nonexistent_file"]) + self.assertIn("No such file or directory", str(context.exception)) + + def test_run_command_failure_no_raise(self): + # Test a failing command execution with raise_on_fail=False + result = run_command(["false"], raise_on_fail=False) + self.assertEqual(result.return_code, 1) + + def test_run_command_input(self): + # Test providing input to the command + result = run_command(["cat"], input="test input") + self.assertEqual(result.stdout, "test input") + + @patch('subprocess.run') + def test_run_command_stdin(self, mock_run): + # Test providing stdin to the command + mock_run.return_value.stdout = "test input" + mock_run.return_value.stderr = "" + mock_run.return_value.return_code = 0 + result = run_command(["cat"], stdin=StringIO("test input")) + self.assertEqual(result.stdout, "test input") + + def test_run_command_input_and_stdin_error(self): + # Test raising an error when both input and stdin are provided + with self.assertRaises(RunCommandError) as context: + run_command(["echo"], input="test", stdin=StringIO("test")) + self.assertIn("You cannot use both 'input' and 'stdin'", str(context.exception)) + + def test_run_command_shell_warning(self): + # Test the warning message when using shell=True + with self.assertLogs(level='WARNING') as cm: + run_command("echo test", shell=True) + self.assertIn("Command echo test being run as a shell script", cm.stdout[0]) + + def test_command_result(self): + # Test the CommandResult class + result = CommandResult("output", "error", 0) + self.assertEqual(result.stdout, "output") + self.assertEqual(result.stderr, "error") + self.assertEqual(result.return_code, 0) + + +if __name__ == '__main__': + unittest.main() From ea17a88dfa7e0bc210751941e7e93efc66331c6c Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Fri, 25 Oct 2024 17:16:51 -0400 Subject: [PATCH 03/41] Refactored runcommand_error.py to use standard values --- wlanpi_core/models/runcommand_error.py | 4 ++-- wlanpi_core/services/helpers.py | 2 +- wlanpi_core/utils/general.py | 6 +++--- wlanpi_core/utils/tests/run_command_async_tests.py | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/wlanpi_core/models/runcommand_error.py b/wlanpi_core/models/runcommand_error.py index dac16e0..5b0fc9a 100644 --- a/wlanpi_core/models/runcommand_error.py +++ b/wlanpi_core/models/runcommand_error.py @@ -1,8 +1,8 @@ class RunCommandError(Exception): """Raised when runcommand returns stderr""" - def __init__(self, error_msg: str, status_code: int): + def __init__(self, error_msg: str, return_code: int): super().__init__(error_msg) - self.status_code = status_code + self.return_code = return_code self.error_msg = error_msg diff --git a/wlanpi_core/services/helpers.py b/wlanpi_core/services/helpers.py index 89600d4..3cd2d97 100644 --- a/wlanpi_core/services/helpers.py +++ b/wlanpi_core/services/helpers.py @@ -57,7 +57,7 @@ async def run_cli_async(cmd: str, want_stderr: bool = False) -> str: if stderr: raise RunCommandError( - status_code=424, error_msg=f"'{cmd}' gave stderr response" + return_code=424, error_msg=f"'{cmd}' gave stderr response" ) diff --git a/wlanpi_core/utils/general.py b/wlanpi_core/utils/general.py index bb9d7f1..9560ab0 100644 --- a/wlanpi_core/utils/general.py +++ b/wlanpi_core/utils/general.py @@ -14,7 +14,7 @@ def run_command(cmd: Union[list, str], input:Optional[str]=None, stdin:Optional[ # cannot have both input and STDIN, unless stdin is the constant for PIPE or /dev/null if input and stdin and not isinstance(stdin, int): - raise RunCommandError(error_msg="You cannot use both 'input' and 'stdin' on the same call.", status_code=-1) + raise RunCommandError(error_msg="You cannot use both 'input' and 'stdin' on the same call.", return_code=-1) if shell: cmd: str @@ -47,7 +47,7 @@ async def run_command_async(cmd: Union[list, str], input:Optional[str]=None, std # cannot have both input and STDIN, unless stdin is the constant for PIPE or /dev/null if input and stdin and not isinstance(stdin, int): - raise RunCommandError(error_msg="You cannot use both 'input' and 'stdin' on the same call.", status_code=-1) + raise RunCommandError(error_msg="You cannot use both 'input' and 'stdin' on the same call.", return_code=-1) # Prepare input data for communicate if input: @@ -85,5 +85,5 @@ async def run_command_async(cmd: Union[list, str], input:Optional[str]=None, std stdout, stderr = await proc.communicate(input=input_data) if raise_on_fail and proc.returncode != 0: - raise RunCommandError(error_msg=stderr.decode(), status_code=proc.returncode) + raise RunCommandError(error_msg=stderr.decode(), return_code=proc.returncode) return CommandResult(stdout.decode(), stderr.decode(), proc.returncode) diff --git a/wlanpi_core/utils/tests/run_command_async_tests.py b/wlanpi_core/utils/tests/run_command_async_tests.py index daa96ee..389be25 100644 --- a/wlanpi_core/utils/tests/run_command_async_tests.py +++ b/wlanpi_core/utils/tests/run_command_async_tests.py @@ -62,7 +62,7 @@ async def test_run_command_async_failure(self): cmd[0], *cmd[1:], stdin=None, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) self.assertEqual(str(context.exception), "error") - self.assertEqual(context.exception.status_code, 2) + self.assertEqual(context.exception.return_code, 2) async def test_run_command_async_failure_no_raise(self): cmd = ["ls", "-z"] @@ -94,14 +94,14 @@ async def test_run_command_async_shell_failure(self): cmd, stdin=None, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) self.assertEqual(str(context.exception), "error") - self.assertEqual(context.exception.status_code, 2) + self.assertEqual(context.exception.return_code, 2) async def test_run_command_async_input_and_stdin_error(self): cmd = ["ls", "-l"] with self.assertRaises(RunCommandError) as context: await run_command_async(cmd, input="test input", stdin=StringIO("test input")) self.assertEqual(str(context.exception), "You cannot use both 'input' and 'stdin' on the same call.") - self.assertEqual(context.exception.status_code, -1) + self.assertEqual(context.exception.return_code, -1) async def test_run_command_async_input_and_stdin_pipe_ok(self): cmd = ["ls", "-l"] From 9845c785a4103583a894920183cc413107d27f67 Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Fri, 25 Oct 2024 17:33:47 -0400 Subject: [PATCH 04/41] Fix tests --- .../utils/tests/{run_command_tests.py => test_run_command.py} | 2 +- .../{run_command_async_tests.py => test_runcommand_async.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename wlanpi_core/utils/tests/{run_command_tests.py => test_run_command.py} (99%) rename wlanpi_core/utils/tests/{run_command_async_tests.py => test_runcommand_async.py} (100%) diff --git a/wlanpi_core/utils/tests/run_command_tests.py b/wlanpi_core/utils/tests/test_run_command.py similarity index 99% rename from wlanpi_core/utils/tests/run_command_tests.py rename to wlanpi_core/utils/tests/test_run_command.py index 9446e4f..ff45ab9 100644 --- a/wlanpi_core/utils/tests/run_command_tests.py +++ b/wlanpi_core/utils/tests/test_run_command.py @@ -47,7 +47,7 @@ def test_run_command_shell_warning(self): # Test the warning message when using shell=True with self.assertLogs(level='WARNING') as cm: run_command("echo test", shell=True) - self.assertIn("Command echo test being run as a shell script", cm.stdout[0]) + self.assertIn("Command echo test being run as a shell script", cm.output[0]) def test_command_result(self): # Test the CommandResult class diff --git a/wlanpi_core/utils/tests/run_command_async_tests.py b/wlanpi_core/utils/tests/test_runcommand_async.py similarity index 100% rename from wlanpi_core/utils/tests/run_command_async_tests.py rename to wlanpi_core/utils/tests/test_runcommand_async.py From b11e18a4d6953fc44bb7e772420226aa90a49698 Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Fri, 25 Oct 2024 18:14:45 -0400 Subject: [PATCH 05/41] Improve command parsing using shlex and add docblocks --- wlanpi_core/utils/general.py | 69 +++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/wlanpi_core/utils/general.py b/wlanpi_core/utils/general.py index 9560ab0..3f9f476 100644 --- a/wlanpi_core/utils/general.py +++ b/wlanpi_core/utils/general.py @@ -1,5 +1,6 @@ import asyncio.subprocess import logging +import shlex import subprocess from asyncio.subprocess import Process from io import StringIO @@ -11,16 +12,49 @@ def run_command(cmd: Union[list, str], input:Optional[str]=None, stdin:Optional[TextIO]=None, shell=False, raise_on_fail=True) -> CommandResult: """Run a single CLI command with subprocess and returns the output""" + """ + This function executes a single CLI command using the the built-in subprocess module. + + Args: + cmd: The command to be executed. It can be a string or a list, it will be converted to the appropriate form by shlex. + If it's a string, the command will be executed with its arguments as separate words, + unless `shell=True` is specified. + input: Optional input string that will be fed to the process's stdin. + If provided and stdin=None, then this string will be used for stdin. + stdin: Optional TextIO object that will be fed to the process's stdin. + If None, then `input` or `stdin` will be used instead (if any). + shell: Whether to execute the command using a shell or not. Default is False. + If True, then the entire command string will be executed in a shell. + Otherwise, the command and its arguments are executed separately. + raise_on_fail: Whether to raise an error if the command fails or not. Default is True. + + Returns: + A CommandResult object containing the output of the command, along with a boolean indicating + whether the command was successful or not. + + Raises: + RunCommandError: If `raise_on_fail=True` and the command failed. + """ + # cannot have both input and STDIN, unless stdin is the constant for PIPE or /dev/null if input and stdin and not isinstance(stdin, int): raise RunCommandError(error_msg="You cannot use both 'input' and 'stdin' on the same call.", return_code=-1) + # Todo: explore using shlex to always split to protect against injections if shell: + # If a list was passed in shell mode, safely join using shlex to protect against injection. + if isinstance(cmd, list): + cmd: list + cmd: str = shlex.join(cmd) cmd: str logging.getLogger().warning(f"Command {cmd} being run as a shell script. This could present " f"an injection vulnerability. Consider whether you really need to do this.") else: + # If a string was passed in non-shell mode, safely split it using shlex to protect against injection. + if isinstance(cmd, str): + cmd:str + cmd:list[str] = shlex.split(cmd) cmd: list[str] with subprocess.Popen( cmd, @@ -43,7 +77,30 @@ def run_command(cmd: Union[list, str], input:Optional[str]=None, stdin:Optional[ async def run_command_async(cmd: Union[list, str], input:Optional[str]=None, stdin:Optional[TextIO]=None, shell=False, raise_on_fail=True) -> CommandResult: - """Run a single CLI command with asyncio.subprocess and returns the output""" + """Run a single CLI command with subprocess and returns the output""" + """ + This function executes a single CLI command using the the built-in subprocess module. + + Args: + cmd: The command to be executed. It can be a string or a list, it will be converted to the appropriate form by shlex. + If it's a string, the command will be executed with its arguments as separate words, + unless `shell=True` is specified. + input: Optional input string that will be fed to the process's stdin. + If provided and stdin=None, then this string will be used for stdin. + stdin: Optional TextIO object that will be fed to the process's stdin. + If None, then `input` or `stdin` will be used instead (if any). + shell: Whether to execute the command using a shell or not. Default is False. + If True, then the entire command string will be executed in a shell. + Otherwise, the command and its arguments are executed separately. + raise_on_fail: Whether to raise an error if the command fails or not. Default is True. + + Returns: + A CommandResult object containing the output of the command, along with a boolean indicating + whether the command was successful or not. + + Raises: + RunCommandError: If `raise_on_fail=True` and the command failed. + """ # cannot have both input and STDIN, unless stdin is the constant for PIPE or /dev/null if input and stdin and not isinstance(stdin, int): @@ -57,9 +114,15 @@ async def run_command_async(cmd: Union[list, str], input:Optional[str]=None, std else: input_data = None + # Todo: explore using shlex to always split to protect against injections + # asyncio.subprocess has different commands for shell and no shell. # Switch between them to keep a standard interface. if shell: + # If a list was passed in shell mode, safely join using shlex to protect against injection. + if isinstance(cmd, list): + cmd: list + cmd: str = shlex.join(cmd) cmd: str logging.getLogger().warning(f"Command {cmd} being run as a shell script. This could present " f"an injection vulnerability. Consider whether you really need to do this.") @@ -73,6 +136,10 @@ async def run_command_async(cmd: Union[list, str], input:Optional[str]=None, std proc: Process stdout, stderr = await proc.communicate(input=input_data) else: + # If a string was passed in non-shell mode, safely split it using shlex to protect against injection. + if isinstance(cmd, str): + cmd: str + cmd: list[str] = shlex.split(cmd) cmd: list[str] proc = await asyncio.subprocess.create_subprocess_exec( cmd[0], From e578a7709da7ab4b8ced74d25395a7ae6328053a Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Fri, 25 Oct 2024 20:42:54 -0400 Subject: [PATCH 06/41] Start a constants file --- wlanpi_core/constants.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 wlanpi_core/constants.py diff --git a/wlanpi_core/constants.py b/wlanpi_core/constants.py new file mode 100644 index 0000000..affae59 --- /dev/null +++ b/wlanpi_core/constants.py @@ -0,0 +1 @@ +UFW_FILE = "/usr/sbin/ufw" \ No newline at end of file From 6cecf23d513d2ead5d5c9676fa460f5ac479fc48 Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Fri, 25 Oct 2024 20:43:55 -0400 Subject: [PATCH 07/41] Add more util methods from other repos, and tests --- wlanpi_core/utils/general.py | 47 ++++++++++++ wlanpi_core/utils/network.py | 44 ++++++++++++ wlanpi_core/utils/tests/test_general.py | 95 +++++++++++++++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 wlanpi_core/utils/network.py create mode 100644 wlanpi_core/utils/tests/test_general.py diff --git a/wlanpi_core/utils/general.py b/wlanpi_core/utils/general.py index 3f9f476..e7e1d69 100644 --- a/wlanpi_core/utils/general.py +++ b/wlanpi_core/utils/general.py @@ -1,7 +1,9 @@ import asyncio.subprocess +import datetime import logging import shlex import subprocess +import time from asyncio.subprocess import Process from io import StringIO from typing import Union, Optional, TextIO @@ -154,3 +156,48 @@ async def run_command_async(cmd: Union[list, str], input:Optional[str]=None, std if raise_on_fail and proc.returncode != 0: raise RunCommandError(error_msg=stderr.decode(), return_code=proc.returncode) return CommandResult(stdout.decode(), stderr.decode(), proc.returncode) + +def get_model_info() -> dict[str, str]: + """Uses wlanpi-model cli command to get model info + Returns: + dictionary of model info + Raises: + RunCommandError: If the underlying command failed. + """ + + model_info = run_command(["wlanpi-model"]).stdout.split("\n") + split_model_info = [a.split(":", 1) for a in model_info if a.strip() != ""] + model_dict = {} + for a, b in split_model_info: + model_dict[a.strip()] = b.strip() + return model_dict + +def get_uptime() -> dict[str, str]: + """Gets the system uptime using jc and the uptime command. + Returns: + dictionary of uptime info + Raises: + RunCommandError: If the underlying command failed. + """ + cmd = "jc uptime" + return run_command(cmd.split(" ")).output_from_json() + +def get_hostname() -> str: + """Gets the system hostname using hostname command. + Returns: + The system hostname as a string + Raises: + RunCommandError: If the underlying command failed. + """ + return run_command(["hostname"]).stdout.strip("\n ") + +def get_current_unix_timestamp() -> float: + """Gets the current unix timestamp in milliseconds + Returns: + The current unix timestamp in milliseconds + """ + ms = datetime.datetime.now() + return time.mktime(ms.timetuple()) * 1000 + + + diff --git a/wlanpi_core/utils/network.py b/wlanpi_core/utils/network.py new file mode 100644 index 0000000..b30541d --- /dev/null +++ b/wlanpi_core/utils/network.py @@ -0,0 +1,44 @@ +from typing import Optional, Any + +from wlanpi_core.utils.general import run_command + +def get_default_gateways() -> dict[str, str]: + """ Finds the default gateway of each interface on the system using 'ip route show' + Returns: + a dictionary mapping interfaces to their default gateways. + Raises: + RunCommandError: If the underlying command failed. + """ + + # Execute 'ip route show' command which lists all network routes + output = run_command("ip route show").stdout.split("\n") + + gateways: dict[str, str] = {} + for line in output: + if "default via" in line: # This is the default gateway line + res = line.split("via ")[1].split(" dev ") + gateways[res[1].strip()] = res[0].strip() + return gateways + +def trace_route(target: str) -> dict[str, Any]: + # Execute 'ip route show' command which lists all network routes + output = run_command(["jc", "traceroute", target]).output_from_json() + return output + + +def get_interface_address_data(interface: Optional[str] = None) -> list[dict[str, Any]]: + cmd: list[str] = "ip -j addr show".split(" ") + if interface is not None and interface.strip() != "": + cmd.append(interface.strip()) + result = run_command(cmd).output_from_json() + return result + +def get_interface_addresses(interface: Optional[str] = None) -> dict[str, dict[str, str]]: + res = get_interface_address_data(interface=interface) + out_obj = {} + for item in res: + if item['ifname'] not in out_obj: + out_obj[item['ifname']] = {'inet': [], 'inet6': []} + for addr in item["addr_info"]: + out_obj[item['ifname']][addr['family']].append(addr['local']) + return out_obj diff --git a/wlanpi_core/utils/tests/test_general.py b/wlanpi_core/utils/tests/test_general.py new file mode 100644 index 0000000..a05634e --- /dev/null +++ b/wlanpi_core/utils/tests/test_general.py @@ -0,0 +1,95 @@ +import time +import unittest.mock # python -m unittest.mock +from unittest.mock import patch, Mock + +from wlanpi_core.models.runcommand_error import RunCommandError +from wlanpi_core.utils import general +from wlanpi_core.utils.general import get_model_info + + +class TestGeneralUtils(unittest.TestCase): + + @unittest.mock.patch('wlanpi_core.utils.general.run_command') + def test_get_hostname(self, mock_run_command): + # Mock the run_command function to return a mocked subprocess object with specific attributes + mock_subprocess = unittest.mock.Mock() + mock_subprocess.stdout = 'test_hostname\n' # Replace this with your expected hostname + mock_run_command.return_value = mock_subprocess + + result = general.get_hostname() + + # Assert that the function called run_command correctly + mock_run_command.assert_called_once_with(['hostname']) + # Assert that the hostname is returned properly + self.assertEqual(result, 'test_hostname') + + + def test_get_current_unix_timestamp(self): + # Get current Unix timestamp in milliseconds + ms = int(round(time.time() * 1000)) + + # Call function and get its result + func_ms = general.get_current_unix_timestamp() + + # The difference should be less than a second (assuming the test is not run at the exact second) + self.assertTrue(abs(func_ms - ms) < 1000, + f"The function returned {func_ms}, which differs from current Unix timestamp in milliseconds by more than 1000.") + + + @patch('wlanpi_core.utils.general.run_command') + def test_get_uptime(self, mock_run_command: Mock): + # Define the output from the 'jc uptime' command + expected_output = {"uptime": "123456", "idle_time": "7890"} + + # Configure the mock to return this output when called with 'jc uptime'. + mock_run_command.return_value.output_from_json.return_value = expected_output + + actual_output = general.get_uptime() + + self.assertEqual(actual_output, expected_output) + + + + @patch('wlanpi_core.utils.general.run_command') + def test_get_model_info(self, mock_run_command): + # Define the output of run_command that we expect to get from wlanpi-model command + mock_output = """ + Model: WLAN Pi R4 + Main board: Raspberry Pi 4 + USB Wi-Fi adapter: 3574:6211 MediaTek Inc. Wireless_Device + Bluetooth adapter: Built-in + """ + + # Set up the mock object's return value. This is where we tell it what to do when called + mock_run_command.return_value.stdout = mock_output + + expected_dict = { + "Model": "WLAN Pi R4", + "Main board": "Raspberry Pi 4", + "USB Wi-Fi adapter": "3574:6211 MediaTek Inc. Wireless_Device", + "Bluetooth adapter": "Built-in", + } + + # Call the function we are testing and store its return value in a variable + result = get_model_info() + + # Assert that the return value is what we expect it to be, i.e., equal to expected_dict + self.assertEqual(result, expected_dict) + + + @patch('wlanpi_core.utils.general.run_command') + def test_get_model_info_error(self, mock_run_command): + # Define the output of run_command that we expect to get from wlanpi-model command when error occurs + mock_output = "Error: Command not found" + + # Set up the mock object's return value. This is where we tell it what to do when called + mock_run_command.return_value.stdout = mock_output + mock_run_command.return_value.return_code = 1 + mock_run_command.side_effect = RunCommandError("Failed to run command",1) + + # Assert that the function raises a RunCommandError when there is an error running the command + with self.assertRaises(RunCommandError): + get_model_info() + +if __name__ == '__main__': + unittest.main() From 3f28a72e257296b950c0f1220d7ad119e91838c5 Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Fri, 25 Oct 2024 20:44:55 -0400 Subject: [PATCH 08/41] Refactor util api to use runcommand --- wlanpi_core/api/api_v1/endpoints/utils_api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wlanpi_core/api/api_v1/endpoints/utils_api.py b/wlanpi_core/api/api_v1/endpoints/utils_api.py index c7e6cea..b5887c2 100644 --- a/wlanpi_core/api/api_v1/endpoints/utils_api.py +++ b/wlanpi_core/api/api_v1/endpoints/utils_api.py @@ -23,7 +23,7 @@ async def reachability(): """ try: - reachability = utils_service.show_reachability() + reachability = await utils_service.show_reachability() if reachability.get("error"): return Response( @@ -78,7 +78,7 @@ async def usb_interfaces(): """ try: - result = utils_service.show_usb() + result = await utils_service.show_usb() if result.get("error"): return Response( @@ -103,7 +103,7 @@ async def usb_interfaces(): """ try: - result = utils_service.show_ufw() + result = await utils_service.show_ufw() if result.get("error"): return Response( From b5142190f84a21977b5a3345bd0c7ef350320869 Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Fri, 25 Oct 2024 20:46:15 -0400 Subject: [PATCH 09/41] Refactor subprocess usage out of utils service --- wlanpi_core/services/utils_service.py | 106 ++++++++++---------------- 1 file changed, 39 insertions(+), 67 deletions(-) diff --git a/wlanpi_core/services/utils_service.py b/wlanpi_core/services/utils_service.py index a359f09..ada1dcc 100644 --- a/wlanpi_core/services/utils_service.py +++ b/wlanpi_core/services/utils_service.py @@ -1,13 +1,13 @@ import os import re -import subprocess -from .helpers import run_command +from ..constants import UFW_FILE +from ..models.runcommand_error import RunCommandError +from ..utils.general import run_command_async +from ..utils.network import get_default_gateways -UFW_FILE = "/usr/sbin/ufw" - -def show_reachability(): +async def show_reachability(): """ Check if default gateway, internet and DNS are reachable and working """ @@ -16,97 +16,69 @@ def show_reachability(): # --- Variables --- try: - default_gateway = ( - subprocess.check_output( - "ip route | grep 'default' | grep -E -o '([0-9]{1,3}[\\.]){3}[0-9]{1,3}' | head -n1", - shell=True, - ) - .decode() - .strip() - ) - - dg_interface = ( - subprocess.check_output( - "ip route | grep 'default' | head -n1 | cut -d ' ' -f5", shell=True - ) - .decode() - .strip() - ) + dg_interface, default_gateway = list(get_default_gateways().items())[0] dns_servers = [ line.split()[1] for line in open("/etc/resolv.conf") if line.startswith("nameserver") ] - except subprocess.CalledProcessError: - return {"error": "Failed to determine network configuration"} + except RunCommandError as err: + return {"error": "Failed to determine network configuration: {}".format(err)} # --- Checks --- if not default_gateway: return {"error": "No default gateway"} + # Start executing tests in the background + ping_google_cr = run_command_async("jc ping -c1 -W2 -q google.com", raise_on_fail=False) + browse_google_result_cr = run_command_async("timeout 2 curl -s -L www.google.com", raise_on_fail=False) + ping_gateway_cr = run_command_async(f"jc ping -c1 -W2 -q {default_gateway}", raise_on_fail=False) + arping_gateway_cr = run_command_async(f"timeout 2 arping -c1 -w2 -I {dg_interface} {default_gateway}", + raise_on_fail=False) + dns_res_crs = [(i, run_command_async(f"dig +short +time=2 +tries=1 @{dns} NS google.com", raise_on_fail=False)) for i,dns in enumerate(dns_servers[:3], start=1)] + + # Ping Google - ping_google = run_command("ping -c1 -W2 -q google.com") - try: - ping_google_rtt = re.search( - r"rtt min/avg/max/mdev = \S+/(\S+)/\S+/\S+ ms", ping_google - ) - output["results"]["Ping Google"] = ( - f"{ping_google_rtt.group(1)}ms" if ping_google_rtt else None - ) - except: - output["results"]["Ping Google"] = "FAIL" + ping_google = await ping_google_cr + output["results"][ + "Ping Google"] = f"{ping_google.output_from_json()['round_trip_ms_avg']}ms" if ping_google.success else "FAIL" # Browse Google.com - browse_google = run_command( - "timeout 2 curl -s -L www.google.com | grep 'google.com'" - ) - output["results"]["Browse Google"] = "OK" if browse_google is not None else "FAIL" + browse_google_result = await browse_google_result_cr + output["results"]["Browse Google"] = "OK" if ( + browse_google_result.success and "google.com" in browse_google_result.stdout) else "FAIL" # Ping default gateway - ping_gateway = run_command(f"ping -c1 -W2 -q {default_gateway}") - try: - ping_gateway_rtt = re.search( - r"rtt min/avg/max/mdev = \S+/(\S+)/\S+/\S+ ms", ping_gateway - ) - output["results"]["Ping Gateway"] = ( - f"{ping_gateway_rtt.group(1)}ms" if ping_gateway_rtt else None - ) - except: - output["results"]["Ping Gateway"] = "FAIL" + ping_gateway = await ping_gateway_cr + output["results"][ + "Ping Gateway"] = f"{ping_gateway.output_from_json()['round_trip_ms_avg']}ms" if ping_gateway.success else "FAIL" # DNS resolution checks - for i, dns in enumerate(dns_servers[:3], start=1): - dns_res = run_command(f"dig +short +time=2 +tries=1 @{dns} NS google.com") - if dns_res: - output["results"][f"DNS Server {i} Resolution"] = "OK" + for i,cr in dns_res_crs: + dns_res = await cr + output["results"][f"DNS Server {i} Resolution"] = "OK" if dns_res.success else "FAIL" # ARPing default gateway - arping_gateway = run_command( - f"timeout 2 arping -c1 -w2 -I {dg_interface} {default_gateway} 2>/dev/null" - ) + arping_gateway = (await arping_gateway_cr).stdout arping_rtt = re.search(r"\d+ms", arping_gateway) output["results"]["Arping Gateway"] = arping_rtt.group(0) if arping_rtt else "FAIL" return output -def show_usb(): +async def show_usb(): """ Return a list of non-Linux USB interfaces found with the lsusb command """ - - lsusb = r"/usr/bin/lsusb | /bin/grep -v Linux | /usr/bin/cut -d\ -f7-" - lsusb_info = [] - interfaces = {} try: - lsusb_output = subprocess.check_output(lsusb, shell=True).decode() - lsusb_info = lsusb_output.split("\n") - except subprocess.CalledProcessError: + lsusb_output = (await run_command_async("/usr/bin/lsusb", raise_on_fail=True)).stdout.split("\n") + lsusb_info = [line.split(" ", 6)[-1].strip() for line in lsusb_output if "Linux" not in line] + except RunCommandError as err: error_descr = "Issue getting usb info using lsusb command" - interfaces["error"] = {"error": {error_descr}} + interfaces["error"] = {"error": {error_descr + ": " + err.error_msg}} return interfaces interfaces["interfaces"] = [] @@ -160,7 +132,7 @@ def parse_ufw(output): return final_output -def show_ufw(): +async def show_ufw(): """ Return a list ufw ports """ @@ -176,9 +148,9 @@ def show_ufw(): return response try: - ufw_output = subprocess.check_output( - "sudo {} status".format(ufw_file), shell=True - ).decode() + ufw_output = (await run_command_async( + "sudo {} status".format(ufw_file), raise_on_fail=True + )).stdout ufw_info = parse_ufw(ufw_output) except: From 6e8395cae8b7ec8390087ba11e6480bb48199775 Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Fri, 25 Oct 2024 20:47:00 -0400 Subject: [PATCH 10/41] Refactor subprocess usage out of network service --- wlanpi_core/services/network_service.py | 36 ++++++++++--------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/wlanpi_core/services/network_service.py b/wlanpi_core/services/network_service.py index fa5fead..67b899e 100644 --- a/wlanpi_core/services/network_service.py +++ b/wlanpi_core/services/network_service.py @@ -1,4 +1,3 @@ -import subprocess import time from datetime import datetime @@ -7,8 +6,11 @@ from dbus.exceptions import DBusException from gi.repository import GLib +from wlanpi_core.models.runcommand_error import RunCommandError +from wlanpi_core.utils.general import run_command from wlanpi_core.models.validation_error import ValidationError from wlanpi_core.schemas import network +from wlanpi_core.utils.network import get_interface_addresses # For running locally (not in API) # import asyncio @@ -90,12 +92,12 @@ def renew_dhcp(interface): """ try: # Release the current DHCP lease - subprocess.run(["sudo", "dhclient", "-r", interface], check=True) + run_command(["sudo", "dhclient", "-r", interface], raise_on_fail=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) + run_command(["sudo", "dhclient", interface], raise_on_fail=True) + except RunCommandError as err: + debug_print(f"Failed to renew DHCP. Code:{err.return_code}, Error: {err.error_msg}", 1) def get_ip_address(interface): @@ -103,23 +105,12 @@ def get_ip_address(interface): Extract the IP Address from the linux ip add show 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) + res = get_interface_addresses(interface)[interface]['inet'] + if len(res): + return res[0] return None + except RunCommandError as err: + debug_print(f"Failed to get IP address. Code:{err.return_code}, Error: {err.error_msg}", 1) def getBss(bss): @@ -557,10 +548,11 @@ async def set_systemd_network_addNetwork( # time.sleep(10) debug_print(f"Network selected with result: {selectErr}", 2) + timeout_check = 0 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 From d46ea8cba39ad0cecfcf597141f4cac525f13216 Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Fri, 25 Oct 2024 21:03:49 -0400 Subject: [PATCH 11/41] heavy-handed pass at consolidating all our global constants into constants.py --- .../api/api_v1/endpoints/network_api.py | 3 +- wlanpi_core/constants.py | 57 ++++++++++++++++++- wlanpi_core/core/config.py | 10 ++-- wlanpi_core/models/network/vlan/vlan_file.py | 4 +- wlanpi_core/services/bluetooth_service.py | 3 +- wlanpi_core/services/helpers.py | 34 ----------- wlanpi_core/services/network_info_service.py | 12 +--- wlanpi_core/services/network_service.py | 10 +--- wlanpi_core/services/system_service.py | 7 +-- wlanpi_core/services/utils_service.py | 2 +- 10 files changed, 70 insertions(+), 72 deletions(-) diff --git a/wlanpi_core/api/api_v1/endpoints/network_api.py b/wlanpi_core/api/api_v1/endpoints/network_api.py index 0e5c0d4..bde0332 100644 --- a/wlanpi_core/api/api_v1/endpoints/network_api.py +++ b/wlanpi_core/api/api_v1/endpoints/network_api.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Response +from wlanpi_core.constants import API_DEFAULT_TIMEOUT from wlanpi_core.models.network.vlan.vlan_errors import VLANError from wlanpi_core.models.validation_error import ValidationError from wlanpi_core.schemas import network @@ -12,8 +13,6 @@ router = APIRouter() -API_DEFAULT_TIMEOUT = 20 - log = logging.getLogger("uvicorn") diff --git a/wlanpi_core/constants.py b/wlanpi_core/constants.py index affae59..2a54ab0 100644 --- a/wlanpi_core/constants.py +++ b/wlanpi_core/constants.py @@ -1 +1,56 @@ -UFW_FILE = "/usr/sbin/ufw" \ No newline at end of file +# Core config +API_V1_STR: str = "/api/v1" +PROJECT_NAME: str = "wlanpi-core" +PROJECT_DESCRIPTION: str = "The wlanpi-core API provides endpoints for applications on the WLAN Pi to share data. 🚀" + +# Linux programs +IFCONFIG_FILE: str = "/sbin/ifconfig" +IW_FILE: str = "/sbin/iw" +IP_FILE: str = "/usr/sbin/ip" +UFW_FILE: str = "/usr/sbin/ufw" +ETHTOOL_FILE: str = "/sbin/ethtool" + +# Mode changer scripts +MODE_FILE: str = "/etc/wlanpi-state" + +# Version file for WLAN Pi image +WLANPI_IMAGE_FILE: str = "/etc/wlanpi-release" + +WCONSOLE_SWITCHER_FILE: str = "/opt/wlanpi-wconsole/wconsole_switcher" +HOTSPOT_SWITCHER_FILE: str = "/opt/wlanpi-hotspot/hotspot_switcher" +WIPERF_SWITCHER_FILE: str = "/opt/wlanpi-wiperf/wiperf_switcher" +SERVER_SWITCHER_FILE: str = "/opt/wlanpi-server/server_switcher" +BRIDGE_SWITCHER_FILE: str = "/opt/wlanpi-bridge/bridge_switcher" + +REG_DOMAIN_FILE: str = "/usr/bin/wlanpi-reg-domain" +TIME_ZONE_FILE: str = "/usr/bin/wlanpi-timezone" + +# WPA Supplicant dbus service and interface +WPAS_DBUS_SERVICE: str = "fi.w1.wpa_supplicant1" +WPAS_DBUS_INTERFACE: str = "fi.w1.wpa_supplicant1" +WPAS_DBUS_OPATH: str = "/fi/w1/wpa_supplicant1" +WPAS_DBUS_INTERFACES_INTERFACE: str = "fi.w1.wpa_supplicant1.Interface" +WPAS_DBUS_INTERFACES_OPATH: str = "/fi/w1/wpa_supplicant1/Interfaces" +WPAS_DBUS_BSS_INTERFACE: str = "fi.w1.wpa_supplicant1.BSS" +WPAS_DBUS_NETWORK_INTERFACE: str = "fi.w1.wpa_supplicant1.Network" + +# Core API configuration +API_DEFAULT_TIMEOUT: int = 20 + +# VLAN model constants +DEFAULT_VLAN_INTERFACE_FILE = "/etc/network/interfaces.d/vlans" +DEFAULT_INTERFACE_FILE = "/etc/network/interfaces" + +# Service Constants +BT_ADAPTER = "hci0" + +#### Paths below here are relative to script dir or /tmp fixed paths ### + +# Networkinfo data file names +LLDPNEIGH_FILE: str = "/tmp/lldpneigh.txt" +CDPNEIGH_FILE: str = "/tmp/cdpneigh.txt" +IPCONFIG_FILE: str = "/opt/wlanpi-common/networkinfo/ipconfig.sh 2>/dev/null" +REACHABILITY_FILE: str = "/opt/wlanpi-common/networkinfo/reachability.sh" +PUBLICIP_CMD: str = "/opt/wlanpi-common/networkinfo/publicip.sh" +PUBLICIP6_CMD: str = "/opt/wlanpi-common/networkinfo/publicip6.sh" +BLINKER_FILE: str = "/opt/wlanpi-common/networkinfo/portblinker.sh" diff --git a/wlanpi_core/core/config.py b/wlanpi_core/core/config.py index 7e25447..324ee0a 100644 --- a/wlanpi_core/core/config.py +++ b/wlanpi_core/core/config.py @@ -1,16 +1,14 @@ from pathlib import Path from pydantic_settings import BaseSettings - +from wlanpi_core import constants class Settings(BaseSettings): - API_V1_STR: str = "/api/v1" + API_V1_STR: str = constants.API_V1_STR - PROJECT_NAME: str = "wlanpi-core" + PROJECT_NAME: str = constants.PROJECT_NAME - PROJECT_DESCRIPTION: str = """ - The wlanpi-core API provides endpoints for applications on the WLAN Pi to share data. 🚀 - """ + PROJECT_DESCRIPTION: str = constants.PROJECT_DESCRIPTION TAGS_METADATA: list = [ { diff --git a/wlanpi_core/models/network/vlan/vlan_file.py b/wlanpi_core/models/network/vlan/vlan_file.py index c3a5523..7450903 100644 --- a/wlanpi_core/models/network/vlan/vlan_file.py +++ b/wlanpi_core/models/network/vlan/vlan_file.py @@ -1,6 +1,7 @@ from collections import defaultdict from typing import Optional, Union +from wlanpi_core.constants import DEFAULT_INTERFACE_FILE, DEFAULT_VLAN_INTERFACE_FILE from wlanpi_core.models.validation_error import ValidationError from wlanpi_core.schemas.network.config import Vlan from wlanpi_core.services.helpers import run_cli_async @@ -28,8 +29,7 @@ class VLANFile: "wvdial", "ipv4ll", ) - DEFAULT_VLAN_INTERFACE_FILE = "/etc/network/interfaces.d/vlans" - DEFAULT_INTERFACE_FILE = "/etc/network/interfaces" + def __init__( self, diff --git a/wlanpi_core/services/bluetooth_service.py b/wlanpi_core/services/bluetooth_service.py index 972d92b..c22a880 100644 --- a/wlanpi_core/services/bluetooth_service.py +++ b/wlanpi_core/services/bluetooth_service.py @@ -1,8 +1,7 @@ import re from .helpers import run_command - -BT_ADAPTER = "hci0" +from wlanpi_core.constants import BT_ADAPTER def bluetooth_present(): diff --git a/wlanpi_core/services/helpers.py b/wlanpi_core/services/helpers.py index 3cd2d97..7524f4c 100644 --- a/wlanpi_core/services/helpers.py +++ b/wlanpi_core/services/helpers.py @@ -8,40 +8,6 @@ from wlanpi_core.models.runcommand_error import RunCommandError -# Linux programs -IFCONFIG_FILE = "/sbin/ifconfig" -IW_FILE = "/sbin/iw" -IP_FILE = "/usr/sbin/ip" -UFW_FILE = "/usr/sbin/ufw" -ETHTOOL_FILE = "/sbin/ethtool" - -# Mode changer scripts -MODE_FILE = "/etc/wlanpi-state" - -# Version file for WLAN Pi image -WLANPI_IMAGE_FILE = "/etc/wlanpi-release" - -WCONSOLE_SWITCHER_FILE = "/opt/wlanpi-wconsole/wconsole_switcher" -HOTSPOT_SWITCHER_FILE = "/opt/wlanpi-hotspot/hotspot_switcher" -WIPERF_SWITCHER_FILE = "/opt/wlanpi-wiperf/wiperf_switcher" -SERVER_SWITCHER_FILE = "/opt/wlanpi-server/server_switcher" -BRIDGE_SWITCHER_FILE = "/opt/wlanpi-bridge/bridge_switcher" - -REG_DOMAIN_FILE = "/usr/bin/wlanpi-reg-domain" -TIME_ZONE_FILE = "/usr/bin/wlanpi-timezone" - -#### Paths below here are relative to script dir or /tmp fixed paths ### - -# Networkinfo data file names -LLDPNEIGH_FILE = "/tmp/lldpneigh.txt" -CDPNEIGH_FILE = "/tmp/cdpneigh.txt" -IPCONFIG_FILE = "/opt/wlanpi-common/networkinfo/ipconfig.sh 2>/dev/null" -REACHABILITY_FILE = "/opt/wlanpi-common/networkinfo/reachability.sh" -PUBLICIP_CMD = "/opt/wlanpi-common/networkinfo/publicip.sh" -PUBLICIP6_CMD = "/opt/wlanpi-common/networkinfo/publicip6.sh" -BLINKER_FILE = "/opt/wlanpi-common/networkinfo/portblinker.sh" - - async def run_cli_async(cmd: str, want_stderr: bool = False) -> str: proc = await asyncio.create_subprocess_shell( cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE diff --git a/wlanpi_core/services/network_info_service.py b/wlanpi_core/services/network_info_service.py index b06c205..a4dd83a 100644 --- a/wlanpi_core/services/network_info_service.py +++ b/wlanpi_core/services/network_info_service.py @@ -2,16 +2,8 @@ import re import subprocess -from .helpers import ( - CDPNEIGH_FILE, - ETHTOOL_FILE, - IFCONFIG_FILE, - IPCONFIG_FILE, - IW_FILE, - LLDPNEIGH_FILE, - PUBLICIP6_CMD, - PUBLICIP_CMD, -) +from wlanpi_core.constants import IFCONFIG_FILE, IW_FILE, ETHTOOL_FILE, LLDPNEIGH_FILE, CDPNEIGH_FILE, IPCONFIG_FILE, \ + PUBLICIP_CMD, PUBLICIP6_CMD def show_info(): diff --git a/wlanpi_core/services/network_service.py b/wlanpi_core/services/network_service.py index 67b899e..ee96368 100644 --- a/wlanpi_core/services/network_service.py +++ b/wlanpi_core/services/network_service.py @@ -6,6 +6,8 @@ from dbus.exceptions import DBusException from gi.repository import GLib +from wlanpi_core.constants import WPAS_DBUS_SERVICE, WPAS_DBUS_INTERFACE, WPAS_DBUS_OPATH, \ + WPAS_DBUS_INTERFACES_INTERFACE, WPAS_DBUS_BSS_INTERFACE from wlanpi_core.models.runcommand_error import RunCommandError from wlanpi_core.utils.general import run_command from wlanpi_core.models.validation_error import ValidationError @@ -15,14 +17,6 @@ # 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 diff --git a/wlanpi_core/services/system_service.py b/wlanpi_core/services/system_service.py index 1889723..e1ba590 100644 --- a/wlanpi_core/services/system_service.py +++ b/wlanpi_core/services/system_service.py @@ -6,6 +6,7 @@ from dbus import Interface, SystemBus from dbus.exceptions import DBusException +from wlanpi_core.constants import MODE_FILE, WLANPI_IMAGE_FILE from wlanpi_core.models.validation_error import ValidationError bus = SystemBus() @@ -41,12 +42,6 @@ PLATFORM_UNKNOWN = "Unknown" -# Mode changer scripts -MODE_FILE = "/etc/wlanpi-state" - -# Version file for WLAN Pi image -WLANPI_IMAGE_FILE = "/etc/wlanpi-release" - def get_mode(): valid_modes = ["classic", "wconsole", "hotspot", "wiperf", "server", "bridge"] diff --git a/wlanpi_core/services/utils_service.py b/wlanpi_core/services/utils_service.py index ada1dcc..a9b13f3 100644 --- a/wlanpi_core/services/utils_service.py +++ b/wlanpi_core/services/utils_service.py @@ -1,7 +1,7 @@ import os import re -from ..constants import UFW_FILE +from wlanpi_core.constants import UFW_FILE from ..models.runcommand_error import RunCommandError from ..utils.general import run_command_async from ..utils.network import get_default_gateways From 1f08bd5f27979ecf80a8c5309425350dd8dedd98 Mon Sep 17 00:00:00 2001 From: jsz Date: Sun, 27 Oct 2024 19:01:46 -0400 Subject: [PATCH 12/41] API port hardening release v1.0.5-1 (#52) * add error handling when removing iptables rule (#51) --- debian/changelog | 4 ++-- debian/postinst | 18 +++++++++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/debian/changelog b/debian/changelog index 19ce422..c1f81bb 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,8 +1,8 @@ -wlanpi-core (1.0.5) unstable; urgency=high +wlanpi-core (1.0.5-1) unstable; urgency=high * API hardening by blocking iptables rule added by pi-gen - -- Josh Schmelzle Sun, 27 Oct 2024 15:26:42 +0000 + -- Josh Schmelzle Sun, 27 Oct 2024 14:55:42 -0400 wlanpi-core (1.0.4) unstable; urgency=medium diff --git a/debian/postinst b/debian/postinst index 65e9a25..cc71022 100755 --- a/debian/postinst +++ b/debian/postinst @@ -46,8 +46,20 @@ if [ $CONF_CHANGED -eq 0 ]; then fi # Remove specific iptables rules added during pi-gen versions prior to v3.2.3 -echo "Removing old pi-gen iptables rules in WLAN Pi OS v3.2.2 and earlier" -iptables -D ufw-user-input -p tcp --dport 31415 -j ACCEPT -iptables -D ufw-user-input -p udp --dport 31415 -j ACCEPT +echo "Attempting to remove old pi-gen iptables rules in WLAN Pi OS v3.2.2 and earlier" + +if iptables -C ufw-user-input -p tcp --dport 31415 -j ACCEPT 2>/dev/null; then + iptables -D ufw-user-input -p tcp --dport 31415 -j ACCEPT + echo "tcp 31415 rule removed." +else + echo "tcp 31415 rule not found; no changes made." +fi + +if iptables -C ufw-user-input -p udp --dport 31415 -j ACCEPT 2>/dev/null; then + iptables -D ufw-user-input -p udp --dport 31415 -j ACCEPT + echo "udp 31415 rule removed successfully." +else + echo "udp 31415 rule not found; no changes made." +fi exit 0 From 7232d0c3e4dae2ef94ae50da2d45e48f8b1ccaf2 Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Mon, 28 Oct 2024 19:00:14 -0400 Subject: [PATCH 13/41] Convert bluetooth_service.py --- wlanpi_core/services/bluetooth_service.py | 44 +++++++++++++++-------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/wlanpi_core/services/bluetooth_service.py b/wlanpi_core/services/bluetooth_service.py index c22a880..9603f98 100644 --- a/wlanpi_core/services/bluetooth_service.py +++ b/wlanpi_core/services/bluetooth_service.py @@ -1,6 +1,6 @@ import re -from .helpers import run_command +from wlanpi_core.utils.general import run_command from wlanpi_core.constants import BT_ADAPTER @@ -8,30 +8,36 @@ def bluetooth_present(): """ We want to use hciconfig here as it works OK when no devices are present """ - return run_command(f"hciconfig | grep {BT_ADAPTER}") + cmd = f"hciconfig" + filtered = [x for x in run_command(cmd=cmd, raise_on_fail=True).stdout.split("\n") if BT_ADAPTER in x] + return filtered[0].strip() if filtered else "" def bluetooth_name(): - cmd = f"bt-adapter -a {BT_ADAPTER} -i" + "| grep Name | awk '{ print $2 }'" - return run_command(cmd) + cmd = f"bt-adapter -a {BT_ADAPTER} -i" + filtered = [x for x in run_command(cmd=cmd, raise_on_fail=True).stdout.split("\n") if "Name" in x] + return filtered[0].strip().split(" ")[1] if filtered else "" def bluetooth_alias(): - cmd = f"bt-adapter -a {BT_ADAPTER} -i" + "| grep Alias | awk '{ print $2 }'" - return run_command(cmd) + cmd = f"bt-adapter -a {BT_ADAPTER} -i" + filtered = [x for x in run_command(cmd=cmd, raise_on_fail=True).stdout.split("\n") if "Alias" in x] + return filtered[0].strip().split(" ")[1] if filtered else "" def bluetooth_address(): - cmd = f"bt-adapter -a {BT_ADAPTER} -i" + "| grep Address | awk '{ print $2 }'" - return run_command(cmd) + cmd = f"bt-adapter -a {BT_ADAPTER} -i" + filtered = [x for x in run_command(cmd=cmd, raise_on_fail=True).stdout.split("\n") if "Address" in x] + return filtered[0].strip().split(" ")[1] if filtered else "" def bluetooth_power(): """ We want to use hciconfig here as it works OK when no devices are present """ - cmd = f"hciconfig {BT_ADAPTER} | grep -E '^\s+UP'" - return run_command(cmd) + cmd = f"hciconfig {BT_ADAPTER} " + filtered = [ x for x in run_command(cmd=cmd, raise_on_fail=True).stdout.split("\n") if re.match(r"^\s+UP", x)] + return filtered[0].strip() if filtered else "" def bluetooth_set_power(power): @@ -40,12 +46,16 @@ def bluetooth_set_power(power): if power: if bluetooth_is_on: return True - cmd = f"bt-adapter -a {BT_ADAPTER} --set Powered 1 && echo 1 > /etc/wlanpi-bluetooth/state" + cmd = f"bt-adapter -a {BT_ADAPTER} --set Powered 1" + bt_state = 1 else: if not bluetooth_is_on: return True - cmd = f"bt-adapter -a {BT_ADAPTER} --set Powered 0 && echo 0 > /etc/wlanpi-bluetooth/state" - result = run_command(cmd) + cmd = f"bt-adapter -a {BT_ADAPTER} --set Powered 0" + bt_state = 0 + result = run_command(cmd, shell=True, raise_on_fail=True).stdout + with open('/etc/wlanpi-bluetooth/state', 'w') as bt_state_file: + bt_state_file.write(str(bt_state)) if result: return True @@ -57,12 +67,16 @@ def bluetooth_paired_devices(): """ Returns a dictionary of paired devices, indexed by MAC address """ - if not bluetooth_present(): return None cmd = "bluetoothctl -- paired-devices | grep -iv 'no default controller'" - output = run_command(cmd) + + output = "\n".join([ + x for x in run_command(cmd=cmd, raise_on_fail=True).stdout.split("\n") + if not re.match(r"^no default controller", x, re.I) + ]) + if len(output) > 0: output = re.sub("Device *", "", output).split("\n") return dict([line.split(" ", 1) for line in output]) From d0f07563ab142ef33adf215483df3d10ee2eab65 Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Mon, 28 Oct 2024 19:05:02 -0400 Subject: [PATCH 14/41] Complete refactoring out of helpers.py --- wlanpi_core/models/network/vlan/vlan_file.py | 8 ++--- wlanpi_core/services/helpers.py | 38 -------------------- 2 files changed, 4 insertions(+), 42 deletions(-) delete mode 100644 wlanpi_core/services/helpers.py diff --git a/wlanpi_core/models/network/vlan/vlan_file.py b/wlanpi_core/models/network/vlan/vlan_file.py index 7450903..a2bfffe 100644 --- a/wlanpi_core/models/network/vlan/vlan_file.py +++ b/wlanpi_core/models/network/vlan/vlan_file.py @@ -4,7 +4,7 @@ from wlanpi_core.constants import DEFAULT_INTERFACE_FILE, DEFAULT_VLAN_INTERFACE_FILE from wlanpi_core.models.validation_error import ValidationError from wlanpi_core.schemas.network.config import Vlan -from wlanpi_core.services.helpers import run_cli_async +from wlanpi_core.utils.general import run_command_async class VLANFile: @@ -173,9 +173,9 @@ def generate_if_config_from_object(configuration: Vlan) -> str: @staticmethod async def check_interface_exists(interface: str) -> bool: - ethernet_interfaces = ( - await run_cli_async("ls /sys/class/net/ | grep eth") - ).split("\n") + ethernet_interfaces = [x for x in ( + await run_command_async("ls /sys/class/net/", raise_on_fail=True) + ).stdout.split("\n") if "eth" in x] ethernet_interfaces = set([i.split(".")[0] for i in ethernet_interfaces if i]) return interface in ethernet_interfaces diff --git a/wlanpi_core/services/helpers.py b/wlanpi_core/services/helpers.py deleted file mode 100644 index 7524f4c..0000000 --- a/wlanpi_core/services/helpers.py +++ /dev/null @@ -1,38 +0,0 @@ -""" -shared resources between services -""" - -import asyncio -import subprocess -from typing import Union - -from wlanpi_core.models.runcommand_error import RunCommandError - -async def run_cli_async(cmd: str, want_stderr: bool = False) -> str: - proc = await asyncio.create_subprocess_shell( - cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE - ) - - stdout, stderr = await proc.communicate() - - if proc.returncode == 0: - if stdout: - return stdout.decode() - if stderr and want_stderr: - return stderr.decode() - - if stderr: - raise RunCommandError( - return_code=424, error_msg=f"'{cmd}' gave stderr response" - ) - - -def run_command(cmd) -> Union[str, None]: - """ - Runs the given command, and handles errors and formatting. - """ - try: - output = subprocess.check_output(cmd, shell=True, stderr=subprocess.DEVNULL) - return output.decode().strip() - except subprocess.CalledProcessError: - return None From 5c76ac2c99446963c12d0397dbbdd98b52a60792 Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Mon, 28 Oct 2024 20:00:16 -0400 Subject: [PATCH 15/41] Completed refactoring of subprocess usage --- .../api/api_v1/endpoints/system_api.py | 3 +- wlanpi_core/models/command_result.py | 17 +++++ wlanpi_core/services/bluetooth_service.py | 21 +++--- wlanpi_core/services/network_info_service.py | 70 ++++++++----------- wlanpi_core/services/system_service.py | 30 ++++---- 5 files changed, 71 insertions(+), 70 deletions(-) diff --git a/wlanpi_core/api/api_v1/endpoints/system_api.py b/wlanpi_core/api/api_v1/endpoints/system_api.py index cb7c7e8..46716e5 100644 --- a/wlanpi_core/api/api_v1/endpoints/system_api.py +++ b/wlanpi_core/api/api_v1/endpoints/system_api.py @@ -6,6 +6,7 @@ from wlanpi_core.models.validation_error import ValidationError from wlanpi_core.schemas import system from wlanpi_core.services import system_service +from wlanpi_core.utils.general import run_command router = APIRouter() @@ -77,7 +78,7 @@ async def show_device_model(): # get output of wlanpi-model model_cmd = "wlanpi-model -b" try: - platform = subprocess.check_output(model_cmd, shell=True).decode().strip() + platform = run_command(model_cmd).stdout.strip() if platform.endswith("?"): platform = "Unknown" diff --git a/wlanpi_core/models/command_result.py b/wlanpi_core/models/command_result.py index 1bdeb84..ec2db95 100644 --- a/wlanpi_core/models/command_result.py +++ b/wlanpi_core/models/command_result.py @@ -1,5 +1,7 @@ import json +import re from json import JSONDecodeError +from re import RegexFlag from typing import Union @@ -17,3 +19,18 @@ def output_from_json(self) -> Union[dict, list, int, float, str, None]: return json.loads(self.stdout) except JSONDecodeError: return None + + + def grep_stdout_for_string(self, string:str, negate:bool=False, split:bool=False) -> Union[str, list[str]]: + if negate: + filtered = list(filter(lambda x: string not in x, self.stdout.split("\n"))) + else: + filtered = list(filter(lambda x: string in x, self.stdout.split("\n"))) + return filtered if split else "\n".join(filtered) + + def grep_stdout_for_pattern(self, pattern: Union[re.Pattern[str], str], flags: Union[int, RegexFlag] = 0, negate:bool=False, split:bool=False) -> Union[str, list[str]]: + if negate: + filtered = list(filter(lambda x: not re.match(pattern, x, flags=flags), self.stdout.split("\n"))) + else: + filtered = list(filter(lambda x: re.match(pattern, x, flags=flags), self.stdout.split("\n"))) + return filtered if split else "\n".join(filtered) \ No newline at end of file diff --git a/wlanpi_core/services/bluetooth_service.py b/wlanpi_core/services/bluetooth_service.py index 9603f98..5a0c611 100644 --- a/wlanpi_core/services/bluetooth_service.py +++ b/wlanpi_core/services/bluetooth_service.py @@ -9,25 +9,25 @@ def bluetooth_present(): We want to use hciconfig here as it works OK when no devices are present """ cmd = f"hciconfig" - filtered = [x for x in run_command(cmd=cmd, raise_on_fail=True).stdout.split("\n") if BT_ADAPTER in x] - return filtered[0].strip() if filtered else "" + filtered = run_command(cmd=cmd, raise_on_fail=True).grep_stdout_for_string(BT_ADAPTER,) + return filtered.strip() if filtered else "" def bluetooth_name(): cmd = f"bt-adapter -a {BT_ADAPTER} -i" - filtered = [x for x in run_command(cmd=cmd, raise_on_fail=True).stdout.split("\n") if "Name" in x] + filtered = run_command(cmd=cmd, raise_on_fail=True).grep_stdout_for_string("Name", split=True) return filtered[0].strip().split(" ")[1] if filtered else "" def bluetooth_alias(): cmd = f"bt-adapter -a {BT_ADAPTER} -i" - filtered = [x for x in run_command(cmd=cmd, raise_on_fail=True).stdout.split("\n") if "Alias" in x] + filtered = run_command(cmd=cmd, raise_on_fail=True).grep_stdout_for_string("Alias", split=True) return filtered[0].strip().split(" ")[1] if filtered else "" def bluetooth_address(): cmd = f"bt-adapter -a {BT_ADAPTER} -i" - filtered = [x for x in run_command(cmd=cmd, raise_on_fail=True).stdout.split("\n") if "Address" in x] + filtered = run_command(cmd=cmd, raise_on_fail=True).grep_stdout_for_string("Address", split=True) return filtered[0].strip().split(" ")[1] if filtered else "" @@ -36,7 +36,7 @@ def bluetooth_power(): We want to use hciconfig here as it works OK when no devices are present """ cmd = f"hciconfig {BT_ADAPTER} " - filtered = [ x for x in run_command(cmd=cmd, raise_on_fail=True).stdout.split("\n") if re.match(r"^\s+UP", x)] + filtered = run_command(cmd=cmd, raise_on_fail=True).grep_stdout_for_pattern(r"^\s+UP", split=True) return filtered[0].strip() if filtered else "" @@ -70,13 +70,8 @@ def bluetooth_paired_devices(): if not bluetooth_present(): return None - cmd = "bluetoothctl -- paired-devices | grep -iv 'no default controller'" - - output = "\n".join([ - x for x in run_command(cmd=cmd, raise_on_fail=True).stdout.split("\n") - if not re.match(r"^no default controller", x, re.I) - ]) - + cmd = "bluetoothctl -- paired-devices" + output = run_command(cmd=cmd, raise_on_fail=True).grep_stdout_for_pattern(r"no default controller", flags=re.I, negate=True, split=False) if len(output) > 0: output = re.sub("Device *", "", output).split("\n") return dict([line.split(" ", 1) for line in output]) diff --git a/wlanpi_core/services/network_info_service.py b/wlanpi_core/services/network_info_service.py index a4dd83a..e065e2a 100644 --- a/wlanpi_core/services/network_info_service.py +++ b/wlanpi_core/services/network_info_service.py @@ -2,9 +2,12 @@ import re import subprocess +from wlanpi_core.models.runcommand_error import RunCommandError +from wlanpi_core.utils.general import run_command, run_command_async from wlanpi_core.constants import IFCONFIG_FILE, IW_FILE, ETHTOOL_FILE, LLDPNEIGH_FILE, CDPNEIGH_FILE, IPCONFIG_FILE, \ PUBLICIP_CMD, PUBLICIP6_CMD +# TODO: There is a TON of "except Exception" clauses in here that need worked on. These are generally EVIL and mask real errors that need dealt with. def show_info(): output = {} @@ -31,9 +34,7 @@ def show_interfaces(): interfaces = {} try: - ifconfig_info = subprocess.check_output( - f"{ifconfig_file} -a", shell=True - ).decode() + ifconfig_info = run_command(f"{ifconfig_file} -a", raise_on_fail=True).stdout except Exception as ex: interfaces["error"] = "ifconfig error" + str(ex) return interfaces @@ -70,9 +71,7 @@ def show_interfaces(): if re.search(r"(wlan\d+)|(mon\d+)", interface_name, re.MULTILINE): # fire up 'iw' for this interface (hmmm..is this a bit of an un-necessary ovehead?) try: - iw_info = subprocess.check_output( - "{} {} info".format(iw_file, interface_name), shell=True - ).decode() + iw_info = run_command("{} {} info".format(iw_file, interface_name), raise_on_fail=True).stdout if re.search("type monitor", iw_info, re.MULTILINE): ip_address = "Monitor" @@ -113,15 +112,8 @@ def show_wlan_interfaces(): output = {} try: - interfaces = ( - subprocess.check_output( - f"{IW_FILE} dev 2>&1 | grep -i interface" + "| awk '{ print $2 }'", - shell=True, - ) - .decode() - .strip() - .split() - ) + interfaces = run_command(f"{IW_FILE} dev 2>&1", shell=True).grep_stdout_for_pattern(r"interface", flags=re.I,split=True) + interfaces = map(lambda x: x.strip().split(" ")[1], interfaces) except Exception as e: print(e) @@ -130,11 +122,7 @@ def show_wlan_interfaces(): # Driver try: - ethtool_output = ( - subprocess.check_output(f"{ETHTOOL_FILE} -i {interface}", shell=True) - .decode() - .strip() - ) + ethtool_output = run_command(f"{ETHTOOL_FILE} -i {interface}").stdout.strip() driver = re.search(".*driver:\s+(.*)", ethtool_output).group(1) output[interface]["driver"] = driver except Exception: @@ -142,12 +130,7 @@ def show_wlan_interfaces(): # Addr, SSID, Mode, Channel try: - iw_output = ( - subprocess.check_output(f"{IW_FILE} {interface} info", shell=True) - .decode() - .strip() - ) - + iw_output = run_command(f"{IW_FILE} {interface} info").stdout.strip() # Addr try: addr = ( @@ -200,11 +183,12 @@ def show_eth0_ipconfig(): eth0_ipconfig_info = {} try: - ipconfig_output = ( - subprocess.check_output(ipconfig_file, shell=True).decode().strip() - ) - ipconfig_info = ipconfig_output.split("\n") + # Currently, ipconfig_file is a constant with a shell redirect in it, so need shell=True until it can be refactored + ipconfig_info = run_command(ipconfig_file, shell=True).stdout.strip().split("\n") + except RunCommandError as exc: + eth0_ipconfig_info["error"] = f"Issue getting ipconfig ({exc.return_code}): {exc.error_msg}" + return eth0_ipconfig_info except subprocess.CalledProcessError as exc: output = exc.output.decode() eth0_ipconfig_info["error"] = "Issue getting ipconfig" + str(output) @@ -240,8 +224,8 @@ def show_vlan(): if os.path.exists(lldpneigh_file): try: - vlan_output = subprocess.check_output(vlan_cmd, shell=True).decode() - for line in vlan_output.split("\n"): + vlan_output = run_command(vlan_cmd, shell=True).stdout.strip().split("\n") + for line in vlan_output: vlan_info["info"].append(line) if len(vlan_info) == 0: @@ -264,12 +248,13 @@ def show_lldp_neighbour(): if os.path.exists(lldpneigh_file): try: - neighbour_output = subprocess.check_output( - neighbour_cmd, shell=True - ).decode() - for line in neighbour_output.split("\n"): + neighbour_output = run_command(neighbour_cmd).stdout.strip().split("\n") + for line in neighbour_output: neighbour_info["info"].append(line) + except RunCommandError as exc: + neighbour_info["error"] = f"Issue getting LLDP neighbour ({exc.return_code}): {exc.error_msg}" + return neighbour_info except subprocess.CalledProcessError as exc: neighbour_info["error"] = "Issue getting LLDP neighbour" return neighbour_info @@ -291,12 +276,13 @@ def show_cdp_neighbour(): if os.path.exists(cdpneigh_file): try: - neighbour_output = subprocess.check_output( - neighbour_cmd, shell=True - ).decode() - for line in neighbour_output.split("\n"): + neighbour_output = run_command(neighbour_cmd).stdout.strip().split("\n") + for line in neighbour_output: neighbour_info["info"].append(line) + except RunCommandError as exc: + neighbour_info["error"] = f"Issue getting CDP neighbour ({exc.return_code}): {exc.error_msg}" + return neighbour_info except subprocess.CalledProcessError as exc: neighbour_info["error"] = "Issue getting CDP neighbour" return neighbour_info @@ -316,8 +302,8 @@ def show_publicip(ip_version=4): cmd = PUBLICIP6_CMD if ip_version == 6 else PUBLICIP_CMD try: - publicip_output = subprocess.check_output(cmd, shell=True).decode().strip() - for line in publicip_output.split("\n"): + publicip_output = run_command(cmd).stdout.strip().split("\n") + for line in publicip_output: publicip_info["info"].append(line) except subprocess.CalledProcessError: publicip_info["error"] = "Failed to detect public IP address" diff --git a/wlanpi_core/services/system_service.py b/wlanpi_core/services/system_service.py index e1ba590..f0cf7d2 100644 --- a/wlanpi_core/services/system_service.py +++ b/wlanpi_core/services/system_service.py @@ -1,4 +1,5 @@ import json +import logging import os import socket import subprocess @@ -7,7 +8,9 @@ from dbus.exceptions import DBusException from wlanpi_core.constants import MODE_FILE, WLANPI_IMAGE_FILE +from wlanpi_core.models.runcommand_error import RunCommandError from wlanpi_core.models.validation_error import ValidationError +from wlanpi_core.utils.general import run_command bus = SystemBus() systemd = bus.get_object("org.freedesktop.systemd1", "/org/freedesktop/systemd1") @@ -87,17 +90,12 @@ def get_image_ver(): def get_hostname(): try: - hostname = ( - subprocess.check_output("/usr/bin/hostname", shell=True).decode().strip() - ) + + hostname = run_command("/usr/bin/hostname").stdout.strip() if not "." in hostname: domain = "local" try: - output = ( - subprocess.check_output("/usr/bin/hostname -d", shell=True) - .decode() - .strip() - ) + output = run_command("/usr/bin/hostname -d").stdout.strip() if len(output) != 0: domain = output except: @@ -129,7 +127,11 @@ def get_platform(): # get output of wlanpi-model model_cmd = "wlanpi-model -b" try: - platform = subprocess.check_output(model_cmd, shell=True).decode().strip() + platform = run_command(model_cmd).stdout.strip() + + except RunCommandError as exc: + logging.warning(f"Issue getting wlanpi model ({exc.return_code}): {exc.error_msg}") + return "Unknown" except subprocess.CalledProcessError as exc: exc.model.decode() # print("Err: issue running 'wlanpi-model -b' : ", model) @@ -158,9 +160,9 @@ def get_stats(): # determine CPU load # cmd = "top -bn1 | grep load | awk '{printf \"%.2f%%\", $(NF-2)}'" - cmd = "mpstat 1 1 -o JSON | grep idle" + cmd = "mpstat 1 1 -o JSON" try: - CPU_JSON = subprocess.check_output(cmd, shell=True).decode() + CPU_JSON = run_command(cmd).grep_stdout_for_string('idle') CPU_IDLE = json.loads(CPU_JSON)["idle"] CPU = "{0:.2f}%".format(100 - CPU_IDLE) if CPU_IDLE == 100: @@ -173,14 +175,14 @@ def get_stats(): # determine mem useage cmd = "free -m | awk 'NR==2{printf \"%s/%sMB %.2f%%\", $3,$2,$3*100/$2 }'" try: - MemUsage = subprocess.check_output(cmd, shell=True).decode() + MemUsage = run_command(cmd, shell=True).stdout.strip() except Exception: MemUsage = "unknown" # determine disk util cmd = 'df -h | awk \'$NF=="/"{printf "%d/%dGB %s", $3,$2,$5}\'' try: - Disk = subprocess.check_output(cmd, shell=True).decode() + Disk = run_command(cmd, shell=True).stdout.strip() except Exception: Disk = "unknown" @@ -197,7 +199,7 @@ def get_stats(): # determine uptime cmd = "uptime -p | sed -r 's/up|,//g' | sed -r 's/\s*week[s]?/w/g' | sed -r 's/\s*day[s]?/d/g' | sed -r 's/\s*hour[s]?/h/g' | sed -r 's/\s*minute[s]?/m/g'" try: - uptime = subprocess.check_output(cmd, shell=True).decode().strip() + uptime = run_command(cmd, shell=True).stdout.strip() except Exception: uptime = "unknown" From fb57c2af501c95fc625524b13196453ff0f41edd Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Tue, 29 Oct 2024 14:01:37 -0400 Subject: [PATCH 16/41] Refactor unittests to pytest --- testing.in | 1 + testing.txt | 8 + wlanpi_core/utils/tests/test_run_command.py | 112 ++++----- .../utils/tests/test_runcommand_async.py | 234 ++++++++++-------- 4 files changed, 192 insertions(+), 163 deletions(-) diff --git a/testing.in b/testing.in index 41764c1..fc0c787 100644 --- a/testing.in +++ b/testing.in @@ -5,6 +5,7 @@ autoflake mypy flake8 pytest +pytest-asyncio pytest-cov coverage-badge pytest-mock \ No newline at end of file diff --git a/testing.txt b/testing.txt index 7a3b24b..57b71c2 100644 --- a/testing.txt +++ b/testing.txt @@ -4,6 +4,8 @@ # # pip-compile testing.in # +--extra-index-url https://www.piwheels.org/simple + autoflake==2.3.1 # via -r testing.in black==24.8.0 @@ -72,8 +74,11 @@ pyproject-api==1.8.0 pytest==8.3.3 # via # -r testing.in + # pytest-asyncio # pytest-cov # pytest-mock +pytest-asyncio==0.24.0 + # via -r testing.in pytest-cov==5.0.0 # via -r testing.in pytest-mock==3.14.0 @@ -95,3 +100,6 @@ typing-extensions==4.7.1 # mypy virtualenv==20.26.6 # via tox + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/wlanpi_core/utils/tests/test_run_command.py b/wlanpi_core/utils/tests/test_run_command.py index ff45ab9..69ead24 100644 --- a/wlanpi_core/utils/tests/test_run_command.py +++ b/wlanpi_core/utils/tests/test_run_command.py @@ -1,61 +1,57 @@ -import unittest -from unittest.mock import patch from io import StringIO +from unittest.mock import patch + +import pytest + from wlanpi_core.utils.general import run_command, RunCommandError, CommandResult -class TestRunCommand(unittest.TestCase): - - def test_run_command_success(self): - # Test a successful command execution - result = run_command(["echo", "test"]) - self.assertEqual(result.stdout, "test\n") - self.assertEqual(result.stderr, "") - self.assertEqual(result.return_code, 0) - - def test_run_command_failure(self): - # Test a failing command execution with raise_on_fail=True - with self.assertRaises(RunCommandError) as context: - run_command(["ls", "nonexistent_file"]) - self.assertIn("No such file or directory", str(context.exception)) - - def test_run_command_failure_no_raise(self): - # Test a failing command execution with raise_on_fail=False - result = run_command(["false"], raise_on_fail=False) - self.assertEqual(result.return_code, 1) - - def test_run_command_input(self): - # Test providing input to the command - result = run_command(["cat"], input="test input") - self.assertEqual(result.stdout, "test input") - - @patch('subprocess.run') - def test_run_command_stdin(self, mock_run): - # Test providing stdin to the command - mock_run.return_value.stdout = "test input" - mock_run.return_value.stderr = "" - mock_run.return_value.return_code = 0 - result = run_command(["cat"], stdin=StringIO("test input")) - self.assertEqual(result.stdout, "test input") - - def test_run_command_input_and_stdin_error(self): - # Test raising an error when both input and stdin are provided - with self.assertRaises(RunCommandError) as context: - run_command(["echo"], input="test", stdin=StringIO("test")) - self.assertIn("You cannot use both 'input' and 'stdin'", str(context.exception)) - - def test_run_command_shell_warning(self): - # Test the warning message when using shell=True - with self.assertLogs(level='WARNING') as cm: - run_command("echo test", shell=True) - self.assertIn("Command echo test being run as a shell script", cm.output[0]) - - def test_command_result(self): - # Test the CommandResult class - result = CommandResult("output", "error", 0) - self.assertEqual(result.stdout, "output") - self.assertEqual(result.stderr, "error") - self.assertEqual(result.return_code, 0) - - -if __name__ == '__main__': - unittest.main() +def test_run_command_success(): + # Test a successful command execution + result = run_command(["echo", "test"]) + assert result.stdout == "test\n" + assert result.stderr == "" + assert result.return_code == 0 + +def test_run_command_failure(): + # Test a failing command execution with raise_on_fail=True + with pytest.raises(RunCommandError) as context: + run_command(["ls", "nonexistent_file"]) + assert "No such file or directory" in str(context.value) + +def test_run_command_failure_no_raise(): + # Test a failing command execution with raise_on_fail=False + result = run_command(["false"], raise_on_fail=False) + assert result.return_code == 1 + +def test_run_command_input(): + # Test providing input to the command + result = run_command(["cat"], input="test input") + assert result.stdout == "test input" + +@patch('subprocess.run') +def test_run_command_stdin(mock_run): + # Test providing stdin to the command + mock_run.return_value.stdout = "test input" + mock_run.return_value.stderr = "" + mock_run.return_value.return_code = 0 + result = run_command(["cat"], stdin=StringIO("test input")) + assert result.stdout == "test input" + +def test_run_command_input_and_stdin_error(): + # Test raising an error when both input and stdin are provided + with pytest.raises(RunCommandError) as context: + run_command(["echo"], input="test", stdin=StringIO("test")) + assert "You cannot use both 'input' and 'stdin'" in str(context.value) + +def test_run_command_shell_warning(caplog): + # Test the warning message when using shell=True + with caplog.at_level("WARNING"): + run_command("echo test", shell=True) + assert "Command echo test being run as a shell script" in caplog.text + +def test_command_result(): + # Test the CommandResult class + result = CommandResult("output", "error", 0) + assert result.stdout == "output" + assert result.stderr == "error" + assert result.return_code == 0 diff --git a/wlanpi_core/utils/tests/test_runcommand_async.py b/wlanpi_core/utils/tests/test_runcommand_async.py index 389be25..06e1530 100644 --- a/wlanpi_core/utils/tests/test_runcommand_async.py +++ b/wlanpi_core/utils/tests/test_runcommand_async.py @@ -1,11 +1,11 @@ import asyncio from io import StringIO -import asyncio -import unittest -from unittest import IsolatedAsyncioTestCase -from unittest.mock import AsyncMock, MagicMock, patch +import pytest +from unittest.mock import AsyncMock, patch + from wlanpi_core.utils.general import run_command_async, CommandResult, RunCommandError + class MockProcess: def __init__(self, returncode=0, stdout="success", stderr=""): self.returncode = returncode @@ -15,104 +15,128 @@ def __init__(self, returncode=0, stdout="success", stderr=""): async def communicate(self, input=None): return self.stdout, self.stderr -class TestRunCommandAsync(IsolatedAsyncioTestCase): - - async def test_run_command_async_success(self): - cmd = ["ls", "-l"] - with patch('asyncio.subprocess.create_subprocess_exec', new=AsyncMock(return_value=MockProcess())) as mock_create_subprocess_exec: - result = await run_command_async(cmd) - mock_create_subprocess_exec.assert_called_once_with( - cmd[0], *cmd[1:], stdin=None, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE - ) - self.assertEqual(result.stdout, "success") - self.assertEqual(result.stderr, "") - self.assertEqual(result.return_code, 0) - - async def test_run_command_async_success_with_input(self): - cmd = ["cat"] - test_input = "test input" - with patch('asyncio.subprocess.create_subprocess_exec', new=AsyncMock(return_value=MockProcess(stdout=test_input))) as mock_create_subprocess_exec: - result = await run_command_async(cmd, input=test_input) - mock_create_subprocess_exec.assert_called_once_with( - cmd[0], *cmd[1:], stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE - ) - self.assertEqual(result.stdout, test_input) - self.assertEqual(result.stderr, "") - self.assertEqual(result.return_code, 0) - - async def test_run_command_async_success_with_stdin(self): - cmd = ["cat"] - test_input = "test input" - stdin = StringIO(test_input) - with patch('asyncio.subprocess.create_subprocess_exec', new=AsyncMock(return_value=MockProcess(stdout=test_input))) as mock_create_subprocess_exec: - result = await run_command_async(cmd, stdin=stdin) - mock_create_subprocess_exec.assert_called_once_with( - cmd[0], *cmd[1:], stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE - ) - self.assertEqual(result.stdout, test_input) - self.assertEqual(result.stderr, "") - self.assertEqual(result.return_code, 0) - - async def test_run_command_async_failure(self): - cmd = ["ls", "-z"] - with patch('asyncio.subprocess.create_subprocess_exec', new=AsyncMock(return_value=MockProcess(returncode=2, stderr="error"))) as mock_create_subprocess_exec: - with self.assertRaises(RunCommandError) as context: - await run_command_async(cmd) - mock_create_subprocess_exec.assert_called_once_with( - cmd[0], *cmd[1:], stdin=None, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE - ) - self.assertEqual(str(context.exception), "error") - self.assertEqual(context.exception.return_code, 2) - - async def test_run_command_async_failure_no_raise(self): - cmd = ["ls", "-z"] - with patch('asyncio.subprocess.create_subprocess_exec', new=AsyncMock(return_value=MockProcess(returncode=2, stderr="error"))) as mock_create_subprocess_exec: - result = await run_command_async(cmd, raise_on_fail=False) - mock_create_subprocess_exec.assert_called_once_with( - cmd[0], *cmd[1:], stdin=None, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE - ) - self.assertEqual(result.stderr, "error") - self.assertEqual(result.return_code, 2) - - async def test_run_command_async_shell_success(self): - cmd = "ls -l" - with patch('asyncio.subprocess.create_subprocess_shell', new=AsyncMock(return_value=MockProcess())) as mock_create_subprocess_shell: - result = await run_command_async(cmd, shell=True) - mock_create_subprocess_shell.assert_called_once_with( - cmd, stdin=None, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE - ) - self.assertEqual(result.stdout, "success") - self.assertEqual(result.stderr, "") - self.assertEqual(result.return_code, 0) - - async def test_run_command_async_shell_failure(self): - cmd = "ls -z" - with patch('asyncio.subprocess.create_subprocess_shell', new=AsyncMock(return_value=MockProcess(returncode=2, stderr="error"))) as mock_create_subprocess_shell: - with self.assertRaises(RunCommandError) as context: - await run_command_async(cmd, shell=True) - mock_create_subprocess_shell.assert_called_once_with( - cmd, stdin=None, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE - ) - self.assertEqual(str(context.exception), "error") - self.assertEqual(context.exception.return_code, 2) - - async def test_run_command_async_input_and_stdin_error(self): - cmd = ["ls", "-l"] - with self.assertRaises(RunCommandError) as context: - await run_command_async(cmd, input="test input", stdin=StringIO("test input")) - self.assertEqual(str(context.exception), "You cannot use both 'input' and 'stdin' on the same call.") - self.assertEqual(context.exception.return_code, -1) - - async def test_run_command_async_input_and_stdin_pipe_ok(self): - cmd = ["ls", "-l"] - with patch('asyncio.subprocess.create_subprocess_exec', new=AsyncMock(return_value=MockProcess())) as mock_create_subprocess_exec: - result = await run_command_async(cmd, input="test input", stdin=asyncio.subprocess.PIPE) - mock_create_subprocess_exec.assert_called_once_with( - cmd[0], *cmd[1:], stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE - ) - self.assertEqual(result.stdout, "success") - self.assertEqual(result.stderr, "") - self.assertEqual(result.return_code, 0) - -if __name__ == '__main__': - unittest.main() + +@pytest.mark.asyncio +async def test_run_command_async_success(): + cmd = ["ls", "-l"] + with patch('asyncio.subprocess.create_subprocess_exec', + new=AsyncMock(return_value=MockProcess())) as mock_create_subprocess_exec: + result = await run_command_async(cmd) + mock_create_subprocess_exec.assert_called_once_with( + cmd[0], *cmd[1:], stdin=None, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + assert result.stdout == "success" + assert result.stderr == "" + assert result.return_code == 0 + + +@pytest.mark.asyncio +async def test_run_command_async_success_with_input(): + cmd = ["cat"] + test_input = "test input" + with patch('asyncio.subprocess.create_subprocess_exec', + new=AsyncMock(return_value=MockProcess(stdout=test_input))) as mock_create_subprocess_exec: + result = await run_command_async(cmd, input=test_input) + mock_create_subprocess_exec.assert_called_once_with( + cmd[0], *cmd[1:], stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + assert result.stdout == test_input + assert result.stderr == "" + assert result.return_code == 0 + + +@pytest.mark.asyncio +async def test_run_command_async_success_with_stdin(): + cmd = ["cat"] + test_input = "test input" + stdin = StringIO(test_input) + with patch('asyncio.subprocess.create_subprocess_exec', + new=AsyncMock(return_value=MockProcess(stdout=test_input))) as mock_create_subprocess_exec: + result = await run_command_async(cmd, stdin=stdin) + mock_create_subprocess_exec.assert_called_once_with( + cmd[0], *cmd[1:], stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + assert result.stdout == test_input + assert result.stderr == "" + assert result.return_code == 0 + + +@pytest.mark.asyncio +async def test_run_command_async_failure(): + cmd = ["ls", "-z"] + with patch('asyncio.subprocess.create_subprocess_exec', + new=AsyncMock(return_value=MockProcess(returncode=2, stderr="error"))) as mock_create_subprocess_exec: + with pytest.raises(RunCommandError) as exc_info: + await run_command_async(cmd) + mock_create_subprocess_exec.assert_called_once_with( + cmd[0], *cmd[1:], stdin=None, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + assert str(exc_info.value) == "error" + assert exc_info.value.return_code == 2 + + +@pytest.mark.asyncio +async def test_run_command_async_failure_no_raise(): + cmd = ["ls", "-z"] + with patch('asyncio.subprocess.create_subprocess_exec', + new=AsyncMock(return_value=MockProcess(returncode=2, stderr="error"))) as mock_create_subprocess_exec: + result = await run_command_async(cmd, raise_on_fail=False) + mock_create_subprocess_exec.assert_called_once_with( + cmd[0], *cmd[1:], stdin=None, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + assert result.stderr == "error" + assert result.return_code == 2 + + +@pytest.mark.asyncio +async def test_run_command_async_shell_success(): + cmd = "ls -l" + with patch('asyncio.subprocess.create_subprocess_shell', + new=AsyncMock(return_value=MockProcess())) as mock_create_subprocess_shell: + result = await run_command_async(cmd, shell=True) + mock_create_subprocess_shell.assert_called_once_with( + cmd, stdin=None, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + assert result.stdout == "success" + assert result.stderr == "" + assert result.return_code == 0 + + +@pytest.mark.asyncio +async def test_run_command_async_shell_failure(): + cmd = "ls -z" + with patch('asyncio.subprocess.create_subprocess_shell', + new=AsyncMock(return_value=MockProcess(returncode=2, stderr="error"))) as mock_create_subprocess_shell: + with pytest.raises(RunCommandError) as exc_info: + await run_command_async(cmd, shell=True) + mock_create_subprocess_shell.assert_called_once_with( + cmd, stdin=None, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + assert str(exc_info.value) == "error" + assert exc_info.value.return_code == 2 + + +@pytest.mark.asyncio +async def test_run_command_async_input_and_stdin_error(): + cmd = ["ls", "-l"] + with pytest.raises(RunCommandError) as exc_info: + await run_command_async(cmd, input="test input", stdin=StringIO("test input")) + assert str(exc_info.value) == "You cannot use both 'input' and 'stdin' on the same call." + assert exc_info.value.return_code == -1 + + +@pytest.mark.asyncio +async def test_run_command_async_input_and_stdin_pipe_ok(): + cmd = ["ls", "-l"] + with patch('asyncio.subprocess.create_subprocess_exec', + new=AsyncMock(return_value=MockProcess())) as mock_create_subprocess_exec: + result = await run_command_async(cmd, input="test input", stdin=asyncio.subprocess.PIPE) + mock_create_subprocess_exec.assert_called_once_with( + cmd[0], *cmd[1:], stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + assert result.stdout == "success" + assert result.stderr == "" + assert result.return_code == 0 From ecf9aa102018ba62cdfc31601772bad5df6105e3 Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Tue, 29 Oct 2024 14:07:11 -0400 Subject: [PATCH 17/41] Format Cleanups --- wlanpi_core/constants.py | 4 +- wlanpi_core/core/config.py | 2 + wlanpi_core/models/command_result.py | 28 +++-- wlanpi_core/models/network/vlan/vlan_file.py | 11 +- wlanpi_core/services/bluetooth_service.py | 28 +++-- wlanpi_core/services/network_info_service.py | 44 ++++++-- wlanpi_core/services/network_service.py | 24 +++-- wlanpi_core/services/system_service.py | 6 +- wlanpi_core/services/utils_service.py | 77 ++++++++++---- wlanpi_core/utils/general.py | 74 ++++++++----- wlanpi_core/utils/network.py | 17 +-- wlanpi_core/utils/tests/test_general.py | 32 +++--- wlanpi_core/utils/tests/test_run_command.py | 12 ++- .../utils/tests/test_runcommand_async.py | 100 +++++++++++++----- 14 files changed, 323 insertions(+), 136 deletions(-) diff --git a/wlanpi_core/constants.py b/wlanpi_core/constants.py index 2a54ab0..55072ac 100644 --- a/wlanpi_core/constants.py +++ b/wlanpi_core/constants.py @@ -1,7 +1,9 @@ # Core config API_V1_STR: str = "/api/v1" PROJECT_NAME: str = "wlanpi-core" -PROJECT_DESCRIPTION: str = "The wlanpi-core API provides endpoints for applications on the WLAN Pi to share data. 🚀" +PROJECT_DESCRIPTION: str = ( + "The wlanpi-core API provides endpoints for applications on the WLAN Pi to share data. 🚀" +) # Linux programs IFCONFIG_FILE: str = "/sbin/ifconfig" diff --git a/wlanpi_core/core/config.py b/wlanpi_core/core/config.py index 324ee0a..861debf 100644 --- a/wlanpi_core/core/config.py +++ b/wlanpi_core/core/config.py @@ -1,8 +1,10 @@ from pathlib import Path from pydantic_settings import BaseSettings + from wlanpi_core import constants + class Settings(BaseSettings): API_V1_STR: str = constants.API_V1_STR diff --git a/wlanpi_core/models/command_result.py b/wlanpi_core/models/command_result.py index ec2db95..718ed6b 100644 --- a/wlanpi_core/models/command_result.py +++ b/wlanpi_core/models/command_result.py @@ -20,17 +20,33 @@ def output_from_json(self) -> Union[dict, list, int, float, str, None]: except JSONDecodeError: return None - - def grep_stdout_for_string(self, string:str, negate:bool=False, split:bool=False) -> Union[str, list[str]]: + def grep_stdout_for_string( + self, string: str, negate: bool = False, split: bool = False + ) -> Union[str, list[str]]: if negate: filtered = list(filter(lambda x: string not in x, self.stdout.split("\n"))) else: filtered = list(filter(lambda x: string in x, self.stdout.split("\n"))) return filtered if split else "\n".join(filtered) - def grep_stdout_for_pattern(self, pattern: Union[re.Pattern[str], str], flags: Union[int, RegexFlag] = 0, negate:bool=False, split:bool=False) -> Union[str, list[str]]: + def grep_stdout_for_pattern( + self, + pattern: Union[re.Pattern[str], str], + flags: Union[int, RegexFlag] = 0, + negate: bool = False, + split: bool = False, + ) -> Union[str, list[str]]: if negate: - filtered = list(filter(lambda x: not re.match(pattern, x, flags=flags), self.stdout.split("\n"))) + filtered = list( + filter( + lambda x: not re.match(pattern, x, flags=flags), + self.stdout.split("\n"), + ) + ) else: - filtered = list(filter(lambda x: re.match(pattern, x, flags=flags), self.stdout.split("\n"))) - return filtered if split else "\n".join(filtered) \ No newline at end of file + filtered = list( + filter( + lambda x: re.match(pattern, x, flags=flags), self.stdout.split("\n") + ) + ) + return filtered if split else "\n".join(filtered) diff --git a/wlanpi_core/models/network/vlan/vlan_file.py b/wlanpi_core/models/network/vlan/vlan_file.py index a2bfffe..16a1af0 100644 --- a/wlanpi_core/models/network/vlan/vlan_file.py +++ b/wlanpi_core/models/network/vlan/vlan_file.py @@ -30,7 +30,6 @@ class VLANFile: "ipv4ll", ) - def __init__( self, interface_file: str = DEFAULT_INTERFACE_FILE, @@ -173,9 +172,13 @@ def generate_if_config_from_object(configuration: Vlan) -> str: @staticmethod async def check_interface_exists(interface: str) -> bool: - ethernet_interfaces = [x for x in ( - await run_command_async("ls /sys/class/net/", raise_on_fail=True) - ).stdout.split("\n") if "eth" in x] + ethernet_interfaces = [ + x + for x in ( + await run_command_async("ls /sys/class/net/", raise_on_fail=True) + ).stdout.split("\n") + if "eth" in x + ] ethernet_interfaces = set([i.split(".")[0] for i in ethernet_interfaces if i]) return interface in ethernet_interfaces diff --git a/wlanpi_core/services/bluetooth_service.py b/wlanpi_core/services/bluetooth_service.py index 5a0c611..9dabbbc 100644 --- a/wlanpi_core/services/bluetooth_service.py +++ b/wlanpi_core/services/bluetooth_service.py @@ -1,7 +1,7 @@ import re -from wlanpi_core.utils.general import run_command from wlanpi_core.constants import BT_ADAPTER +from wlanpi_core.utils.general import run_command def bluetooth_present(): @@ -9,25 +9,33 @@ def bluetooth_present(): We want to use hciconfig here as it works OK when no devices are present """ cmd = f"hciconfig" - filtered = run_command(cmd=cmd, raise_on_fail=True).grep_stdout_for_string(BT_ADAPTER,) + filtered = run_command(cmd=cmd, raise_on_fail=True).grep_stdout_for_string( + BT_ADAPTER, + ) return filtered.strip() if filtered else "" def bluetooth_name(): cmd = f"bt-adapter -a {BT_ADAPTER} -i" - filtered = run_command(cmd=cmd, raise_on_fail=True).grep_stdout_for_string("Name", split=True) + filtered = run_command(cmd=cmd, raise_on_fail=True).grep_stdout_for_string( + "Name", split=True + ) return filtered[0].strip().split(" ")[1] if filtered else "" def bluetooth_alias(): cmd = f"bt-adapter -a {BT_ADAPTER} -i" - filtered = run_command(cmd=cmd, raise_on_fail=True).grep_stdout_for_string("Alias", split=True) + filtered = run_command(cmd=cmd, raise_on_fail=True).grep_stdout_for_string( + "Alias", split=True + ) return filtered[0].strip().split(" ")[1] if filtered else "" def bluetooth_address(): cmd = f"bt-adapter -a {BT_ADAPTER} -i" - filtered = run_command(cmd=cmd, raise_on_fail=True).grep_stdout_for_string("Address", split=True) + filtered = run_command(cmd=cmd, raise_on_fail=True).grep_stdout_for_string( + "Address", split=True + ) return filtered[0].strip().split(" ")[1] if filtered else "" @@ -36,7 +44,9 @@ def bluetooth_power(): We want to use hciconfig here as it works OK when no devices are present """ cmd = f"hciconfig {BT_ADAPTER} " - filtered = run_command(cmd=cmd, raise_on_fail=True).grep_stdout_for_pattern(r"^\s+UP", split=True) + filtered = run_command(cmd=cmd, raise_on_fail=True).grep_stdout_for_pattern( + r"^\s+UP", split=True + ) return filtered[0].strip() if filtered else "" @@ -54,7 +64,7 @@ def bluetooth_set_power(power): cmd = f"bt-adapter -a {BT_ADAPTER} --set Powered 0" bt_state = 0 result = run_command(cmd, shell=True, raise_on_fail=True).stdout - with open('/etc/wlanpi-bluetooth/state', 'w') as bt_state_file: + with open("/etc/wlanpi-bluetooth/state", "w") as bt_state_file: bt_state_file.write(str(bt_state)) if result: @@ -71,7 +81,9 @@ def bluetooth_paired_devices(): return None cmd = "bluetoothctl -- paired-devices" - output = run_command(cmd=cmd, raise_on_fail=True).grep_stdout_for_pattern(r"no default controller", flags=re.I, negate=True, split=False) + output = run_command(cmd=cmd, raise_on_fail=True).grep_stdout_for_pattern( + r"no default controller", flags=re.I, negate=True, split=False + ) if len(output) > 0: output = re.sub("Device *", "", output).split("\n") return dict([line.split(" ", 1) for line in output]) diff --git a/wlanpi_core/services/network_info_service.py b/wlanpi_core/services/network_info_service.py index e065e2a..42ebba6 100644 --- a/wlanpi_core/services/network_info_service.py +++ b/wlanpi_core/services/network_info_service.py @@ -2,13 +2,22 @@ import re import subprocess +from wlanpi_core.constants import ( + CDPNEIGH_FILE, + ETHTOOL_FILE, + IFCONFIG_FILE, + IPCONFIG_FILE, + IW_FILE, + LLDPNEIGH_FILE, + PUBLICIP6_CMD, + PUBLICIP_CMD, +) from wlanpi_core.models.runcommand_error import RunCommandError from wlanpi_core.utils.general import run_command, run_command_async -from wlanpi_core.constants import IFCONFIG_FILE, IW_FILE, ETHTOOL_FILE, LLDPNEIGH_FILE, CDPNEIGH_FILE, IPCONFIG_FILE, \ - PUBLICIP_CMD, PUBLICIP6_CMD # TODO: There is a TON of "except Exception" clauses in here that need worked on. These are generally EVIL and mask real errors that need dealt with. + def show_info(): output = {} @@ -71,7 +80,10 @@ def show_interfaces(): if re.search(r"(wlan\d+)|(mon\d+)", interface_name, re.MULTILINE): # fire up 'iw' for this interface (hmmm..is this a bit of an un-necessary ovehead?) try: - iw_info = run_command("{} {} info".format(iw_file, interface_name), raise_on_fail=True).stdout + iw_info = run_command( + "{} {} info".format(iw_file, interface_name), + raise_on_fail=True, + ).stdout if re.search("type monitor", iw_info, re.MULTILINE): ip_address = "Monitor" @@ -112,7 +124,9 @@ def show_wlan_interfaces(): output = {} try: - interfaces = run_command(f"{IW_FILE} dev 2>&1", shell=True).grep_stdout_for_pattern(r"interface", flags=re.I,split=True) + interfaces = run_command( + f"{IW_FILE} dev 2>&1", shell=True + ).grep_stdout_for_pattern(r"interface", flags=re.I, split=True) interfaces = map(lambda x: x.strip().split(" ")[1], interfaces) except Exception as e: print(e) @@ -122,7 +136,9 @@ def show_wlan_interfaces(): # Driver try: - ethtool_output = run_command(f"{ETHTOOL_FILE} -i {interface}").stdout.strip() + ethtool_output = run_command( + f"{ETHTOOL_FILE} -i {interface}" + ).stdout.strip() driver = re.search(".*driver:\s+(.*)", ethtool_output).group(1) output[interface]["driver"] = driver except Exception: @@ -184,10 +200,14 @@ def show_eth0_ipconfig(): try: # Currently, ipconfig_file is a constant with a shell redirect in it, so need shell=True until it can be refactored - ipconfig_info = run_command(ipconfig_file, shell=True).stdout.strip().split("\n") + ipconfig_info = ( + run_command(ipconfig_file, shell=True).stdout.strip().split("\n") + ) except RunCommandError as exc: - eth0_ipconfig_info["error"] = f"Issue getting ipconfig ({exc.return_code}): {exc.error_msg}" + eth0_ipconfig_info["error"] = ( + f"Issue getting ipconfig ({exc.return_code}): {exc.error_msg}" + ) return eth0_ipconfig_info except subprocess.CalledProcessError as exc: output = exc.output.decode() @@ -248,12 +268,14 @@ def show_lldp_neighbour(): if os.path.exists(lldpneigh_file): try: - neighbour_output = run_command(neighbour_cmd).stdout.strip().split("\n") + neighbour_output = run_command(neighbour_cmd).stdout.strip().split("\n") for line in neighbour_output: neighbour_info["info"].append(line) except RunCommandError as exc: - neighbour_info["error"] = f"Issue getting LLDP neighbour ({exc.return_code}): {exc.error_msg}" + neighbour_info["error"] = ( + f"Issue getting LLDP neighbour ({exc.return_code}): {exc.error_msg}" + ) return neighbour_info except subprocess.CalledProcessError as exc: neighbour_info["error"] = "Issue getting LLDP neighbour" @@ -281,7 +303,9 @@ def show_cdp_neighbour(): neighbour_info["info"].append(line) except RunCommandError as exc: - neighbour_info["error"] = f"Issue getting CDP neighbour ({exc.return_code}): {exc.error_msg}" + neighbour_info["error"] = ( + f"Issue getting CDP neighbour ({exc.return_code}): {exc.error_msg}" + ) return neighbour_info except subprocess.CalledProcessError as exc: neighbour_info["error"] = "Issue getting CDP neighbour" diff --git a/wlanpi_core/services/network_service.py b/wlanpi_core/services/network_service.py index ee96368..74a9601 100644 --- a/wlanpi_core/services/network_service.py +++ b/wlanpi_core/services/network_service.py @@ -6,12 +6,17 @@ from dbus.exceptions import DBusException from gi.repository import GLib -from wlanpi_core.constants import WPAS_DBUS_SERVICE, WPAS_DBUS_INTERFACE, WPAS_DBUS_OPATH, \ - WPAS_DBUS_INTERFACES_INTERFACE, WPAS_DBUS_BSS_INTERFACE +from wlanpi_core.constants import ( + WPAS_DBUS_BSS_INTERFACE, + WPAS_DBUS_INTERFACE, + WPAS_DBUS_INTERFACES_INTERFACE, + WPAS_DBUS_OPATH, + WPAS_DBUS_SERVICE, +) from wlanpi_core.models.runcommand_error import RunCommandError -from wlanpi_core.utils.general import run_command from wlanpi_core.models.validation_error import ValidationError from wlanpi_core.schemas import network +from wlanpi_core.utils.general import run_command from wlanpi_core.utils.network import get_interface_addresses # For running locally (not in API) @@ -91,7 +96,9 @@ def renew_dhcp(interface): # Obtain a new DHCP lease run_command(["sudo", "dhclient", interface], raise_on_fail=True) except RunCommandError as err: - debug_print(f"Failed to renew DHCP. Code:{err.return_code}, Error: {err.error_msg}", 1) + debug_print( + f"Failed to renew DHCP. Code:{err.return_code}, Error: {err.error_msg}", 1 + ) def get_ip_address(interface): @@ -99,12 +106,15 @@ def get_ip_address(interface): Extract the IP Address from the linux ip add show command """ try: - res = get_interface_addresses(interface)[interface]['inet'] + res = get_interface_addresses(interface)[interface]["inet"] if len(res): return res[0] return None except RunCommandError as err: - debug_print(f"Failed to get IP address. Code:{err.return_code}, Error: {err.error_msg}", 1) + debug_print( + f"Failed to get IP address. Code:{err.return_code}, Error: {err.error_msg}", + 1, + ) def getBss(bss): @@ -681,4 +691,4 @@ async def get_systemd_network_currentNetwork_details( # print(getBss(res)) if __name__ == "__main__": - print(get_ip_address('eth0')) \ No newline at end of file + print(get_ip_address("eth0")) diff --git a/wlanpi_core/services/system_service.py b/wlanpi_core/services/system_service.py index f0cf7d2..30dde00 100644 --- a/wlanpi_core/services/system_service.py +++ b/wlanpi_core/services/system_service.py @@ -130,7 +130,9 @@ def get_platform(): platform = run_command(model_cmd).stdout.strip() except RunCommandError as exc: - logging.warning(f"Issue getting wlanpi model ({exc.return_code}): {exc.error_msg}") + logging.warning( + f"Issue getting WLAN Pi model ({exc.return_code}): {exc.error_msg}" + ) return "Unknown" except subprocess.CalledProcessError as exc: exc.model.decode() @@ -162,7 +164,7 @@ def get_stats(): # cmd = "top -bn1 | grep load | awk '{printf \"%.2f%%\", $(NF-2)}'" cmd = "mpstat 1 1 -o JSON" try: - CPU_JSON = run_command(cmd).grep_stdout_for_string('idle') + CPU_JSON = run_command(cmd).grep_stdout_for_string("idle") CPU_IDLE = json.loads(CPU_JSON)["idle"] CPU = "{0:.2f}%".format(100 - CPU_IDLE) if CPU_IDLE == 100: diff --git a/wlanpi_core/services/utils_service.py b/wlanpi_core/services/utils_service.py index a9b13f3..efad944 100644 --- a/wlanpi_core/services/utils_service.py +++ b/wlanpi_core/services/utils_service.py @@ -2,6 +2,7 @@ import re from wlanpi_core.constants import UFW_FILE + from ..models.runcommand_error import RunCommandError from ..utils.general import run_command_async from ..utils.network import get_default_gateways @@ -31,33 +32,61 @@ async def show_reachability(): return {"error": "No default gateway"} # Start executing tests in the background - ping_google_cr = run_command_async("jc ping -c1 -W2 -q google.com", raise_on_fail=False) - browse_google_result_cr = run_command_async("timeout 2 curl -s -L www.google.com", raise_on_fail=False) - ping_gateway_cr = run_command_async(f"jc ping -c1 -W2 -q {default_gateway}", raise_on_fail=False) - arping_gateway_cr = run_command_async(f"timeout 2 arping -c1 -w2 -I {dg_interface} {default_gateway}", - raise_on_fail=False) - dns_res_crs = [(i, run_command_async(f"dig +short +time=2 +tries=1 @{dns} NS google.com", raise_on_fail=False)) for i,dns in enumerate(dns_servers[:3], start=1)] - + ping_google_cr = run_command_async( + "jc ping -c1 -W2 -q google.com", raise_on_fail=False + ) + browse_google_result_cr = run_command_async( + "timeout 2 curl -s -L www.google.com", raise_on_fail=False + ) + ping_gateway_cr = run_command_async( + f"jc ping -c1 -W2 -q {default_gateway}", raise_on_fail=False + ) + arping_gateway_cr = run_command_async( + f"timeout 2 arping -c1 -w2 -I {dg_interface} {default_gateway}", + raise_on_fail=False, + ) + dns_res_crs = [ + ( + i, + run_command_async( + f"dig +short +time=2 +tries=1 @{dns} NS google.com", raise_on_fail=False + ), + ) + for i, dns in enumerate(dns_servers[:3], start=1) + ] # Ping Google ping_google = await ping_google_cr - output["results"][ - "Ping Google"] = f"{ping_google.output_from_json()['round_trip_ms_avg']}ms" if ping_google.success else "FAIL" + output["results"]["Ping Google"] = ( + f"{ping_google.output_from_json()['round_trip_ms_avg']}ms" + if ping_google.success + else "FAIL" + ) # Browse Google.com browse_google_result = await browse_google_result_cr - output["results"]["Browse Google"] = "OK" if ( - browse_google_result.success and "google.com" in browse_google_result.stdout) else "FAIL" + output["results"]["Browse Google"] = ( + "OK" + if ( + browse_google_result.success and "google.com" in browse_google_result.stdout + ) + else "FAIL" + ) # Ping default gateway ping_gateway = await ping_gateway_cr - output["results"][ - "Ping Gateway"] = f"{ping_gateway.output_from_json()['round_trip_ms_avg']}ms" if ping_gateway.success else "FAIL" + output["results"]["Ping Gateway"] = ( + f"{ping_gateway.output_from_json()['round_trip_ms_avg']}ms" + if ping_gateway.success + else "FAIL" + ) # DNS resolution checks - for i,cr in dns_res_crs: + for i, cr in dns_res_crs: dns_res = await cr - output["results"][f"DNS Server {i} Resolution"] = "OK" if dns_res.success else "FAIL" + output["results"][f"DNS Server {i} Resolution"] = ( + "OK" if dns_res.success else "FAIL" + ) # ARPing default gateway arping_gateway = (await arping_gateway_cr).stdout @@ -74,8 +103,14 @@ async def show_usb(): interfaces = {} try: - lsusb_output = (await run_command_async("/usr/bin/lsusb", raise_on_fail=True)).stdout.split("\n") - lsusb_info = [line.split(" ", 6)[-1].strip() for line in lsusb_output if "Linux" not in line] + lsusb_output = ( + await run_command_async("/usr/bin/lsusb", raise_on_fail=True) + ).stdout.split("\n") + lsusb_info = [ + line.split(" ", 6)[-1].strip() + for line in lsusb_output + if "Linux" not in line + ] except RunCommandError as err: error_descr = "Issue getting usb info using lsusb command" interfaces["error"] = {"error": {error_descr + ": " + err.error_msg}} @@ -148,9 +183,11 @@ async def show_ufw(): return response try: - ufw_output = (await run_command_async( - "sudo {} status".format(ufw_file), raise_on_fail=True - )).stdout + ufw_output = ( + await run_command_async( + "sudo {} status".format(ufw_file), raise_on_fail=True + ) + ).stdout ufw_info = parse_ufw(ufw_output) except: diff --git a/wlanpi_core/utils/general.py b/wlanpi_core/utils/general.py index e7e1d69..bc62114 100644 --- a/wlanpi_core/utils/general.py +++ b/wlanpi_core/utils/general.py @@ -6,13 +6,19 @@ import time from asyncio.subprocess import Process from io import StringIO -from typing import Union, Optional, TextIO +from typing import Optional, TextIO, Union from wlanpi_core.models.command_result import CommandResult from wlanpi_core.models.runcommand_error import RunCommandError -def run_command(cmd: Union[list, str], input:Optional[str]=None, stdin:Optional[TextIO]=None, shell=False, raise_on_fail=True) -> CommandResult: +def run_command( + cmd: Union[list, str], + input: Optional[str] = None, + stdin: Optional[TextIO] = None, + shell=False, + raise_on_fail=True, +) -> CommandResult: """Run a single CLI command with subprocess and returns the output""" """ This function executes a single CLI command using the the built-in subprocess module. @@ -38,10 +44,12 @@ def run_command(cmd: Union[list, str], input:Optional[str]=None, stdin:Optional[ RunCommandError: If `raise_on_fail=True` and the command failed. """ - # cannot have both input and STDIN, unless stdin is the constant for PIPE or /dev/null if input and stdin and not isinstance(stdin, int): - raise RunCommandError(error_msg="You cannot use both 'input' and 'stdin' on the same call.", return_code=-1) + raise RunCommandError( + error_msg="You cannot use both 'input' and 'stdin' on the same call.", + return_code=-1, + ) # Todo: explore using shlex to always split to protect against injections if shell: @@ -50,20 +58,22 @@ def run_command(cmd: Union[list, str], input:Optional[str]=None, stdin:Optional[ cmd: list cmd: str = shlex.join(cmd) cmd: str - logging.getLogger().warning(f"Command {cmd} being run as a shell script. This could present " - f"an injection vulnerability. Consider whether you really need to do this.") + logging.getLogger().warning( + f"Command {cmd} being run as a shell script. This could present " + f"an injection vulnerability. Consider whether you really need to do this." + ) else: # If a string was passed in non-shell mode, safely split it using shlex to protect against injection. if isinstance(cmd, str): - cmd:str - cmd:list[str] = shlex.split(cmd) + cmd: str + cmd: list[str] = shlex.split(cmd) cmd: list[str] with subprocess.Popen( cmd, shell=shell, stdin=subprocess.PIPE if input or isinstance(stdin, StringIO) else stdin, stdout=subprocess.PIPE, - stderr=subprocess.PIPE + stderr=subprocess.PIPE, ) as proc: if input: input_data = input.encode() @@ -78,7 +88,13 @@ def run_command(cmd: Union[list, str], input:Optional[str]=None, stdin:Optional[ return CommandResult(stdout.decode(), stderr.decode(), proc.returncode) -async def run_command_async(cmd: Union[list, str], input:Optional[str]=None, stdin:Optional[TextIO]=None, shell=False, raise_on_fail=True) -> CommandResult: +async def run_command_async( + cmd: Union[list, str], + input: Optional[str] = None, + stdin: Optional[TextIO] = None, + shell=False, + raise_on_fail=True, +) -> CommandResult: """Run a single CLI command with subprocess and returns the output""" """ This function executes a single CLI command using the the built-in subprocess module. @@ -106,7 +122,10 @@ async def run_command_async(cmd: Union[list, str], input:Optional[str]=None, std # cannot have both input and STDIN, unless stdin is the constant for PIPE or /dev/null if input and stdin and not isinstance(stdin, int): - raise RunCommandError(error_msg="You cannot use both 'input' and 'stdin' on the same call.", return_code=-1) + raise RunCommandError( + error_msg="You cannot use both 'input' and 'stdin' on the same call.", + return_code=-1, + ) # Prepare input data for communicate if input: @@ -126,14 +145,16 @@ async def run_command_async(cmd: Union[list, str], input:Optional[str]=None, std cmd: list cmd: str = shlex.join(cmd) cmd: str - logging.getLogger().warning(f"Command {cmd} being run as a shell script. This could present " - f"an injection vulnerability. Consider whether you really need to do this.") + logging.getLogger().warning( + f"Command {cmd} being run as a shell script. This could present " + f"an injection vulnerability. Consider whether you really need to do this." + ) proc = await asyncio.subprocess.create_subprocess_shell( - cmd, - stdin=subprocess.PIPE if input or isinstance(stdin, StringIO) else stdin, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE + cmd, + stdin=subprocess.PIPE if input or isinstance(stdin, StringIO) else stdin, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, ) proc: Process stdout, stderr = await proc.communicate(input=input_data) @@ -143,12 +164,12 @@ async def run_command_async(cmd: Union[list, str], input:Optional[str]=None, std cmd: str cmd: list[str] = shlex.split(cmd) cmd: list[str] - proc = await asyncio.subprocess.create_subprocess_exec( - cmd[0], - *cmd[1:], - stdin=subprocess.PIPE if input or isinstance(stdin, StringIO) else stdin, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE + proc = await asyncio.subprocess.create_subprocess_exec( + cmd[0], + *cmd[1:], + stdin=subprocess.PIPE if input or isinstance(stdin, StringIO) else stdin, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, ) proc: Process stdout, stderr = await proc.communicate(input=input_data) @@ -157,6 +178,7 @@ async def run_command_async(cmd: Union[list, str], input:Optional[str]=None, std raise RunCommandError(error_msg=stderr.decode(), return_code=proc.returncode) return CommandResult(stdout.decode(), stderr.decode(), proc.returncode) + def get_model_info() -> dict[str, str]: """Uses wlanpi-model cli command to get model info Returns: @@ -172,6 +194,7 @@ def get_model_info() -> dict[str, str]: model_dict[a.strip()] = b.strip() return model_dict + def get_uptime() -> dict[str, str]: """Gets the system uptime using jc and the uptime command. Returns: @@ -182,6 +205,7 @@ def get_uptime() -> dict[str, str]: cmd = "jc uptime" return run_command(cmd.split(" ")).output_from_json() + def get_hostname() -> str: """Gets the system hostname using hostname command. Returns: @@ -191,6 +215,7 @@ def get_hostname() -> str: """ return run_command(["hostname"]).stdout.strip("\n ") + def get_current_unix_timestamp() -> float: """Gets the current unix timestamp in milliseconds Returns: @@ -198,6 +223,3 @@ def get_current_unix_timestamp() -> float: """ ms = datetime.datetime.now() return time.mktime(ms.timetuple()) * 1000 - - - diff --git a/wlanpi_core/utils/network.py b/wlanpi_core/utils/network.py index b30541d..661a0d2 100644 --- a/wlanpi_core/utils/network.py +++ b/wlanpi_core/utils/network.py @@ -1,9 +1,10 @@ -from typing import Optional, Any +from typing import Any, Optional from wlanpi_core.utils.general import run_command + def get_default_gateways() -> dict[str, str]: - """ Finds the default gateway of each interface on the system using 'ip route show' + """Finds the default gateway of each interface on the system using 'ip route show' Returns: a dictionary mapping interfaces to their default gateways. Raises: @@ -20,6 +21,7 @@ def get_default_gateways() -> dict[str, str]: gateways[res[1].strip()] = res[0].strip() return gateways + def trace_route(target: str) -> dict[str, Any]: # Execute 'ip route show' command which lists all network routes output = run_command(["jc", "traceroute", target]).output_from_json() @@ -33,12 +35,15 @@ def get_interface_address_data(interface: Optional[str] = None) -> list[dict[str result = run_command(cmd).output_from_json() return result -def get_interface_addresses(interface: Optional[str] = None) -> dict[str, dict[str, str]]: + +def get_interface_addresses( + interface: Optional[str] = None, +) -> dict[str, dict[str, str]]: res = get_interface_address_data(interface=interface) out_obj = {} for item in res: - if item['ifname'] not in out_obj: - out_obj[item['ifname']] = {'inet': [], 'inet6': []} + if item["ifname"] not in out_obj: + out_obj[item["ifname"]] = {"inet": [], "inet6": []} for addr in item["addr_info"]: - out_obj[item['ifname']][addr['family']].append(addr['local']) + out_obj[item["ifname"]][addr["family"]].append(addr["local"]) return out_obj diff --git a/wlanpi_core/utils/tests/test_general.py b/wlanpi_core/utils/tests/test_general.py index a05634e..7bbe6a1 100644 --- a/wlanpi_core/utils/tests/test_general.py +++ b/wlanpi_core/utils/tests/test_general.py @@ -1,6 +1,6 @@ import time import unittest.mock # python -m unittest.mock -from unittest.mock import patch, Mock +from unittest.mock import Mock, patch from wlanpi_core.models.runcommand_error import RunCommandError from wlanpi_core.utils import general @@ -9,20 +9,19 @@ class TestGeneralUtils(unittest.TestCase): - @unittest.mock.patch('wlanpi_core.utils.general.run_command') + @unittest.mock.patch("wlanpi_core.utils.general.run_command") def test_get_hostname(self, mock_run_command): # Mock the run_command function to return a mocked subprocess object with specific attributes mock_subprocess = unittest.mock.Mock() - mock_subprocess.stdout = 'test_hostname\n' # Replace this with your expected hostname + mock_subprocess.stdout = "test_hostname\n" mock_run_command.return_value = mock_subprocess result = general.get_hostname() # Assert that the function called run_command correctly - mock_run_command.assert_called_once_with(['hostname']) + mock_run_command.assert_called_once_with(["hostname"]) # Assert that the hostname is returned properly - self.assertEqual(result, 'test_hostname') - + self.assertEqual(result, "test_hostname") def test_get_current_unix_timestamp(self): # Get current Unix timestamp in milliseconds @@ -32,11 +31,12 @@ def test_get_current_unix_timestamp(self): func_ms = general.get_current_unix_timestamp() # The difference should be less than a second (assuming the test is not run at the exact second) - self.assertTrue(abs(func_ms - ms) < 1000, - f"The function returned {func_ms}, which differs from current Unix timestamp in milliseconds by more than 1000.") - + self.assertTrue( + abs(func_ms - ms) < 1000, + f"The function returned {func_ms}, which differs from current Unix timestamp in milliseconds by more than 1000.", + ) - @patch('wlanpi_core.utils.general.run_command') + @patch("wlanpi_core.utils.general.run_command") def test_get_uptime(self, mock_run_command: Mock): # Define the output from the 'jc uptime' command expected_output = {"uptime": "123456", "idle_time": "7890"} @@ -48,9 +48,7 @@ def test_get_uptime(self, mock_run_command: Mock): self.assertEqual(actual_output, expected_output) - - - @patch('wlanpi_core.utils.general.run_command') + @patch("wlanpi_core.utils.general.run_command") def test_get_model_info(self, mock_run_command): # Define the output of run_command that we expect to get from wlanpi-model command mock_output = """ @@ -76,8 +74,7 @@ def test_get_model_info(self, mock_run_command): # Assert that the return value is what we expect it to be, i.e., equal to expected_dict self.assertEqual(result, expected_dict) - - @patch('wlanpi_core.utils.general.run_command') + @patch("wlanpi_core.utils.general.run_command") def test_get_model_info_error(self, mock_run_command): # Define the output of run_command that we expect to get from wlanpi-model command when error occurs mock_output = "Error: Command not found" @@ -85,11 +82,12 @@ def test_get_model_info_error(self, mock_run_command): # Set up the mock object's return value. This is where we tell it what to do when called mock_run_command.return_value.stdout = mock_output mock_run_command.return_value.return_code = 1 - mock_run_command.side_effect = RunCommandError("Failed to run command",1) + mock_run_command.side_effect = RunCommandError("Failed to run command", 1) # Assert that the function raises a RunCommandError when there is an error running the command with self.assertRaises(RunCommandError): get_model_info() -if __name__ == '__main__': + +if __name__ == "__main__": unittest.main() diff --git a/wlanpi_core/utils/tests/test_run_command.py b/wlanpi_core/utils/tests/test_run_command.py index 69ead24..c5c52f0 100644 --- a/wlanpi_core/utils/tests/test_run_command.py +++ b/wlanpi_core/utils/tests/test_run_command.py @@ -3,7 +3,8 @@ import pytest -from wlanpi_core.utils.general import run_command, RunCommandError, CommandResult +from wlanpi_core.utils.general import CommandResult, RunCommandError, run_command + def test_run_command_success(): # Test a successful command execution @@ -12,23 +13,27 @@ def test_run_command_success(): assert result.stderr == "" assert result.return_code == 0 + def test_run_command_failure(): # Test a failing command execution with raise_on_fail=True with pytest.raises(RunCommandError) as context: run_command(["ls", "nonexistent_file"]) assert "No such file or directory" in str(context.value) + def test_run_command_failure_no_raise(): # Test a failing command execution with raise_on_fail=False result = run_command(["false"], raise_on_fail=False) assert result.return_code == 1 + def test_run_command_input(): # Test providing input to the command result = run_command(["cat"], input="test input") assert result.stdout == "test input" -@patch('subprocess.run') + +@patch("subprocess.run") def test_run_command_stdin(mock_run): # Test providing stdin to the command mock_run.return_value.stdout = "test input" @@ -37,18 +42,21 @@ def test_run_command_stdin(mock_run): result = run_command(["cat"], stdin=StringIO("test input")) assert result.stdout == "test input" + def test_run_command_input_and_stdin_error(): # Test raising an error when both input and stdin are provided with pytest.raises(RunCommandError) as context: run_command(["echo"], input="test", stdin=StringIO("test")) assert "You cannot use both 'input' and 'stdin'" in str(context.value) + def test_run_command_shell_warning(caplog): # Test the warning message when using shell=True with caplog.at_level("WARNING"): run_command("echo test", shell=True) assert "Command echo test being run as a shell script" in caplog.text + def test_command_result(): # Test the CommandResult class result = CommandResult("output", "error", 0) diff --git a/wlanpi_core/utils/tests/test_runcommand_async.py b/wlanpi_core/utils/tests/test_runcommand_async.py index 06e1530..d69f801 100644 --- a/wlanpi_core/utils/tests/test_runcommand_async.py +++ b/wlanpi_core/utils/tests/test_runcommand_async.py @@ -1,9 +1,10 @@ import asyncio from io import StringIO -import pytest from unittest.mock import AsyncMock, patch -from wlanpi_core.utils.general import run_command_async, CommandResult, RunCommandError +import pytest + +from wlanpi_core.utils.general import RunCommandError, run_command_async class MockProcess: @@ -19,11 +20,17 @@ async def communicate(self, input=None): @pytest.mark.asyncio async def test_run_command_async_success(): cmd = ["ls", "-l"] - with patch('asyncio.subprocess.create_subprocess_exec', - new=AsyncMock(return_value=MockProcess())) as mock_create_subprocess_exec: + with patch( + "asyncio.subprocess.create_subprocess_exec", + new=AsyncMock(return_value=MockProcess()), + ) as mock_create_subprocess_exec: result = await run_command_async(cmd) mock_create_subprocess_exec.assert_called_once_with( - cmd[0], *cmd[1:], stdin=None, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + cmd[0], + *cmd[1:], + stdin=None, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE ) assert result.stdout == "success" assert result.stderr == "" @@ -34,11 +41,16 @@ async def test_run_command_async_success(): async def test_run_command_async_success_with_input(): cmd = ["cat"] test_input = "test input" - with patch('asyncio.subprocess.create_subprocess_exec', - new=AsyncMock(return_value=MockProcess(stdout=test_input))) as mock_create_subprocess_exec: + with patch( + "asyncio.subprocess.create_subprocess_exec", + new=AsyncMock(return_value=MockProcess(stdout=test_input)), + ) as mock_create_subprocess_exec: result = await run_command_async(cmd, input=test_input) mock_create_subprocess_exec.assert_called_once_with( - cmd[0], *cmd[1:], stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, + cmd[0], + *cmd[1:], + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) assert result.stdout == test_input @@ -51,11 +63,16 @@ async def test_run_command_async_success_with_stdin(): cmd = ["cat"] test_input = "test input" stdin = StringIO(test_input) - with patch('asyncio.subprocess.create_subprocess_exec', - new=AsyncMock(return_value=MockProcess(stdout=test_input))) as mock_create_subprocess_exec: + with patch( + "asyncio.subprocess.create_subprocess_exec", + new=AsyncMock(return_value=MockProcess(stdout=test_input)), + ) as mock_create_subprocess_exec: result = await run_command_async(cmd, stdin=stdin) mock_create_subprocess_exec.assert_called_once_with( - cmd[0], *cmd[1:], stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, + cmd[0], + *cmd[1:], + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) assert result.stdout == test_input @@ -66,12 +83,18 @@ async def test_run_command_async_success_with_stdin(): @pytest.mark.asyncio async def test_run_command_async_failure(): cmd = ["ls", "-z"] - with patch('asyncio.subprocess.create_subprocess_exec', - new=AsyncMock(return_value=MockProcess(returncode=2, stderr="error"))) as mock_create_subprocess_exec: + with patch( + "asyncio.subprocess.create_subprocess_exec", + new=AsyncMock(return_value=MockProcess(returncode=2, stderr="error")), + ) as mock_create_subprocess_exec: with pytest.raises(RunCommandError) as exc_info: await run_command_async(cmd) mock_create_subprocess_exec.assert_called_once_with( - cmd[0], *cmd[1:], stdin=None, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + cmd[0], + *cmd[1:], + stdin=None, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE ) assert str(exc_info.value) == "error" assert exc_info.value.return_code == 2 @@ -80,11 +103,17 @@ async def test_run_command_async_failure(): @pytest.mark.asyncio async def test_run_command_async_failure_no_raise(): cmd = ["ls", "-z"] - with patch('asyncio.subprocess.create_subprocess_exec', - new=AsyncMock(return_value=MockProcess(returncode=2, stderr="error"))) as mock_create_subprocess_exec: + with patch( + "asyncio.subprocess.create_subprocess_exec", + new=AsyncMock(return_value=MockProcess(returncode=2, stderr="error")), + ) as mock_create_subprocess_exec: result = await run_command_async(cmd, raise_on_fail=False) mock_create_subprocess_exec.assert_called_once_with( - cmd[0], *cmd[1:], stdin=None, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + cmd[0], + *cmd[1:], + stdin=None, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE ) assert result.stderr == "error" assert result.return_code == 2 @@ -93,11 +122,16 @@ async def test_run_command_async_failure_no_raise(): @pytest.mark.asyncio async def test_run_command_async_shell_success(): cmd = "ls -l" - with patch('asyncio.subprocess.create_subprocess_shell', - new=AsyncMock(return_value=MockProcess())) as mock_create_subprocess_shell: + with patch( + "asyncio.subprocess.create_subprocess_shell", + new=AsyncMock(return_value=MockProcess()), + ) as mock_create_subprocess_shell: result = await run_command_async(cmd, shell=True) mock_create_subprocess_shell.assert_called_once_with( - cmd, stdin=None, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + cmd, + stdin=None, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, ) assert result.stdout == "success" assert result.stderr == "" @@ -107,8 +141,10 @@ async def test_run_command_async_shell_success(): @pytest.mark.asyncio async def test_run_command_async_shell_failure(): cmd = "ls -z" - with patch('asyncio.subprocess.create_subprocess_shell', - new=AsyncMock(return_value=MockProcess(returncode=2, stderr="error"))) as mock_create_subprocess_shell: + with patch( + "asyncio.subprocess.create_subprocess_shell", + new=AsyncMock(return_value=MockProcess(returncode=2, stderr="error")), + ) as mock_create_subprocess_shell: with pytest.raises(RunCommandError) as exc_info: await run_command_async(cmd, shell=True) mock_create_subprocess_shell.assert_called_once_with( @@ -123,18 +159,28 @@ async def test_run_command_async_input_and_stdin_error(): cmd = ["ls", "-l"] with pytest.raises(RunCommandError) as exc_info: await run_command_async(cmd, input="test input", stdin=StringIO("test input")) - assert str(exc_info.value) == "You cannot use both 'input' and 'stdin' on the same call." + assert ( + str(exc_info.value) + == "You cannot use both 'input' and 'stdin' on the same call." + ) assert exc_info.value.return_code == -1 @pytest.mark.asyncio async def test_run_command_async_input_and_stdin_pipe_ok(): cmd = ["ls", "-l"] - with patch('asyncio.subprocess.create_subprocess_exec', - new=AsyncMock(return_value=MockProcess())) as mock_create_subprocess_exec: - result = await run_command_async(cmd, input="test input", stdin=asyncio.subprocess.PIPE) + with patch( + "asyncio.subprocess.create_subprocess_exec", + new=AsyncMock(return_value=MockProcess()), + ) as mock_create_subprocess_exec: + result = await run_command_async( + cmd, input="test input", stdin=asyncio.subprocess.PIPE + ) mock_create_subprocess_exec.assert_called_once_with( - cmd[0], *cmd[1:], stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, + cmd[0], + *cmd[1:], + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) assert result.stdout == "success" From 0254570a6eb51afe1beebf7f8ee884392c307e93 Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Wed, 30 Oct 2024 14:07:03 -0400 Subject: [PATCH 18/41] Remove todo item --- wlanpi_core/services/network_info_service.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/wlanpi_core/services/network_info_service.py b/wlanpi_core/services/network_info_service.py index 42ebba6..cc4b5a9 100644 --- a/wlanpi_core/services/network_info_service.py +++ b/wlanpi_core/services/network_info_service.py @@ -13,10 +13,7 @@ PUBLICIP_CMD, ) from wlanpi_core.models.runcommand_error import RunCommandError -from wlanpi_core.utils.general import run_command, run_command_async - -# TODO: There is a TON of "except Exception" clauses in here that need worked on. These are generally EVIL and mask real errors that need dealt with. - +from wlanpi_core.utils.general import run_command def show_info(): output = {} From ccfbc71a9908e4d6e97cec09758688b32f8dec9f Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Mon, 4 Nov 2024 18:31:16 -0500 Subject: [PATCH 19/41] Major refactor of wireless control --- setup.py | 8 +- .../api/api_v1/endpoints/network_api.py | 26 +- wlanpi_core/app.py | 8 +- wlanpi_core/models/network/wlan/__init__.py | 0 wlanpi_core/models/network/wlan/exceptions.py | 18 + wlanpi_core/models/network/wlan/wlan_dbus.py | 68 ++ .../network/wlan/wlan_dbus_interface.py | 410 ++++++++++ wlanpi_core/schemas/network/network.py | 2 +- wlanpi_core/services/network_service.py | 700 ++---------------- wlanpi_core/utils/g_lib_loop.py | 41 + wlanpi_core/utils/general.py | 12 + wlanpi_core/utils/network.py | 42 +- 12 files changed, 655 insertions(+), 680 deletions(-) create mode 100644 wlanpi_core/models/network/wlan/__init__.py create mode 100644 wlanpi_core/models/network/wlan/exceptions.py create mode 100644 wlanpi_core/models/network/wlan/wlan_dbus.py create mode 100644 wlanpi_core/models/network/wlan/wlan_dbus_interface.py create mode 100644 wlanpi_core/utils/g_lib_loop.py diff --git a/setup.py b/setup.py index 1e1eec1..e340934 100644 --- a/setup.py +++ b/setup.py @@ -18,14 +18,14 @@ def parse_requires(_list): - requires = list() + require_list = list() trims = ["#", "piwheels.org"] for require in _list: if any(match in require for match in trims): continue - requires.append(require) - requires = list(filter(None, requires)) # remove "" from list - return requires + require_list.append(require) + require_list = list(filter(None, require_list)) # remove "" from list + return require_list # important to collect various modules in the package directory diff --git a/wlanpi_core/api/api_v1/endpoints/network_api.py b/wlanpi_core/api/api_v1/endpoints/network_api.py index bde0332..26261e6 100644 --- a/wlanpi_core/api/api_v1/endpoints/network_api.py +++ b/wlanpi_core/api/api_v1/endpoints/network_api.py @@ -205,8 +205,8 @@ async def delete_ethernet_vlan( ################################ -@router.get("/wlan/getInterfaces", response_model=network.Interfaces) -async def get_a_systemd_network_interfaces(timeout: int = API_DEFAULT_TIMEOUT): +@router.get("/wlan/interfaces", response_model=network.Interfaces) +async def get_wireless_interfaces(timeout: int = API_DEFAULT_TIMEOUT): """ Queries systemd via dbus to get the details of the currently connected network. """ @@ -219,21 +219,19 @@ async def get_a_systemd_network_interfaces(timeout: int = API_DEFAULT_TIMEOUT): 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 + "/wlan/{interface}/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 +async def do_wireless_network_scan( + 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 + return await network_service.get_wireless_network_scan_async( + scan_type, interface, timeout ) except ValidationError as ve: return Response(content=ve.error_msg, status_code=ve.status_code) @@ -242,7 +240,7 @@ async def get_a_systemd_network_scan( return Response(content="Internal Server Error", status_code=500) -@router.post("/wlan/set", response_model=network.NetworkSetupStatus) +@router.post("/wlan/{interface}/set", response_model=network.NetworkSetupStatus) async def set_a_systemd_network( setup: network.WlanInterfaceSetup, timeout: int = API_DEFAULT_TIMEOUT ): @@ -251,7 +249,7 @@ async def set_a_systemd_network( """ try: - return await network_service.set_systemd_network_addNetwork( + return await network_service.add_wireless_network( setup.interface, setup.netConfig, setup.removeAllFirst, timeout ) except ValidationError as ve: @@ -262,11 +260,11 @@ async def set_a_systemd_network( @router.get( - "/wlan/getConnected", + "/wlan/{interface}/connected", response_model=network.ConnectedNetwork, response_model_exclude_none=True, ) -async def get_a_systemd_currentNetwork_details( +async def get_current_wireless_network_details( interface: str, timeout: int = API_DEFAULT_TIMEOUT ): """ @@ -274,7 +272,7 @@ async def get_a_systemd_currentNetwork_details( """ try: - return await network_service.get_systemd_network_currentNetwork_details( + return await network_service.get_current_wireless_network_details( interface, timeout ) except ValidationError as ve: diff --git a/wlanpi_core/app.py b/wlanpi_core/app.py index c53469e..1906220 100644 --- a/wlanpi_core/app.py +++ b/wlanpi_core/app.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import logging # stdlib imports @@ -15,8 +16,13 @@ from wlanpi_core.views import api # setup logger +logging.basicConfig(level=logging.DEBUG, + format="%(asctime)s - %(levelname)s - %(module)s:%(funcName)s:%(lineno)d - %(message)s") + log_config = uvicorn.config.LOGGING_CONFIG -log_config["formatters"]["access"]["fmt"] = "%(asctime)s - %(levelname)s - %(message)s" +log_config["disable_existing_loggers"] = False +log_config["formatters"]["access"]["fmt"] = "%(asctime)s - %(levelprefix)s - %(message)s" +log_config["formatters"]["default"]["fmt"] = "%(asctime)s - %(levelprefix)s - %(module)s:%(funcName)s:%(lineno)d - %(message)s" def create_app(): diff --git a/wlanpi_core/models/network/wlan/__init__.py b/wlanpi_core/models/network/wlan/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/wlanpi_core/models/network/wlan/exceptions.py b/wlanpi_core/models/network/wlan/exceptions.py new file mode 100644 index 0000000..18b1c7e --- /dev/null +++ b/wlanpi_core/models/network/wlan/exceptions.py @@ -0,0 +1,18 @@ + +class WlanDBUSException(Exception): + pass + +class WlanDBUSInterfaceException(WlanDBUSException): + pass + +class WDIScanError(WlanDBUSInterfaceException): + pass + +class WDIConnectionException(WlanDBUSInterfaceException): + pass + +class WDIDisconnectedException(WDIConnectionException): + pass + +class WDIAuthenticationError(WDIConnectionException): + pass diff --git a/wlanpi_core/models/network/wlan/wlan_dbus.py b/wlanpi_core/models/network/wlan/wlan_dbus.py new file mode 100644 index 0000000..e467aa1 --- /dev/null +++ b/wlanpi_core/models/network/wlan/wlan_dbus.py @@ -0,0 +1,68 @@ +import logging +import dbus + +from wlanpi_core.constants import ( + + WPAS_DBUS_INTERFACE, + WPAS_DBUS_INTERFACES_INTERFACE, + WPAS_DBUS_OPATH, + WPAS_DBUS_SERVICE, API_DEFAULT_TIMEOUT, +) +from wlanpi_core.models.network.wlan.wlan_dbus_interface import WlanDBUSInterface + + +class WlanDBUS: + + DBUS_IFACE = dbus.PROPERTIES_IFACE + DEFAULT_TIMEOUT = API_DEFAULT_TIMEOUT + def __init__(self): + self.logger = logging.getLogger(__name__) + + self.default_timeout = API_DEFAULT_TIMEOUT + + self.main_dbus_loop = dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + + + self.bus = dbus.SystemBus(mainloop=self.main_dbus_loop) + self.wpa_supplicant_proxy = self.bus.get_object(WPAS_DBUS_SERVICE, WPAS_DBUS_OPATH) + self.wpa_supplicant = dbus.Interface(self.wpa_supplicant_proxy, WPAS_DBUS_INTERFACE) + self.wpas = self.wpa_supplicant + # setup_DBus_Supplicant_Access(interface) + self.interfaces = {} + + def get_interface(self, interface) -> WlanDBUSInterface: + if not interface in self.interfaces: + new_interface = WlanDBUSInterface(wpa_supplicant=self.wpa_supplicant, system_bus=self.bus, interface_name=interface, default_timeout=self.DEFAULT_TIMEOUT) + self.interfaces[interface] = new_interface + return self.interfaces[interface] + + + def fetch_interfaces(self, wpas_obj): + available_interfaces = [] + ifaces = wpas_obj.Get( + WPAS_DBUS_INTERFACE, "Interfaces", dbus_interface=self.DBUS_IFACE + ) + self.logger.debug("InterfacesRequested: %s" % ifaces) + for path in ifaces: + self.logger.debug("Resolving Interface Path: %s" % path) + if_obj = self.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}) + self.logger.debug(f"Found interface : {ifname}") + return available_interfaces + + def get_systemd_network_interfaces(self, timeout: int = DEFAULT_TIMEOUT): + """ + Queries systemd via dbus to get a list of the available interfaces. + """ + + wpas_obj = self.bus.get_object(WPAS_DBUS_SERVICE, WPAS_DBUS_OPATH) + self.logger.debug("Checking available interfaces", 3) + available_interfaces = self.fetch_interfaces(wpas_obj) + self.logger.debug(f"Available interfaces: {available_interfaces}", 3) + return available_interfaces + diff --git a/wlanpi_core/models/network/wlan/wlan_dbus_interface.py b/wlanpi_core/models/network/wlan/wlan_dbus_interface.py new file mode 100644 index 0000000..a541618 --- /dev/null +++ b/wlanpi_core/models/network/wlan/wlan_dbus_interface.py @@ -0,0 +1,410 @@ +import asyncio +import logging +import time +from datetime import datetime +from typing import Optional, Union + +import dbus.proxies +from dbus import SystemBus, Interface, DBusException + +from gi.repository import GLib + +from wlanpi_core.constants import WPAS_DBUS_SERVICE, WPAS_DBUS_INTERFACES_INTERFACE, WPAS_DBUS_BSS_INTERFACE +from wlanpi_core.models.network.wlan.exceptions import WlanDBUSInterfaceException, WDIConnectionException, \ + WDIAuthenticationError, WDIDisconnectedException, WDIScanError +from wlanpi_core.schemas.network import network, WlanConfig, ScanResults +from wlanpi_core.utils.g_lib_loop import GLibLoop +from wlanpi_core.utils.general import byte_array_to_string +from wlanpi_core.utils.network import renew_dhcp, get_ip_address + + +class WlanDBUSInterface: + ALLOWED_SCAN_TYPES = [ + "active", + "passive", + ] + DBUS_IFACE = dbus.PROPERTIES_IFACE + + def __init__(self, wpa_supplicant:dbus.proxies.Interface, system_bus:SystemBus, interface_name:str, default_timeout:int=20): + self.logger = logging.getLogger(__name__) + self.logger.info(f"Initializing {__name__}") + print(f"Class is {__name__}") + self.wpa_supplicant:Interface = wpa_supplicant + self.interface_name:str = interface_name + self.system_bus:SystemBus = system_bus + self.default_timeout = default_timeout + + interface_path = None + self.logger.debug(f'Getting interface {interface_name}') + try: + interface_path = self.wpa_supplicant.GetInterface(self.interface_name) + except dbus.DBusException as exc: + if not str(exc).startswith("fi.w1.wpa_supplicant1.InterfaceUnknown:"): + raise WlanDBUSInterfaceException(f"Interface unknown : {exc}") from exc + try: + interface_path = self.wpa_supplicant.CreateInterface({"Ifname": self.interface_name, "Driver": "test"}) + time.sleep(1) + except dbus.DBusException as exc: + if not str(exc).startswith("fi.w1.wpa_supplicant1.InterfaceExists:"): + raise WlanDBUSInterfaceException( + f"Interface cannot be created : {exc}" + ) from exc + time.sleep(1) + self.logger.debug(interface_path) + self.interface_dbus_object = self.system_bus.get_object(WPAS_DBUS_SERVICE, interface_path) + self.interface_dbus_interface = dbus.Interface(self.interface_dbus_object, WPAS_DBUS_INTERFACES_INTERFACE) + + + # Transient vars + self.last_scan = None + + + def _get_from_wpa_supplicant_network(self, bss:str, key:str): + net_obj = self.system_bus.get_object(WPAS_DBUS_SERVICE, bss) + return net_obj.Get(WPAS_DBUS_BSS_INTERFACE, key, dbus_interface=self.DBUS_IFACE ) + + def _get_from_wpa_supplicant_interface(self, key:str): + return self.interface_dbus_object.Get(WPAS_DBUS_INTERFACES_INTERFACE, key, dbus_interface=dbus.PROPERTIES_IFACE) + + def _get_bssid_path(self): + return self._get_from_wpa_supplicant_interface("CurrentBSS") + + def get_bss(self, bss): + """ + Queries DBUS_BSS_INTERFACE through dbus for a BSS Path + + Example path: /fi/w1/wpa_supplicant1/Interfaces/0/BSSs/567 + """ + + try: + # Get the BSSID from the byte array + val = self._get_from_wpa_supplicant_network(bss, "BSSID") + bssid = "" + for item in val: + bssid = bssid + ":%02x" % item + bssid = bssid[1:] + + # Get the SSID from the byte array + ssid = byte_array_to_string( + self._get_from_wpa_supplicant_network(bss, "SSID") + ) + + # # Get the WPA Type from the byte array + # val = self._get_from_wpa_supplicant_network(bss, "WPA") + # if len(val["KeyMgmt"]) > 0: + # pass + + # Get the RSN Info from the byte array + val = self._get_from_wpa_supplicant_network(bss, "RSN") + key_mgmt = "/".join([str(r) for r in val["KeyMgmt"]]) + + # Get the Frequency from the byte array + freq = self._get_from_wpa_supplicant_network(bss, "Frequency") + + # Get the RSSI from the byte array + signal = self._get_from_wpa_supplicant_network(bss, "Signal") + + # Get the Phy Rates from the byte array + rates = self._get_from_wpa_supplicant_network(bss, "Rates") + min_rate = min(rates) if len(rates) > 0 else 0 + + # # Get the IEs from the byte array + # ies = self._get_from_wpa_supplicant_network(bss, "IEs") + # self.logger.debug(f"IEs: {ies}") + + return { + "ssid": ssid, + "bssid": bssid, + "key_mgmt": key_mgmt, + "signal": signal, + "freq": freq, + "minrate": min_rate, + } + + except DBusException as e: + raise WlanDBUSInterfaceException() from e + except ValueError as error: + raise WlanDBUSInterfaceException(error) from error + + def pretty_print_bss(self, bss_path): + bss_details = self.get_bss(bss_path) + if bss_details: + ssid = bss_details["ssid"] if bss_details["ssid"] else "" + bssid = bss_details["bssid"] + freq = bss_details["freq"] + rssi = bss_details["signal"] + key_mgmt = bss_details["key_mgmt"] + minrate = bss_details["minrate"] + + result = f"[{bssid}] {freq}, {rssi}dBm, {minrate} | {ssid} [{key_mgmt}] " + return result + else: + return f"BSS Path {bss_path} could not be resolved" + + def get_current_network_details(self) -> dict[str, Optional[dict[str, Union[str,int]]]]: + try: + + bssid_path = self._get_bssid_path() + + if bssid_path != "/": + res = self.get_bss(bssid_path) + return {"connectedStatus": True, "connectedNet": res} + else: + return {"connectedStatus": False, "connectedNet": None} + except DBusException as err: + self.logger.error(f"DBUS error while getting BSSID: {str(err)}") + raise WlanDBUSInterfaceException(f"DBUS error while getting BSSID: {str(err)}") from err + + async def get_network_scan(self, scan_type: str, timeout: Optional[int] = None) -> ScanResults: + self.logger.info("Starting network scan...") + if not timeout: + timeout = self.default_timeout + if scan_type not in self.ALLOWED_SCAN_TYPES: + raise ValueError("Invalid scan type") + + scan_config = dbus.Dictionary({"Type": scan_type}, signature="sv") + + # Get a handle for the main loop so we can start and stop it + with GLibLoop() as glib_loop: + # Create a new Future object to manage the async execution. + async_loop = asyncio.get_running_loop() + done_future = async_loop.create_future() + + def timeout_handler(*args): + done_future.set_exception(TimeoutError(f"Scan timed out after {timeout} seconds: {args}")) + glib_loop.finish() + + def done_handler(success): + self.logger.debug(f"Scan done: success={success}") + # global scan + local_scan = [] + res = self._get_from_wpa_supplicant_interface("BSSs") + self.logger.debug("Scanned wireless networks:") + for opath in res: + bss = self.get_bss(opath) + if bss: + local_scan.append(bss) + self.last_scan = local_scan + self.logger.debug(f"A Scan has completed with {len(local_scan)} results", ) + self.logger.debug(local_scan) + done_future.set_result(local_scan) + glib_loop.finish() + + try: + scan_handler = self.system_bus.add_signal_receiver( + done_handler, + dbus_interface=WPAS_DBUS_INTERFACES_INTERFACE, + signal_name="ScanDone", + ) + + # Start Scan + self.interface_dbus_interface.Scan(scan_config) + # exit after waiting a short time for the signal + glib_loop.start_timeout(seconds=timeout, callback=timeout_handler) + + glib_loop.run() + scan_results = await done_future + except (TimeoutError, GLib.Error, DBusException) as e: + self.logger.warning("Error while scanning: %s", e) + raise WDIScanError from e + finally: + scan_handler.remove() + return scan_results + + async def add_network(self, wlan_config:WlanConfig, remove_others:bool=False, timeout: Optional[int] = None): + if not timeout: + timeout = self.default_timeout + self.logger.info("Configuring WLAN on %s with config: %s", self.interface_name, wlan_config) + + # Create a new Future object to manage the async execution. + async_loop = asyncio.get_running_loop() + add_network_future = async_loop.create_future() + + response = network.NetworkSetupLog(selectErr="", eventLog=[]) + + self.logger.debug("Configuring DBUS interface") + + connection_events = [] + + # Get a handle for the main loop so we can start and stop it + with GLibLoop() as glib_loop: + + def timeout_callback(*args): + add_network_future.set_exception(TimeoutError(f"Connection timed out after {timeout} seconds: {args}")) + glib_loop.finish() + + def network_selected_callback(selected_network): + self.logger.info(f"Network Selected (Signal) : {selected_network}") + + def properties_changed_callback(properties): + self.logger.debug(f"PropertiesChanged: {properties}") + + state = properties.get("State", None) + disconnect_reason = properties.get("DisconnectReason", None) + if state: + if state == "completed": + time.sleep(2) + # Is sleeping here really the answer? + if self.interface_name: + renew_dhcp(self.interface_name) + ipaddr = get_ip_address(self.interface_name) + connection_events.append( + network.NetworkEvent( + event=f"IP Address {ipaddr} on {self.interface_name}", + time=f"{datetime.now()}", + ) + ) + self.logger.debug(f"Connection Completed: State: {state}") + # End + add_network_future.set_result(state) + glib_loop.finish() + elif state == "4way_handshake": + self.logger.debug(f"PropertiesChanged: State: {state}") + if properties.get("CurrentBSS"): + self.logger.debug(f"Handshake attempt to: {self.pretty_print_bss(properties['CurrentBSS'])}") + else: + self.logger.debug(f"PropertiesChanged: State: {state}") + connection_events.append( + network.NetworkEvent(event=f"{state}", time=f"{datetime.now()}") + ) + elif disconnect_reason: + self.logger.debug(f"Disconnect Reason: {disconnect_reason}") + if disconnect_reason: + if disconnect_reason in [3, -3]: + connection_events.append( + network.NetworkEvent(event="Station is Leaving", time=f"{datetime.now()}") + ) + elif disconnect_reason == 15: + event = network.NetworkEvent( + event="4-Way Handshake Fail (check password)", + time=f"{datetime.now()}", + ) + connection_events.append(event + ) + # End + add_network_future.set_exception(WDIAuthenticationError(event)) + glib_loop.finish() + else: + event = network.NetworkEvent( + event=f"Error: Disconnected [{disconnect_reason}]", + time=f"{datetime.now()}", + ) + connection_events.append(event) + add_network_future.set_exception(WDIDisconnectedException(event)) + glib_loop.finish() + + # 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)) + + current_auth_mode = properties.get("CurrentAuthMode") + if current_auth_mode is not None: + self.logger.debug(f"Current Auth Mode is {current_auth_mode}") + + auth_status = properties.get("AuthStatusCode") + if auth_status is not None: + self.logger.debug(f"Auth Status: {auth_status}") + if auth_status == 0: + connection_events.append( + network.NetworkEvent(event="authenticated", time=f"{datetime.now()}") + ) + else: + connection_events.append( + network.NetworkEvent( + event=f"authentication failed with code {auth_status}", + time=f"{datetime.now()}", + ) + ) + + select_err = None + bssid = None + try: + network_change_handler = self.system_bus.add_signal_receiver( + network_selected_callback, + dbus_interface=WPAS_DBUS_INTERFACES_INTERFACE, + signal_name="NetworkSelected", + ) + + properties_change_handler = self.system_bus.add_signal_receiver( + properties_changed_callback, + dbus_interface=WPAS_DBUS_INTERFACES_INTERFACE, + signal_name="PropertiesChanged", + ) + + + # Remove all other connections if requested + if remove_others: + self.logger.info("Removing existing connections") + self.interface_dbus_interface.RemoveAllNetworks() + + wlan_config_cleaned = {k: v for k, v in wlan_config if v is not None} + wlan_config_dbus = dbus.Dictionary(wlan_config_cleaned, signature="sv") + netw = self.interface_dbus_interface.AddNetwork(wlan_config_dbus) + + if netw != "/": + self.logger.debug("Valid network entry received") + + # Select this network using its full path name + select_err = self.interface_dbus_interface.SelectNetwork(netw) + self.logger.debug(f"Network selected with result: {select_err}") + + self.logger.debug(f"Logged connection Events: {connection_events}") + if select_err is None: + # exit after waiting a short time for the signal + glib_loop.start_timeout(seconds=timeout,callback=timeout_callback) + # The network selection has been successfully applied (does not mean a network is selected) + glib_loop.run() + + try: + connect_result = await add_network_future + except WDIConnectionException as err: + self.logger.error(f"Failed to connect: {err}") + connect_result = None + + if connect_result == "completed": + self.logger.info("Connection to network completed. Verifying connection...") + + # Check the current BSSID post connection + bssid_path = self._get_bssid_path() + if bssid_path != "/": + bssid = self.get_bss(bssid_path) + self.logger.debug(f"Logged Events: {connection_events}") + if bssid: + self.logger.info("Connected") + status = "connected" + else: + self.logger.warning("Connection failed. Post connection check returned no network") + status = "connection_lost" + else: + + self.logger.warning("Connection failed. Aborting") + status = "connection_lost" + else: + self.logger.warning("Connection failed. Aborting") + status = f"connection failed:{connect_result}" + + except DBusException as de: + self.logger.error(f"DBUS Error State: {de}", 0) + # raise WlanDBUSInterfaceException from de + finally: + if network_change_handler: + network_change_handler.remove() + if properties_change_handler: + properties_change_handler.remove() + + response.eventLog = connection_events + if select_err and select_err is not None: + response.selectErr = str(select_err) + else: + response.selectErr = "" + + return { + "status": status, + "response": response, + "connectedNet": bssid, + "input": wlan_config.ssid, + } + diff --git a/wlanpi_core/schemas/network/network.py b/wlanpi_core/schemas/network/network.py index f4fc6a1..d517e33 100644 --- a/wlanpi_core/schemas/network/network.py +++ b/wlanpi_core/schemas/network/network.py @@ -102,7 +102,7 @@ class NetworkSetupLog(BaseModel): class NetworkSetupStatus(BaseModel): status: str = Field(example="connected") response: NetworkSetupLog - connectedNet: ScanItem + connectedNet: Optional[ScanItem] input: str diff --git a/wlanpi_core/services/network_service.py b/wlanpi_core/services/network_service.py index 74a9601..72d4f91 100644 --- a/wlanpi_core/services/network_service.py +++ b/wlanpi_core/services/network_service.py @@ -1,422 +1,13 @@ -import time -from datetime import datetime +import logging +from enum import Enum +from typing import Optional -import dbus -from dbus import Interface -from dbus.exceptions import DBusException -from gi.repository import GLib -from wlanpi_core.constants import ( - WPAS_DBUS_BSS_INTERFACE, - WPAS_DBUS_INTERFACE, - WPAS_DBUS_INTERFACES_INTERFACE, - WPAS_DBUS_OPATH, - WPAS_DBUS_SERVICE, -) -from wlanpi_core.models.runcommand_error import RunCommandError +from wlanpi_core.models.network.wlan.exceptions import WlanDBUSException +from wlanpi_core.models.network.wlan.wlan_dbus import WlanDBUS +from wlanpi_core.models.network.wlan.wlan_dbus_interface import WlanDBUSInterface from wlanpi_core.models.validation_error import ValidationError from wlanpi_core.schemas import network -from wlanpi_core.utils.general import run_command -from wlanpi_core.utils.network import get_interface_addresses - -# For running locally (not in API) -# import asyncio - -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 - run_command(["sudo", "dhclient", "-r", interface], raise_on_fail=True) - time.sleep(3) - # Obtain a new DHCP lease - run_command(["sudo", "dhclient", interface], raise_on_fail=True) - except RunCommandError as err: - debug_print( - f"Failed to renew DHCP. Code:{err.return_code}, Error: {err.error_msg}", 1 - ) - - -def get_ip_address(interface): - """ - Extract the IP Address from the linux ip add show command - """ - try: - res = get_interface_addresses(interface)[interface]["inet"] - if len(res): - return res[0] - return None - except RunCommandError as err: - debug_print( - f"Failed to get IP address. Code:{err.return_code}, Error: {err.error_msg}", - 1, - ) - - -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 "" - 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) """ @@ -424,271 +15,66 @@ def setup_DBus_Supplicant_Access(interface): """ -async def get_systemd_network_interfaces(timeout: network.APIConfig): +async def get_systemd_network_interfaces(timeout: int): """ 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} + try: + wlan_dbus = WlanDBUS() + available_interfaces = wlan_dbus.get_systemd_network_interfaces(timeout=timeout) + logging.info(f"Available interfaces: {available_interfaces}") + return {"interfaces": available_interfaces} + except WlanDBUSException as err: + # Need to Split exceptions into validation and actual failures + raise ValidationError(str(err), status_code=400) from err -async def get_async_systemd_network_scan( - type: str, interface: network.Interface, timeout: network.APIConfig +async def get_wireless_network_scan_async( + scan_type: Enum(*WlanDBUSInterface.ALLOWED_SCAN_TYPES), interface: str, timeout:int ): """ 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", + try: + wlan_dbus = WlanDBUS() + clean_scan_type = scan_type.strip().lower() if scan_type else None + if not clean_scan_type or (clean_scan_type not in WlanDBUSInterface.ALLOWED_SCAN_TYPES): + raise ValidationError( + f"scan type must be one of: {', '.join(WlanDBUSInterface.ALLOWED_SCAN_TYPES)}", + status_code=400 ) - 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, + interface_obj = wlan_dbus.get_interface(interface) + return {"nets": await interface_obj.get_network_scan(scan_type, timeout=timeout)} + except [WlanDBUSException, ValueError] as err: + # Need to Split exceptions into validation and actual failures + raise ValidationError(str(err), status_code=400) from err + +async def add_wireless_network( + interface: str, + net_config: network.WlanConfig, + remove_all_first: bool, + timeout: Optional[int], ): """ 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) - - timeout_check = 0 - if selectErr == None: - # The network selection has been successsfully applied (does not mean a network is selected) - main_context = GLib.MainContext.default() - - 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) + wlan_dbus = WlanDBUS() + return await wlan_dbus.get_interface(interface).add_network(wlan_config=net_config, remove_others=remove_all_first, timeout=timeout) 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 +async def get_current_wireless_network_details( + interface: str, timeout: int ): """ 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)) + wlan_dbus = WlanDBUS() + return wlan_dbus.get_interface(interface).get_current_network_details() + except WlanDBUSException as err: + raise ValidationError(str(err), status_code=400) from err -if __name__ == "__main__": - print(get_ip_address("eth0")) diff --git a/wlanpi_core/utils/g_lib_loop.py b/wlanpi_core/utils/g_lib_loop.py new file mode 100644 index 0000000..63dc2b2 --- /dev/null +++ b/wlanpi_core/utils/g_lib_loop.py @@ -0,0 +1,41 @@ +from typing import Optional + +from gi.repository import GLib + +class GLibLoop: + + def __init__(self, loop: Optional[GLib.MainLoop]=None, timeout_seconds: Optional[int]=None, timeout_callback: Optional[callable]=None, timeout_callback_args: Optional[list]=None, timeout_callback_kwargs:Optional[dict]=None): + self.loop = loop if loop else GLib.MainLoop() + self.timeout_seconds = timeout_seconds + self.timeout_callback = timeout_callback + self.timeout_callback_args = timeout_callback_args + self.timeout_callback_kwargs = timeout_callback_kwargs + self.timeout_source: Optional[GLib.Source] = None + self.timeout_source_attachment: Optional[int] = None + + def start_timeout(self, seconds: Optional[int]=None, callback: Optional[callable]=None, *args, **kwargs): + self.timeout_source = GLib.timeout_source_new_seconds(seconds if seconds else self.timeout_seconds) + self.timeout_source.set_callback( + callback if callback else self.timeout_callback, + *(args or self.timeout_callback_args or []), + **(kwargs or self.timeout_callback_kwargs or {}) + ) + self.timeout_source_attachment = self.timeout_source.attach(self.loop.get_context()) + + def stop_timeout(self): + if self.timeout_source and self.timeout_source_attachment: + self.timeout_source.remove(self.timeout_source_attachment) + + def finish(self): + self.stop_timeout() + self.loop.quit() + + def run(self, *args, **kwargs): + self.loop.run(*args, **kwargs) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.stop_timeout() + self.loop.quit() \ No newline at end of file diff --git a/wlanpi_core/utils/general.py b/wlanpi_core/utils/general.py index bc62114..c7b1edb 100644 --- a/wlanpi_core/utils/general.py +++ b/wlanpi_core/utils/general.py @@ -223,3 +223,15 @@ def get_current_unix_timestamp() -> float: """ ms = datetime.datetime.now() return time.mktime(ms.timetuple()) * 1000 + + +def byte_array_to_string(s) -> str: + """Converts a byte array to string, replacing non-printable characters with spaces.""" + r = "" + for c in s: + if 32 <= c < 127: + r += "%c" % c + else: + r += " " + # r += urllib.quote(chr(c)) + return r diff --git a/wlanpi_core/utils/network.py b/wlanpi_core/utils/network.py index 661a0d2..6127922 100644 --- a/wlanpi_core/utils/network.py +++ b/wlanpi_core/utils/network.py @@ -1,5 +1,9 @@ +import logging +import time from typing import Any, Optional +from wlanpi_core.models.runcommand_error import RunCommandError + from wlanpi_core.utils.general import run_command @@ -38,12 +42,44 @@ def get_interface_address_data(interface: Optional[str] = None) -> list[dict[str def get_interface_addresses( interface: Optional[str] = None, -) -> dict[str, dict[str, str]]: +) -> dict[str, dict[str, list[str]]]: res = get_interface_address_data(interface=interface) out_obj = {} for item in res: if item["ifname"] not in out_obj: - out_obj[item["ifname"]] = {"inet": [], "inet6": []} + ifname:str = item["ifname"] + out_obj[ifname] = {"inet": [], "inet6": []} for addr in item["addr_info"]: - out_obj[item["ifname"]][addr["family"]].append(addr["local"]) + ifname: str = item["ifname"] + out_obj[ifname][addr["family"]].append(addr["local"]) return out_obj + + +def get_ip_address(interface): + """ + Extract the IPv4 IP Address from the linux ip add show command + """ + try: + res = get_interface_addresses(interface)[interface]["inet"] + if len(res): + return res[0] + return None + except RunCommandError as err: + logging.warning(f"Failed to get IP address. Code:{err.return_code}, Error: {err.error_msg}") + return None + + +def renew_dhcp(interface) -> None: + """ + Uses dhclient to release and request a new DHCP lease + """ + try: + # Release the current DHCP lease + run_command(["sudo", "dhclient", "-r", interface], raise_on_fail=True) + time.sleep(3) + # Obtain a new DHCP lease + run_command(["sudo", "dhclient", interface], raise_on_fail=True) + except RunCommandError as err: + logging.warning(f"Failed to renew DHCP. Code:{err.return_code}, Error: {err.error_msg}") + return None + From bf84f1ff94cb8de3517a715400345da39c5b0ff0 Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Mon, 4 Nov 2024 20:32:30 -0500 Subject: [PATCH 20/41] Multiple adapter control should work now. --- debian/wlanpi-core.install | 3 +- debian/wpa_supplicant@wlan0.service | 20 ---- .../wpa_supplicant/wpa_supplicant-wlan0.conf | 64 ----------- .../api/api_v1/endpoints/network_api.py | 102 +++++++++++++++++- .../network/wlan/wlan_dbus_interface.py | 43 ++++++-- wlanpi_core/schemas/network/network.py | 3 +- wlanpi_core/services/network_service.py | 57 +++++++++- 7 files changed, 189 insertions(+), 103 deletions(-) delete mode 100644 debian/wpa_supplicant@wlan0.service delete mode 100644 install/etc/wpa_supplicant/wpa_supplicant-wlan0.conf diff --git a/debian/wlanpi-core.install b/debian/wlanpi-core.install index 751fea3..da48490 100644 --- a/debian/wlanpi-core.install +++ b/debian/wlanpi-core.install @@ -1,5 +1,4 @@ /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 -/install/etc/wpa_supplicant/wpa_supplicant-wlan0.conf /etc/wpa_supplicant \ No newline at end of file +/install/etc/wlanpi-core/nginx/unlink.sh /etc/wlanpi-core/scripts \ No newline at end of file diff --git a/debian/wpa_supplicant@wlan0.service b/debian/wpa_supplicant@wlan0.service deleted file mode 100644 index c456a38..0000000 --- a/debian/wpa_supplicant@wlan0.service +++ /dev/null @@ -1,20 +0,0 @@ -#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 deleted file mode 100644 index f4a9471..0000000 --- a/install/etc/wpa_supplicant/wpa_supplicant-wlan0.conf +++ /dev/null @@ -1,64 +0,0 @@ -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/wlanpi_core/api/api_v1/endpoints/network_api.py b/wlanpi_core/api/api_v1/endpoints/network_api.py index 26261e6..4bc3634 100644 --- a/wlanpi_core/api/api_v1/endpoints/network_api.py +++ b/wlanpi_core/api/api_v1/endpoints/network_api.py @@ -8,7 +8,7 @@ from wlanpi_core.models.validation_error import ValidationError from wlanpi_core.schemas import network from wlanpi_core.schemas.network.config import NetworkConfigResponse -from wlanpi_core.schemas.network.network import IPInterface, IPInterfaceAddress +from wlanpi_core.schemas.network.network import IPInterface, IPInterfaceAddress, ScanItem from wlanpi_core.services import network_ethernet_service, network_service router = APIRouter() @@ -240,9 +240,9 @@ async def do_wireless_network_scan( return Response(content="Internal Server Error", status_code=500) -@router.post("/wlan/{interface}/set", response_model=network.NetworkSetupStatus) -async def set_a_systemd_network( - setup: network.WlanInterfaceSetup, timeout: int = API_DEFAULT_TIMEOUT +@router.post("/wlan/{interface}/add_network", response_model=network.NetworkSetupStatus) +async def add_wireless_network( + interface:str, setup: network.WlanInterfaceSetup, timeout: int = API_DEFAULT_TIMEOUT ): """ Queries systemd via dbus to set a single network. @@ -250,7 +250,7 @@ async def set_a_systemd_network( try: return await network_service.add_wireless_network( - setup.interface, setup.netConfig, setup.removeAllFirst, timeout + interface, setup.netConfig, setup.removeAllFirst, timeout ) except ValidationError as ve: return Response(content=ve.error_msg, status_code=ve.status_code) @@ -280,3 +280,95 @@ async def get_current_wireless_network_details( except Exception as ex: log.error(ex) return Response(content="Internal Server Error", status_code=500) + +@router.post( + "/wlan/{interface}/disconnect", + response_model=None, + response_model_exclude_none=True, +) +async def disconnect_wireless_network( + 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.disconnect_wireless_network( + 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.get( + "/wlan/{interface}/networks", + response_model=dict[int, ScanItem], + response_model_exclude_none=True, +) +async def get_all_wireless_networks( + 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.networks( + interface + ) + 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.delete( + "/wlan/{interface}/networks/{network_id}", + response_model=None, + response_model_exclude_none=True, +) +async def disconnect_wireless_network( + interface: str, network_id: int +): + """ + Queries systemd via dbus to get the details of the currently connected network. + """ + + try: + return await network_service.remove_network( + interface, network_id, + ) + 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/{interface}/networks/all/remove", + response_model=None, + response_model_exclude_none=True, +) +async def remove_all_wireless_networks( + interface: str +): + """ + Removes all networks on an interface + """ + + try: + return await network_service.remove_all_networks( + interface + ) + 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/models/network/wlan/wlan_dbus_interface.py b/wlanpi_core/models/network/wlan/wlan_dbus_interface.py index a541618..e0ce36c 100644 --- a/wlanpi_core/models/network/wlan/wlan_dbus_interface.py +++ b/wlanpi_core/models/network/wlan/wlan_dbus_interface.py @@ -6,6 +6,7 @@ import dbus.proxies from dbus import SystemBus, Interface, DBusException +from dbus.proxies import ProxyObject from gi.repository import GLib @@ -42,7 +43,7 @@ def __init__(self, wpa_supplicant:dbus.proxies.Interface, system_bus:SystemBus, if not str(exc).startswith("fi.w1.wpa_supplicant1.InterfaceUnknown:"): raise WlanDBUSInterfaceException(f"Interface unknown : {exc}") from exc try: - interface_path = self.wpa_supplicant.CreateInterface({"Ifname": self.interface_name, "Driver": "test"}) + interface_path = self.wpa_supplicant.CreateInterface({"Ifname": self.interface_name, "Driver": "nl80211"}) time.sleep(1) except dbus.DBusException as exc: if not str(exc).startswith("fi.w1.wpa_supplicant1.InterfaceExists:"): @@ -51,20 +52,23 @@ def __init__(self, wpa_supplicant:dbus.proxies.Interface, system_bus:SystemBus, ) from exc time.sleep(1) self.logger.debug(interface_path) - self.interface_dbus_object = self.system_bus.get_object(WPAS_DBUS_SERVICE, interface_path) - self.interface_dbus_interface = dbus.Interface(self.interface_dbus_object, WPAS_DBUS_INTERFACES_INTERFACE) + self.supplicant_dbus_object = self.system_bus.get_object(WPAS_DBUS_SERVICE, interface_path) + self.supplicant_dbus_interface = dbus.Interface(self.supplicant_dbus_object, WPAS_DBUS_INTERFACES_INTERFACE) # Transient vars self.last_scan = None + def _get_dbus_object(self, object_path:str)->ProxyObject: + return self.system_bus.get_object(WPAS_DBUS_SERVICE, object_path) + def _get_from_wpa_supplicant_network(self, bss:str, key:str): net_obj = self.system_bus.get_object(WPAS_DBUS_SERVICE, bss) return net_obj.Get(WPAS_DBUS_BSS_INTERFACE, key, dbus_interface=self.DBUS_IFACE ) def _get_from_wpa_supplicant_interface(self, key:str): - return self.interface_dbus_object.Get(WPAS_DBUS_INTERFACES_INTERFACE, key, dbus_interface=dbus.PROPERTIES_IFACE) + return self.supplicant_dbus_object.Get(WPAS_DBUS_INTERFACES_INTERFACE, key, dbus_interface=dbus.PROPERTIES_IFACE) def _get_bssid_path(self): return self._get_from_wpa_supplicant_interface("CurrentBSS") @@ -198,7 +202,7 @@ def done_handler(success): ) # Start Scan - self.interface_dbus_interface.Scan(scan_config) + self.supplicant_dbus_interface.Scan(scan_config) # exit after waiting a short time for the signal glib_loop.start_timeout(seconds=timeout, callback=timeout_handler) @@ -338,17 +342,17 @@ def properties_changed_callback(properties): # Remove all other connections if requested if remove_others: self.logger.info("Removing existing connections") - self.interface_dbus_interface.RemoveAllNetworks() + self.supplicant_dbus_interface.RemoveAllNetworks() wlan_config_cleaned = {k: v for k, v in wlan_config if v is not None} wlan_config_dbus = dbus.Dictionary(wlan_config_cleaned, signature="sv") - netw = self.interface_dbus_interface.AddNetwork(wlan_config_dbus) + netw = self.supplicant_dbus_interface.AddNetwork(wlan_config_dbus) if netw != "/": self.logger.debug("Valid network entry received") # Select this network using its full path name - select_err = self.interface_dbus_interface.SelectNetwork(netw) + select_err = self.supplicant_dbus_interface.SelectNetwork(netw) self.logger.debug(f"Network selected with result: {select_err}") self.logger.debug(f"Logged connection Events: {connection_events}") @@ -408,3 +412,26 @@ def properties_changed_callback(properties): "input": wlan_config.ssid, } + + def disconnect(self) -> None: + """ Disconnects the given interface from any network it may be associated with""" + self.logger.info("Disconnecting WLAN on %s", self.interface_name) + self.supplicant_dbus_interface.Disconnect() + + def remove_all_networks(self) -> None: + """ Removes all networks from the interface""" + self.logger.info("Removing all Networks onon %s", self.interface_name) + self.supplicant_dbus_interface.RemoveAllNetworks() + + def remove_network(self, network_id:int) -> None: + """ Removes a single network from the interface""" + self.logger.info("Removing network %s on %s", network_id, self.interface_name) + self.supplicant_dbus_interface.RemoveNetwork(network_id) + + def networks(self): + """ Returns a list of available networks """ + networks = {} + for network_path in self._get_from_wpa_supplicant_interface('Networks'): + networks[network_path] = self._get_dbus_object(network_path).Get('fi.w1.wpa_supplicant1.Network', "Properties",dbus_interface=dbus.PROPERTIES_IFACE) + return networks + diff --git a/wlanpi_core/schemas/network/network.py b/wlanpi_core/schemas/network/network.py index d517e33..cda3bde 100644 --- a/wlanpi_core/schemas/network/network.py +++ b/wlanpi_core/schemas/network/network.py @@ -62,7 +62,7 @@ class IPInterface(BaseModel, extra=Extra.allow): addr_info: list[IPInterfaceAddress] = Field(examples=[]) -class ScanItem(BaseModel): +class ScanItem(BaseModel, extra=Extra.allow): ssid: str = Field(example="A Network") bssid: str = Field(example="11:22:33:44:55") key_mgmt: str = Field(example="wpa-psk") @@ -84,7 +84,6 @@ class WlanConfig(BaseModel): class WlanInterfaceSetup(BaseModel): - interface: str = Field(example="wlan0") netConfig: WlanConfig removeAllFirst: bool diff --git a/wlanpi_core/services/network_service.py b/wlanpi_core/services/network_service.py index 72d4f91..1cbc3cd 100644 --- a/wlanpi_core/services/network_service.py +++ b/wlanpi_core/services/network_service.py @@ -8,7 +8,7 @@ from wlanpi_core.models.network.wlan.wlan_dbus_interface import WlanDBUSInterface from wlanpi_core.models.validation_error import ValidationError from wlanpi_core.schemas import network - +from wlanpi_core.schemas.network.network import ScanItem """ These are the functions used to deliver the API @@ -46,7 +46,7 @@ async def get_wireless_network_scan_async( interface_obj = wlan_dbus.get_interface(interface) return {"nets": await interface_obj.get_network_scan(scan_type, timeout=timeout)} - except [WlanDBUSException, ValueError] as err: + except (WlanDBUSException, ValueError) as err: # Need to Split exceptions into validation and actual failures raise ValidationError(str(err), status_code=400) from err @@ -78,3 +78,56 @@ async def get_current_wireless_network_details( except WlanDBUSException as err: raise ValidationError(str(err), status_code=400) from err + +async def disconnect_wireless_network( + interface: str, + timeout: Optional[int], +): + """ + Uses wpa_supplicant to disconnect to a WLAN network. + """ + try: + wlan_dbus = WlanDBUS() + return wlan_dbus.get_interface(interface).disconnect() + except ValueError as error: + raise ValidationError(f"{error}", status_code=400) + + +async def remove_all_networks( + interface: str, +): + """ + Uses wpa_supplicant to connect to a WLAN network. + """ + try: + wlan_dbus = WlanDBUS() + return wlan_dbus.get_interface(interface).remove_all_networks() + except ValueError as error: + raise ValidationError(f"{error}", status_code=400) + +async def remove_network( + interface: str, + network_id: int, +): + """ + Uses wpa_supplicant to remove a network from the list of known networks. + """ + try: + wlan_dbus = WlanDBUS() + return wlan_dbus.get_interface(interface).remove_network(network_id) + except ValueError as error: + raise ValidationError(f"{error}", status_code=400) + + +async def networks( + interface: str, +)->dict[int, ScanItem]: + """ + Uses wpa_supplicant to connect to a WLAN network. + """ + try: + wlan_dbus = WlanDBUS() + return wlan_dbus.get_interface(interface).networks() + except ValueError as error: + raise ValidationError(f"{error}", status_code=400) + From 8cc892bd99ed1b9c232f1d681fb527d1f4b93308 Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Mon, 4 Nov 2024 21:00:27 -0500 Subject: [PATCH 21/41] More improvements --- .../api/api_v1/endpoints/network_api.py | 44 ++++++++++++++----- .../network/wlan/wlan_dbus_interface.py | 20 ++++++--- wlanpi_core/schemas/network/network.py | 4 ++ wlanpi_core/services/network_service.py | 14 ++++++ 4 files changed, 65 insertions(+), 17 deletions(-) diff --git a/wlanpi_core/api/api_v1/endpoints/network_api.py b/wlanpi_core/api/api_v1/endpoints/network_api.py index 4bc3634..73ebc88 100644 --- a/wlanpi_core/api/api_v1/endpoints/network_api.py +++ b/wlanpi_core/api/api_v1/endpoints/network_api.py @@ -8,7 +8,7 @@ from wlanpi_core.models.validation_error import ValidationError from wlanpi_core.schemas import network from wlanpi_core.schemas.network.config import NetworkConfigResponse -from wlanpi_core.schemas.network.network import IPInterface, IPInterfaceAddress, ScanItem +from wlanpi_core.schemas.network.network import IPInterface, IPInterfaceAddress, SupplicantNetwork from wlanpi_core.services import network_ethernet_service, network_service router = APIRouter() @@ -306,7 +306,7 @@ async def disconnect_wireless_network( @router.get( "/wlan/{interface}/networks", - response_model=dict[int, ScanItem], + response_model=dict[int, SupplicantNetwork], response_model_exclude_none=True, ) async def get_all_wireless_networks( @@ -326,12 +326,13 @@ async def get_all_wireless_networks( log.error(ex) return Response(content="Internal Server Error", status_code=500) -@router.delete( + +@router.get( "/wlan/{interface}/networks/{network_id}", - response_model=None, + response_model=SupplicantNetwork, response_model_exclude_none=True, ) -async def disconnect_wireless_network( +async def get_wireless_network( interface: str, network_id: int ): """ @@ -339,8 +340,8 @@ async def disconnect_wireless_network( """ try: - return await network_service.remove_network( - interface, network_id, + return await network_service.get_network( + interface, network_id ) except ValidationError as ve: return Response(content=ve.error_msg, status_code=ve.status_code) @@ -349,9 +350,8 @@ async def disconnect_wireless_network( return Response(content="Internal Server Error", status_code=500) - -@router.post( - "/wlan/{interface}/networks/all/remove", +@router.delete( + "/wlan/{interface}/networks/all", response_model=None, response_model_exclude_none=True, ) @@ -372,3 +372,27 @@ async def remove_all_wireless_networks( log.error(ex) return Response(content="Internal Server Error", status_code=500) + +@router.delete( + "/wlan/{interface}/networks/{network_id}", + response_model=None, + response_model_exclude_none=True, +) +async def disconnect_wireless_network( + interface: str, network_id: int +): + """ + Queries systemd via dbus to get the details of the currently connected network. + """ + + try: + return await network_service.remove_network( + interface, network_id, + ) + 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/models/network/wlan/wlan_dbus_interface.py b/wlanpi_core/models/network/wlan/wlan_dbus_interface.py index e0ce36c..b64fd88 100644 --- a/wlanpi_core/models/network/wlan/wlan_dbus_interface.py +++ b/wlanpi_core/models/network/wlan/wlan_dbus_interface.py @@ -35,15 +35,15 @@ def __init__(self, wpa_supplicant:dbus.proxies.Interface, system_bus:SystemBus, self.system_bus:SystemBus = system_bus self.default_timeout = default_timeout - interface_path = None + self.interface_dbus_path = None self.logger.debug(f'Getting interface {interface_name}') try: - interface_path = self.wpa_supplicant.GetInterface(self.interface_name) + self.interface_dbus_path = self.wpa_supplicant.GetInterface(self.interface_name) except dbus.DBusException as exc: if not str(exc).startswith("fi.w1.wpa_supplicant1.InterfaceUnknown:"): raise WlanDBUSInterfaceException(f"Interface unknown : {exc}") from exc try: - interface_path = self.wpa_supplicant.CreateInterface({"Ifname": self.interface_name, "Driver": "nl80211"}) + self.interface_dbus_path = self.wpa_supplicant.CreateInterface({"Ifname": self.interface_name, "Driver": "nl80211"}) time.sleep(1) except dbus.DBusException as exc: if not str(exc).startswith("fi.w1.wpa_supplicant1.InterfaceExists:"): @@ -51,8 +51,8 @@ def __init__(self, wpa_supplicant:dbus.proxies.Interface, system_bus:SystemBus, f"Interface cannot be created : {exc}" ) from exc time.sleep(1) - self.logger.debug(interface_path) - self.supplicant_dbus_object = self.system_bus.get_object(WPAS_DBUS_SERVICE, interface_path) + self.logger.debug(f"Interface path: {self.interface_dbus_path}") + self.supplicant_dbus_object = self.system_bus.get_object(WPAS_DBUS_SERVICE, self.interface_dbus_path) self.supplicant_dbus_interface = dbus.Interface(self.supplicant_dbus_object, WPAS_DBUS_INTERFACES_INTERFACE) @@ -426,12 +426,18 @@ def remove_all_networks(self) -> None: def remove_network(self, network_id:int) -> None: """ Removes a single network from the interface""" self.logger.info("Removing network %s on %s", network_id, self.interface_name) - self.supplicant_dbus_interface.RemoveNetwork(network_id) + self.supplicant_dbus_interface.RemoveNetwork(f"{self.interface_dbus_path}/Networks/{network_id}") def networks(self): """ Returns a list of available networks """ networks = {} for network_path in self._get_from_wpa_supplicant_interface('Networks'): - networks[network_path] = self._get_dbus_object(network_path).Get('fi.w1.wpa_supplicant1.Network', "Properties",dbus_interface=dbus.PROPERTIES_IFACE) + networks[network_path.split('/')[-1]] = self._get_dbus_object(network_path).Get('fi.w1.wpa_supplicant1.Network', "Properties",dbus_interface=dbus.PROPERTIES_IFACE) return networks + + def get_network(self, network_id:int): + """ Returns a list of available networks """ + return self._get_dbus_object(f"{self.interface_dbus_path}/Networks/{network_id}").Get('fi.w1.wpa_supplicant1.Network', "Properties",dbus_interface=dbus.PROPERTIES_IFACE) + + diff --git a/wlanpi_core/schemas/network/network.py b/wlanpi_core/schemas/network/network.py index cda3bde..e8b4fde 100644 --- a/wlanpi_core/schemas/network/network.py +++ b/wlanpi_core/schemas/network/network.py @@ -74,6 +74,10 @@ class ScanItem(BaseModel, extra=Extra.allow): class ScanResults(BaseModel): nets: List[ScanItem] +class SupplicantNetwork(BaseModel, extra=Extra.allow): + ssid: str = Field(example="A Network") + key_mgmt: str = Field(example="wpa-psk") + class WlanConfig(BaseModel): ssid: str = Field(example="SSID Name") diff --git a/wlanpi_core/services/network_service.py b/wlanpi_core/services/network_service.py index 1cbc3cd..ccc4618 100644 --- a/wlanpi_core/services/network_service.py +++ b/wlanpi_core/services/network_service.py @@ -119,6 +119,20 @@ async def remove_network( raise ValidationError(f"{error}", status_code=400) +async def get_network( + interface: str, + network_id: int, +): + """ + Uses wpa_supplicant to remove a network from the list of known networks. + """ + try: + wlan_dbus = WlanDBUS() + return wlan_dbus.get_interface(interface).get_network(network_id) + except ValueError as error: + raise ValidationError(f"{error}", status_code=400) + + async def networks( interface: str, )->dict[int, ScanItem]: From 89ae0a429307081ac7a7441b8423cd68ce5e5670 Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Mon, 4 Nov 2024 21:09:30 -0500 Subject: [PATCH 22/41] Formatted and linted --- .../api/api_v1/endpoints/network_api.py | 53 ++--- wlanpi_core/app.py | 19 +- wlanpi_core/models/network/wlan/exceptions.py | 6 +- wlanpi_core/models/network/wlan/wlan_dbus.py | 24 +- .../network/wlan/wlan_dbus_interface.py | 207 ++++++++++++------ wlanpi_core/schemas/network/network.py | 1 + wlanpi_core/services/network_info_service.py | 1 + wlanpi_core/services/network_service.py | 34 +-- wlanpi_core/utils/g_lib_loop.py | 31 ++- wlanpi_core/utils/network.py | 12 +- 10 files changed, 250 insertions(+), 138 deletions(-) diff --git a/wlanpi_core/api/api_v1/endpoints/network_api.py b/wlanpi_core/api/api_v1/endpoints/network_api.py index 73ebc88..878dd26 100644 --- a/wlanpi_core/api/api_v1/endpoints/network_api.py +++ b/wlanpi_core/api/api_v1/endpoints/network_api.py @@ -8,7 +8,11 @@ from wlanpi_core.models.validation_error import ValidationError from wlanpi_core.schemas import network from wlanpi_core.schemas.network.config import NetworkConfigResponse -from wlanpi_core.schemas.network.network import IPInterface, IPInterfaceAddress, SupplicantNetwork +from wlanpi_core.schemas.network.network import ( + IPInterface, + IPInterfaceAddress, + SupplicantNetwork, +) from wlanpi_core.services import network_ethernet_service, network_service router = APIRouter() @@ -219,8 +223,11 @@ async def get_wireless_interfaces(timeout: int = API_DEFAULT_TIMEOUT): log.error(ex) return Response(content="Internal Server Error", status_code=500) + @router.get( - "/wlan/{interface}/scan", response_model=network.ScanResults, response_model_exclude_none=True + "/wlan/{interface}/scan", + response_model=network.ScanResults, + response_model_exclude_none=True, ) async def do_wireless_network_scan( scan_type: str, interface: str, timeout: int = API_DEFAULT_TIMEOUT @@ -242,7 +249,9 @@ async def do_wireless_network_scan( @router.post("/wlan/{interface}/add_network", response_model=network.NetworkSetupStatus) async def add_wireless_network( - interface:str, setup: network.WlanInterfaceSetup, timeout: int = API_DEFAULT_TIMEOUT + interface: str, + setup: network.WlanInterfaceSetup, + timeout: int = API_DEFAULT_TIMEOUT, ): """ Queries systemd via dbus to set a single network. @@ -281,6 +290,7 @@ async def get_current_wireless_network_details( log.error(ex) return Response(content="Internal Server Error", status_code=500) + @router.post( "/wlan/{interface}/disconnect", response_model=None, @@ -294,9 +304,7 @@ async def disconnect_wireless_network( """ try: - return await network_service.disconnect_wireless_network( - interface, timeout - ) + return await network_service.disconnect_wireless_network(interface, timeout) except ValidationError as ve: return Response(content=ve.error_msg, status_code=ve.status_code) except Exception as ex: @@ -309,17 +317,13 @@ async def disconnect_wireless_network( response_model=dict[int, SupplicantNetwork], response_model_exclude_none=True, ) -async def get_all_wireless_networks( - interface: str, timeout: int = API_DEFAULT_TIMEOUT -): +async def get_all_wireless_networks(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.networks( - interface - ) + return await network_service.networks(interface) except ValidationError as ve: return Response(content=ve.error_msg, status_code=ve.status_code) except Exception as ex: @@ -332,17 +336,13 @@ async def get_all_wireless_networks( response_model=SupplicantNetwork, response_model_exclude_none=True, ) -async def get_wireless_network( - interface: str, network_id: int -): +async def get_wireless_network(interface: str, network_id: int): """ Queries systemd via dbus to get the details of the currently connected network. """ try: - return await network_service.get_network( - interface, network_id - ) + return await network_service.get_network(interface, network_id) except ValidationError as ve: return Response(content=ve.error_msg, status_code=ve.status_code) except Exception as ex: @@ -355,17 +355,13 @@ async def get_wireless_network( response_model=None, response_model_exclude_none=True, ) -async def remove_all_wireless_networks( - interface: str -): +async def remove_all_wireless_networks(interface: str): """ Removes all networks on an interface """ try: - return await network_service.remove_all_networks( - interface - ) + return await network_service.remove_all_networks(interface) except ValidationError as ve: return Response(content=ve.error_msg, status_code=ve.status_code) except Exception as ex: @@ -378,21 +374,18 @@ async def remove_all_wireless_networks( response_model=None, response_model_exclude_none=True, ) -async def disconnect_wireless_network( - interface: str, network_id: int -): +async def disconnect_wireless_network(interface: str, network_id: int): """ Queries systemd via dbus to get the details of the currently connected network. """ try: return await network_service.remove_network( - interface, network_id, + interface, + network_id, ) 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/app.py b/wlanpi_core/app.py index 1906220..4ca5b0c 100644 --- a/wlanpi_core/app.py +++ b/wlanpi_core/app.py @@ -1,8 +1,6 @@ # -*- coding: utf-8 -*- import logging -# stdlib imports - # third party imports import uvicorn from fastapi import FastAPI @@ -15,14 +13,23 @@ from wlanpi_core.core.config import endpoints, settings from wlanpi_core.views import api +# stdlib imports + + # setup logger -logging.basicConfig(level=logging.DEBUG, - format="%(asctime)s - %(levelname)s - %(module)s:%(funcName)s:%(lineno)d - %(message)s") +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s - %(levelname)s - %(module)s:%(funcName)s:%(lineno)d - %(message)s", +) log_config = uvicorn.config.LOGGING_CONFIG log_config["disable_existing_loggers"] = False -log_config["formatters"]["access"]["fmt"] = "%(asctime)s - %(levelprefix)s - %(message)s" -log_config["formatters"]["default"]["fmt"] = "%(asctime)s - %(levelprefix)s - %(module)s:%(funcName)s:%(lineno)d - %(message)s" +log_config["formatters"]["access"][ + "fmt" +] = "%(asctime)s - %(levelprefix)s - %(message)s" +log_config["formatters"]["default"][ + "fmt" +] = "%(asctime)s - %(levelprefix)s - %(module)s:%(funcName)s:%(lineno)d - %(message)s" def create_app(): diff --git a/wlanpi_core/models/network/wlan/exceptions.py b/wlanpi_core/models/network/wlan/exceptions.py index 18b1c7e..9c9b4c8 100644 --- a/wlanpi_core/models/network/wlan/exceptions.py +++ b/wlanpi_core/models/network/wlan/exceptions.py @@ -1,18 +1,22 @@ - class WlanDBUSException(Exception): pass + class WlanDBUSInterfaceException(WlanDBUSException): pass + class WDIScanError(WlanDBUSInterfaceException): pass + class WDIConnectionException(WlanDBUSInterfaceException): pass + class WDIDisconnectedException(WDIConnectionException): pass + class WDIAuthenticationError(WDIConnectionException): pass diff --git a/wlanpi_core/models/network/wlan/wlan_dbus.py b/wlanpi_core/models/network/wlan/wlan_dbus.py index e467aa1..2bf70d7 100644 --- a/wlanpi_core/models/network/wlan/wlan_dbus.py +++ b/wlanpi_core/models/network/wlan/wlan_dbus.py @@ -2,11 +2,11 @@ import dbus from wlanpi_core.constants import ( - + API_DEFAULT_TIMEOUT, WPAS_DBUS_INTERFACE, WPAS_DBUS_INTERFACES_INTERFACE, WPAS_DBUS_OPATH, - WPAS_DBUS_SERVICE, API_DEFAULT_TIMEOUT, + WPAS_DBUS_SERVICE, ) from wlanpi_core.models.network.wlan.wlan_dbus_interface import WlanDBUSInterface @@ -15,6 +15,7 @@ class WlanDBUS: DBUS_IFACE = dbus.PROPERTIES_IFACE DEFAULT_TIMEOUT = API_DEFAULT_TIMEOUT + def __init__(self): self.logger = logging.getLogger(__name__) @@ -22,21 +23,28 @@ def __init__(self): self.main_dbus_loop = dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) - self.bus = dbus.SystemBus(mainloop=self.main_dbus_loop) - self.wpa_supplicant_proxy = self.bus.get_object(WPAS_DBUS_SERVICE, WPAS_DBUS_OPATH) - self.wpa_supplicant = dbus.Interface(self.wpa_supplicant_proxy, WPAS_DBUS_INTERFACE) + self.wpa_supplicant_proxy = self.bus.get_object( + WPAS_DBUS_SERVICE, WPAS_DBUS_OPATH + ) + self.wpa_supplicant = dbus.Interface( + self.wpa_supplicant_proxy, WPAS_DBUS_INTERFACE + ) self.wpas = self.wpa_supplicant # setup_DBus_Supplicant_Access(interface) self.interfaces = {} def get_interface(self, interface) -> WlanDBUSInterface: if not interface in self.interfaces: - new_interface = WlanDBUSInterface(wpa_supplicant=self.wpa_supplicant, system_bus=self.bus, interface_name=interface, default_timeout=self.DEFAULT_TIMEOUT) + new_interface = WlanDBUSInterface( + wpa_supplicant=self.wpa_supplicant, + system_bus=self.bus, + interface_name=interface, + default_timeout=self.DEFAULT_TIMEOUT, + ) self.interfaces[interface] = new_interface return self.interfaces[interface] - def fetch_interfaces(self, wpas_obj): available_interfaces = [] ifaces = wpas_obj.Get( @@ -64,5 +72,5 @@ def get_systemd_network_interfaces(self, timeout: int = DEFAULT_TIMEOUT): self.logger.debug("Checking available interfaces", 3) available_interfaces = self.fetch_interfaces(wpas_obj) self.logger.debug(f"Available interfaces: {available_interfaces}", 3) - return available_interfaces + return available_interfaces diff --git a/wlanpi_core/models/network/wlan/wlan_dbus_interface.py b/wlanpi_core/models/network/wlan/wlan_dbus_interface.py index b64fd88..c712024 100644 --- a/wlanpi_core/models/network/wlan/wlan_dbus_interface.py +++ b/wlanpi_core/models/network/wlan/wlan_dbus_interface.py @@ -5,18 +5,32 @@ from typing import Optional, Union import dbus.proxies -from dbus import SystemBus, Interface, DBusException +from dbus import DBusException, Interface, SystemBus from dbus.proxies import ProxyObject - from gi.repository import GLib -from wlanpi_core.constants import WPAS_DBUS_SERVICE, WPAS_DBUS_INTERFACES_INTERFACE, WPAS_DBUS_BSS_INTERFACE -from wlanpi_core.models.network.wlan.exceptions import WlanDBUSInterfaceException, WDIConnectionException, \ - WDIAuthenticationError, WDIDisconnectedException, WDIScanError -from wlanpi_core.schemas.network import network, WlanConfig, ScanResults +from wlanpi_core.constants import ( + WPAS_DBUS_BSS_INTERFACE, + WPAS_DBUS_INTERFACES_INTERFACE, + WPAS_DBUS_SERVICE, +) +from wlanpi_core.models.network.wlan.exceptions import ( + WDIAuthenticationError, + WDIConnectionException, + WDIDisconnectedException, + WDIScanError, + WlanDBUSInterfaceException, +) +from wlanpi_core.schemas.network import ( + NetworkSetupStatus, + ScanResults, + WlanConfig, + network, +) +from wlanpi_core.schemas.network.network import SupplicantNetwork from wlanpi_core.utils.g_lib_loop import GLibLoop from wlanpi_core.utils.general import byte_array_to_string -from wlanpi_core.utils.network import renew_dhcp, get_ip_address +from wlanpi_core.utils.network import get_ip_address, renew_dhcp class WlanDBUSInterface: @@ -26,24 +40,34 @@ class WlanDBUSInterface: ] DBUS_IFACE = dbus.PROPERTIES_IFACE - def __init__(self, wpa_supplicant:dbus.proxies.Interface, system_bus:SystemBus, interface_name:str, default_timeout:int=20): + def __init__( + self, + wpa_supplicant: dbus.proxies.Interface, + system_bus: SystemBus, + interface_name: str, + default_timeout: int = 20, + ): self.logger = logging.getLogger(__name__) self.logger.info(f"Initializing {__name__}") print(f"Class is {__name__}") - self.wpa_supplicant:Interface = wpa_supplicant - self.interface_name:str = interface_name - self.system_bus:SystemBus = system_bus + self.wpa_supplicant: Interface = wpa_supplicant + self.interface_name: str = interface_name + self.system_bus: SystemBus = system_bus self.default_timeout = default_timeout self.interface_dbus_path = None - self.logger.debug(f'Getting interface {interface_name}') + self.logger.debug(f"Getting interface {interface_name}") try: - self.interface_dbus_path = self.wpa_supplicant.GetInterface(self.interface_name) + self.interface_dbus_path = self.wpa_supplicant.GetInterface( + self.interface_name + ) except dbus.DBusException as exc: if not str(exc).startswith("fi.w1.wpa_supplicant1.InterfaceUnknown:"): raise WlanDBUSInterfaceException(f"Interface unknown : {exc}") from exc try: - self.interface_dbus_path = self.wpa_supplicant.CreateInterface({"Ifname": self.interface_name, "Driver": "nl80211"}) + self.interface_dbus_path = self.wpa_supplicant.CreateInterface( + {"Ifname": self.interface_name, "Driver": "nl80211"} + ) time.sleep(1) except dbus.DBusException as exc: if not str(exc).startswith("fi.w1.wpa_supplicant1.InterfaceExists:"): @@ -52,28 +76,32 @@ def __init__(self, wpa_supplicant:dbus.proxies.Interface, system_bus:SystemBus, ) from exc time.sleep(1) self.logger.debug(f"Interface path: {self.interface_dbus_path}") - self.supplicant_dbus_object = self.system_bus.get_object(WPAS_DBUS_SERVICE, self.interface_dbus_path) - self.supplicant_dbus_interface = dbus.Interface(self.supplicant_dbus_object, WPAS_DBUS_INTERFACES_INTERFACE) - + self.supplicant_dbus_object = self.system_bus.get_object( + WPAS_DBUS_SERVICE, self.interface_dbus_path + ) + self.supplicant_dbus_interface = dbus.Interface( + self.supplicant_dbus_object, WPAS_DBUS_INTERFACES_INTERFACE + ) # Transient vars self.last_scan = None - def _get_dbus_object(self, object_path:str)->ProxyObject: + def _get_dbus_object(self, object_path: str) -> ProxyObject: return self.system_bus.get_object(WPAS_DBUS_SERVICE, object_path) - - def _get_from_wpa_supplicant_network(self, bss:str, key:str): + def _get_from_wpa_supplicant_network(self, bss: str, key: str) -> any: net_obj = self.system_bus.get_object(WPAS_DBUS_SERVICE, bss) - return net_obj.Get(WPAS_DBUS_BSS_INTERFACE, key, dbus_interface=self.DBUS_IFACE ) + return net_obj.Get(WPAS_DBUS_BSS_INTERFACE, key, dbus_interface=self.DBUS_IFACE) - def _get_from_wpa_supplicant_interface(self, key:str): - return self.supplicant_dbus_object.Get(WPAS_DBUS_INTERFACES_INTERFACE, key, dbus_interface=dbus.PROPERTIES_IFACE) + def _get_from_wpa_supplicant_interface(self, key: str) -> any: + return self.supplicant_dbus_object.Get( + WPAS_DBUS_INTERFACES_INTERFACE, key, dbus_interface=dbus.PROPERTIES_IFACE + ) - def _get_bssid_path(self): + def _get_bssid_path(self) -> str: return self._get_from_wpa_supplicant_interface("CurrentBSS") - def get_bss(self, bss): + def get_bss(self, bss) -> dict[str, any]: """ Queries DBUS_BSS_INTERFACE through dbus for a BSS Path @@ -130,7 +158,7 @@ def get_bss(self, bss): except ValueError as error: raise WlanDBUSInterfaceException(error) from error - def pretty_print_bss(self, bss_path): + def pretty_print_bss(self, bss_path) -> str: bss_details = self.get_bss(bss_path) if bss_details: ssid = bss_details["ssid"] if bss_details["ssid"] else "" @@ -145,7 +173,9 @@ def pretty_print_bss(self, bss_path): else: return f"BSS Path {bss_path} could not be resolved" - def get_current_network_details(self) -> dict[str, Optional[dict[str, Union[str,int]]]]: + def get_current_network_details( + self, + ) -> dict[str, Optional[dict[str, Union[str, int]]]]: try: bssid_path = self._get_bssid_path() @@ -157,9 +187,13 @@ def get_current_network_details(self) -> dict[str, Optional[dict[str, Union[str, return {"connectedStatus": False, "connectedNet": None} except DBusException as err: self.logger.error(f"DBUS error while getting BSSID: {str(err)}") - raise WlanDBUSInterfaceException(f"DBUS error while getting BSSID: {str(err)}") from err + raise WlanDBUSInterfaceException( + f"DBUS error while getting BSSID: {str(err)}" + ) from err - async def get_network_scan(self, scan_type: str, timeout: Optional[int] = None) -> ScanResults: + async def get_network_scan( + self, scan_type: str, timeout: Optional[int] = None + ) -> ScanResults: self.logger.info("Starting network scan...") if not timeout: timeout = self.default_timeout @@ -175,7 +209,9 @@ async def get_network_scan(self, scan_type: str, timeout: Optional[int] = None) done_future = async_loop.create_future() def timeout_handler(*args): - done_future.set_exception(TimeoutError(f"Scan timed out after {timeout} seconds: {args}")) + done_future.set_exception( + TimeoutError(f"Scan timed out after {timeout} seconds: {args}") + ) glib_loop.finish() def done_handler(success): @@ -189,7 +225,9 @@ def done_handler(success): if bss: local_scan.append(bss) self.last_scan = local_scan - self.logger.debug(f"A Scan has completed with {len(local_scan)} results", ) + self.logger.debug( + f"A Scan has completed with {len(local_scan)} results", + ) self.logger.debug(local_scan) done_future.set_result(local_scan) glib_loop.finish() @@ -215,10 +253,17 @@ def done_handler(success): scan_handler.remove() return scan_results - async def add_network(self, wlan_config:WlanConfig, remove_others:bool=False, timeout: Optional[int] = None): + async def add_network( + self, + wlan_config: WlanConfig, + remove_others: bool = False, + timeout: Optional[int] = None, + ) -> NetworkSetupStatus: if not timeout: timeout = self.default_timeout - self.logger.info("Configuring WLAN on %s with config: %s", self.interface_name, wlan_config) + self.logger.info( + "Configuring WLAN on %s with config: %s", self.interface_name, wlan_config + ) # Create a new Future object to manage the async execution. async_loop = asyncio.get_running_loop() @@ -234,7 +279,11 @@ async def add_network(self, wlan_config:WlanConfig, remove_others:bool=False, t with GLibLoop() as glib_loop: def timeout_callback(*args): - add_network_future.set_exception(TimeoutError(f"Connection timed out after {timeout} seconds: {args}")) + add_network_future.set_exception( + TimeoutError( + f"Connection timed out after {timeout} seconds: {args}" + ) + ) glib_loop.finish() def network_selected_callback(selected_network): @@ -265,7 +314,9 @@ def properties_changed_callback(properties): elif state == "4way_handshake": self.logger.debug(f"PropertiesChanged: State: {state}") if properties.get("CurrentBSS"): - self.logger.debug(f"Handshake attempt to: {self.pretty_print_bss(properties['CurrentBSS'])}") + self.logger.debug( + f"Handshake attempt to: {self.pretty_print_bss(properties['CurrentBSS'])}" + ) else: self.logger.debug(f"PropertiesChanged: State: {state}") connection_events.append( @@ -276,25 +327,30 @@ def properties_changed_callback(properties): if disconnect_reason: if disconnect_reason in [3, -3]: connection_events.append( - network.NetworkEvent(event="Station is Leaving", time=f"{datetime.now()}") + network.NetworkEvent( + event="Station is Leaving", time=f"{datetime.now()}" + ) ) elif disconnect_reason == 15: event = network.NetworkEvent( - event="4-Way Handshake Fail (check password)", - time=f"{datetime.now()}", - ) - connection_events.append(event + event="4-Way Handshake Fail (check password)", + time=f"{datetime.now()}", ) + connection_events.append(event) # End - add_network_future.set_exception(WDIAuthenticationError(event)) + add_network_future.set_exception( + WDIAuthenticationError(event) + ) glib_loop.finish() else: - event = network.NetworkEvent( - event=f"Error: Disconnected [{disconnect_reason}]", - time=f"{datetime.now()}", - ) + event = network.NetworkEvent( + event=f"Error: Disconnected [{disconnect_reason}]", + time=f"{datetime.now()}", + ) connection_events.append(event) - add_network_future.set_exception(WDIDisconnectedException(event)) + add_network_future.set_exception( + WDIDisconnectedException(event) + ) glib_loop.finish() # For debugging purposes only @@ -313,7 +369,9 @@ def properties_changed_callback(properties): self.logger.debug(f"Auth Status: {auth_status}") if auth_status == 0: connection_events.append( - network.NetworkEvent(event="authenticated", time=f"{datetime.now()}") + network.NetworkEvent( + event="authenticated", time=f"{datetime.now()}" + ) ) else: connection_events.append( @@ -338,7 +396,6 @@ def properties_changed_callback(properties): signal_name="PropertiesChanged", ) - # Remove all other connections if requested if remove_others: self.logger.info("Removing existing connections") @@ -358,7 +415,9 @@ def properties_changed_callback(properties): self.logger.debug(f"Logged connection Events: {connection_events}") if select_err is None: # exit after waiting a short time for the signal - glib_loop.start_timeout(seconds=timeout,callback=timeout_callback) + glib_loop.start_timeout( + seconds=timeout, callback=timeout_callback + ) # The network selection has been successfully applied (does not mean a network is selected) glib_loop.run() @@ -369,7 +428,9 @@ def properties_changed_callback(properties): connect_result = None if connect_result == "completed": - self.logger.info("Connection to network completed. Verifying connection...") + self.logger.info( + "Connection to network completed. Verifying connection..." + ) # Check the current BSSID post connection bssid_path = self._get_bssid_path() @@ -380,7 +441,9 @@ def properties_changed_callback(properties): self.logger.info("Connected") status = "connected" else: - self.logger.warning("Connection failed. Post connection check returned no network") + self.logger.warning( + "Connection failed. Post connection check returned no network" + ) status = "connection_lost" else: @@ -412,32 +475,42 @@ def properties_changed_callback(properties): "input": wlan_config.ssid, } - def disconnect(self) -> None: - """ Disconnects the given interface from any network it may be associated with""" + """Disconnects the given interface from any network it may be associated with""" self.logger.info("Disconnecting WLAN on %s", self.interface_name) self.supplicant_dbus_interface.Disconnect() def remove_all_networks(self) -> None: - """ Removes all networks from the interface""" + """Removes all networks from the interface""" self.logger.info("Removing all Networks onon %s", self.interface_name) self.supplicant_dbus_interface.RemoveAllNetworks() - def remove_network(self, network_id:int) -> None: - """ Removes a single network from the interface""" + def remove_network(self, network_id: int) -> None: + """Removes a single network from the interface""" self.logger.info("Removing network %s on %s", network_id, self.interface_name) - self.supplicant_dbus_interface.RemoveNetwork(f"{self.interface_dbus_path}/Networks/{network_id}") + self.supplicant_dbus_interface.RemoveNetwork( + f"{self.interface_dbus_path}/Networks/{network_id}" + ) - def networks(self): - """ Returns a list of available networks """ + def networks(self) -> dict[int, SupplicantNetwork]: + """Returns a list of available networks""" networks = {} - for network_path in self._get_from_wpa_supplicant_interface('Networks'): - networks[network_path.split('/')[-1]] = self._get_dbus_object(network_path).Get('fi.w1.wpa_supplicant1.Network', "Properties",dbus_interface=dbus.PROPERTIES_IFACE) + for network_path in self._get_from_wpa_supplicant_interface("Networks"): + networks[int(network_path.split("/")[-1])] = self._get_dbus_object( + network_path + ).Get( + "fi.w1.wpa_supplicant1.Network", + "Properties", + dbus_interface=dbus.PROPERTIES_IFACE, + ) return networks - - def get_network(self, network_id:int): - """ Returns a list of available networks """ - return self._get_dbus_object(f"{self.interface_dbus_path}/Networks/{network_id}").Get('fi.w1.wpa_supplicant1.Network', "Properties",dbus_interface=dbus.PROPERTIES_IFACE) - - + def get_network(self, network_id: int) -> SupplicantNetwork: + """Returns a list of available networks""" + return self._get_dbus_object( + f"{self.interface_dbus_path}/Networks/{network_id}" + ).Get( + "fi.w1.wpa_supplicant1.Network", + "Properties", + dbus_interface=dbus.PROPERTIES_IFACE, + ) diff --git a/wlanpi_core/schemas/network/network.py b/wlanpi_core/schemas/network/network.py index e8b4fde..e84c541 100644 --- a/wlanpi_core/schemas/network/network.py +++ b/wlanpi_core/schemas/network/network.py @@ -74,6 +74,7 @@ class ScanItem(BaseModel, extra=Extra.allow): class ScanResults(BaseModel): nets: List[ScanItem] + class SupplicantNetwork(BaseModel, extra=Extra.allow): ssid: str = Field(example="A Network") key_mgmt: str = Field(example="wpa-psk") diff --git a/wlanpi_core/services/network_info_service.py b/wlanpi_core/services/network_info_service.py index cc4b5a9..2f7418a 100644 --- a/wlanpi_core/services/network_info_service.py +++ b/wlanpi_core/services/network_info_service.py @@ -15,6 +15,7 @@ from wlanpi_core.models.runcommand_error import RunCommandError from wlanpi_core.utils.general import run_command + def show_info(): output = {} diff --git a/wlanpi_core/services/network_service.py b/wlanpi_core/services/network_service.py index ccc4618..7e27441 100644 --- a/wlanpi_core/services/network_service.py +++ b/wlanpi_core/services/network_service.py @@ -2,13 +2,12 @@ from enum import Enum from typing import Optional - from wlanpi_core.models.network.wlan.exceptions import WlanDBUSException from wlanpi_core.models.network.wlan.wlan_dbus import WlanDBUS from wlanpi_core.models.network.wlan.wlan_dbus_interface import WlanDBUSInterface from wlanpi_core.models.validation_error import ValidationError from wlanpi_core.schemas import network -from wlanpi_core.schemas.network.network import ScanItem +from wlanpi_core.schemas.network.network import SupplicantNetwork """ These are the functions used to deliver the API @@ -30,7 +29,7 @@ async def get_systemd_network_interfaces(timeout: int): async def get_wireless_network_scan_async( - scan_type: Enum(*WlanDBUSInterface.ALLOWED_SCAN_TYPES), interface: str, timeout:int + scan_type: Enum(*WlanDBUSInterface.ALLOWED_SCAN_TYPES), interface: str, timeout: int ): """ Queries systemd via dbus to get a scan of the available networks. @@ -38,18 +37,23 @@ async def get_wireless_network_scan_async( try: wlan_dbus = WlanDBUS() clean_scan_type = scan_type.strip().lower() if scan_type else None - if not clean_scan_type or (clean_scan_type not in WlanDBUSInterface.ALLOWED_SCAN_TYPES): + if not clean_scan_type or ( + clean_scan_type not in WlanDBUSInterface.ALLOWED_SCAN_TYPES + ): raise ValidationError( f"scan type must be one of: {', '.join(WlanDBUSInterface.ALLOWED_SCAN_TYPES)}", - status_code=400 + status_code=400, ) interface_obj = wlan_dbus.get_interface(interface) - return {"nets": await interface_obj.get_network_scan(scan_type, timeout=timeout)} + return { + "nets": await interface_obj.get_network_scan(scan_type, timeout=timeout) + } except (WlanDBUSException, ValueError) as err: # Need to Split exceptions into validation and actual failures raise ValidationError(str(err), status_code=400) from err + async def add_wireless_network( interface: str, net_config: network.WlanConfig, @@ -61,14 +65,14 @@ async def add_wireless_network( """ try: wlan_dbus = WlanDBUS() - return await wlan_dbus.get_interface(interface).add_network(wlan_config=net_config, remove_others=remove_all_first, timeout=timeout) + return await wlan_dbus.get_interface(interface).add_network( + wlan_config=net_config, remove_others=remove_all_first, timeout=timeout + ) except ValueError as error: raise ValidationError(f"{error}", status_code=400) -async def get_current_wireless_network_details( - interface: str, timeout: int -): +async def get_current_wireless_network_details(interface: str, timeout: int): """ Queries systemd via dbus to get a scan of the available networks. """ @@ -105,9 +109,10 @@ async def remove_all_networks( except ValueError as error: raise ValidationError(f"{error}", status_code=400) + async def remove_network( interface: str, - network_id: int, + network_id: int, ): """ Uses wpa_supplicant to remove a network from the list of known networks. @@ -121,8 +126,8 @@ async def remove_network( async def get_network( interface: str, - network_id: int, -): + network_id: int, +) -> SupplicantNetwork: """ Uses wpa_supplicant to remove a network from the list of known networks. """ @@ -135,7 +140,7 @@ async def get_network( async def networks( interface: str, -)->dict[int, ScanItem]: +) -> dict[int, SupplicantNetwork]: """ Uses wpa_supplicant to connect to a WLAN network. """ @@ -144,4 +149,3 @@ async def networks( return wlan_dbus.get_interface(interface).networks() except ValueError as error: raise ValidationError(f"{error}", status_code=400) - diff --git a/wlanpi_core/utils/g_lib_loop.py b/wlanpi_core/utils/g_lib_loop.py index 63dc2b2..5d849bc 100644 --- a/wlanpi_core/utils/g_lib_loop.py +++ b/wlanpi_core/utils/g_lib_loop.py @@ -2,9 +2,18 @@ from gi.repository import GLib -class GLibLoop: - def __init__(self, loop: Optional[GLib.MainLoop]=None, timeout_seconds: Optional[int]=None, timeout_callback: Optional[callable]=None, timeout_callback_args: Optional[list]=None, timeout_callback_kwargs:Optional[dict]=None): +class GLibLoop: + """ Provides a wrapper to make handling scoped glib loop runs a little easier""" + + def __init__( + self, + loop: Optional[GLib.MainLoop] = None, + timeout_seconds: Optional[int] = None, + timeout_callback: Optional[callable] = None, + timeout_callback_args: Optional[list] = None, + timeout_callback_kwargs: Optional[dict] = None, + ): self.loop = loop if loop else GLib.MainLoop() self.timeout_seconds = timeout_seconds self.timeout_callback = timeout_callback @@ -13,14 +22,24 @@ def __init__(self, loop: Optional[GLib.MainLoop]=None, timeout_seconds: Optional self.timeout_source: Optional[GLib.Source] = None self.timeout_source_attachment: Optional[int] = None - def start_timeout(self, seconds: Optional[int]=None, callback: Optional[callable]=None, *args, **kwargs): - self.timeout_source = GLib.timeout_source_new_seconds(seconds if seconds else self.timeout_seconds) + def start_timeout( + self, + seconds: Optional[int] = None, + callback: Optional[callable] = None, + *args, + **kwargs + ): + self.timeout_source = GLib.timeout_source_new_seconds( + seconds if seconds else self.timeout_seconds + ) self.timeout_source.set_callback( callback if callback else self.timeout_callback, *(args or self.timeout_callback_args or []), **(kwargs or self.timeout_callback_kwargs or {}) ) - self.timeout_source_attachment = self.timeout_source.attach(self.loop.get_context()) + self.timeout_source_attachment = self.timeout_source.attach( + self.loop.get_context() + ) def stop_timeout(self): if self.timeout_source and self.timeout_source_attachment: @@ -38,4 +57,4 @@ def __enter__(self): def __exit__(self, exc_type, exc_val, exc_tb): self.stop_timeout() - self.loop.quit() \ No newline at end of file + self.loop.quit() diff --git a/wlanpi_core/utils/network.py b/wlanpi_core/utils/network.py index 6127922..7b2840e 100644 --- a/wlanpi_core/utils/network.py +++ b/wlanpi_core/utils/network.py @@ -3,7 +3,6 @@ from typing import Any, Optional from wlanpi_core.models.runcommand_error import RunCommandError - from wlanpi_core.utils.general import run_command @@ -47,7 +46,7 @@ def get_interface_addresses( out_obj = {} for item in res: if item["ifname"] not in out_obj: - ifname:str = item["ifname"] + ifname: str = item["ifname"] out_obj[ifname] = {"inet": [], "inet6": []} for addr in item["addr_info"]: ifname: str = item["ifname"] @@ -65,7 +64,9 @@ def get_ip_address(interface): return res[0] return None except RunCommandError as err: - logging.warning(f"Failed to get IP address. Code:{err.return_code}, Error: {err.error_msg}") + logging.warning( + f"Failed to get IP address. Code:{err.return_code}, Error: {err.error_msg}" + ) return None @@ -80,6 +81,7 @@ def renew_dhcp(interface) -> None: # Obtain a new DHCP lease run_command(["sudo", "dhclient", interface], raise_on_fail=True) except RunCommandError as err: - logging.warning(f"Failed to renew DHCP. Code:{err.return_code}, Error: {err.error_msg}") + logging.warning( + f"Failed to renew DHCP. Code:{err.return_code}, Error: {err.error_msg}" + ) return None - From 00cdf8ae70963e438fbdcf8f51a1b1ba720e59ac Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Mon, 4 Nov 2024 21:47:55 -0500 Subject: [PATCH 23/41] Formatted and linted again --- wlanpi_core/models/network/wlan/wlan_dbus.py | 3 ++- wlanpi_core/utils/g_lib_loop.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/wlanpi_core/models/network/wlan/wlan_dbus.py b/wlanpi_core/models/network/wlan/wlan_dbus.py index 2bf70d7..9ef0098 100644 --- a/wlanpi_core/models/network/wlan/wlan_dbus.py +++ b/wlanpi_core/models/network/wlan/wlan_dbus.py @@ -1,4 +1,5 @@ import logging + import dbus from wlanpi_core.constants import ( @@ -47,6 +48,7 @@ def get_interface(self, interface) -> WlanDBUSInterface: def fetch_interfaces(self, wpas_obj): available_interfaces = [] + ifaces = wpas_obj.Get( WPAS_DBUS_INTERFACE, "Interfaces", dbus_interface=self.DBUS_IFACE ) @@ -73,4 +75,3 @@ def get_systemd_network_interfaces(self, timeout: int = DEFAULT_TIMEOUT): available_interfaces = self.fetch_interfaces(wpas_obj) self.logger.debug(f"Available interfaces: {available_interfaces}", 3) return available_interfaces - diff --git a/wlanpi_core/utils/g_lib_loop.py b/wlanpi_core/utils/g_lib_loop.py index 5d849bc..73ad398 100644 --- a/wlanpi_core/utils/g_lib_loop.py +++ b/wlanpi_core/utils/g_lib_loop.py @@ -4,7 +4,7 @@ class GLibLoop: - """ Provides a wrapper to make handling scoped glib loop runs a little easier""" + """Provides a wrapper to make handling scoped glib loop runs a little easier""" def __init__( self, From 1b80d9b6ff660b6cb12f92baa2e40a8dac891deb Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Mon, 4 Nov 2024 22:01:29 -0500 Subject: [PATCH 24/41] Formatted and linted again --- wlanpi_core/utils/general.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/wlanpi_core/utils/general.py b/wlanpi_core/utils/general.py index 1736457..9629b73 100644 --- a/wlanpi_core/utils/general.py +++ b/wlanpi_core/utils/general.py @@ -13,11 +13,11 @@ def run_command( - cmd: Union[list, str], - input: Optional[str] = None, - stdin: Optional[TextIO] = None, - shell=False, - raise_on_fail=True, + cmd: Union[list, str], + input: Optional[str] = None, + stdin: Optional[TextIO] = None, + shell=False, + raise_on_fail=True, ) -> CommandResult: """Run a single CLI command with subprocess and returns the output""" """ @@ -69,11 +69,11 @@ def run_command( cmd: list[str] = shlex.split(cmd) cmd: list[str] with subprocess.Popen( - cmd, - shell=shell, - stdin=subprocess.PIPE if input or isinstance(stdin, StringIO) else stdin, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + cmd, + shell=shell, + stdin=subprocess.PIPE if input or isinstance(stdin, StringIO) else stdin, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, ) as proc: if input: input_data = input.encode() @@ -89,11 +89,11 @@ def run_command( async def run_command_async( - cmd: Union[list, str], - input: Optional[str] = None, - stdin: Optional[TextIO] = None, - shell=False, - raise_on_fail=True, + cmd: Union[list, str], + input: Optional[str] = None, + stdin: Optional[TextIO] = None, + shell=False, + raise_on_fail=True, ) -> CommandResult: """Run a single CLI command with subprocess and returns the output""" """ From a91f0810a9c26e7aa0bc07e3c8077074e856bba6 Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Mon, 4 Nov 2024 22:21:18 -0500 Subject: [PATCH 25/41] fixes https://github.com/WLAN-Pi/wlanpi-core/issues/14 and some other cleanups and fixes --- wlanpi_core/__main__.py | 2 +- wlanpi_core/models/network/wlan/wlan_dbus.py | 19 +++++++++++++++++-- wlanpi_core/utils/g_lib_loop.py | 20 ++++++++++---------- wlanpi_core/utils/general.py | 12 ++++++++++++ 4 files changed, 40 insertions(+), 13 deletions(-) diff --git a/wlanpi_core/__main__.py b/wlanpi_core/__main__.py index 5dbc987..3e16003 100644 --- a/wlanpi_core/__main__.py +++ b/wlanpi_core/__main__.py @@ -111,7 +111,7 @@ def init() -> None: ) if __name__ == "__main__": - sys.exit(main()) + return sys.exit(main()) init() diff --git a/wlanpi_core/models/network/wlan/wlan_dbus.py b/wlanpi_core/models/network/wlan/wlan_dbus.py index 9ef0098..f63f5e0 100644 --- a/wlanpi_core/models/network/wlan/wlan_dbus.py +++ b/wlanpi_core/models/network/wlan/wlan_dbus.py @@ -9,7 +9,9 @@ WPAS_DBUS_OPATH, WPAS_DBUS_SERVICE, ) +from wlanpi_core.models.network.wlan.exceptions import WlanDBUSInterfaceException from wlanpi_core.models.network.wlan.wlan_dbus_interface import WlanDBUSInterface +from wlanpi_core.utils.general import run_command class WlanDBUS: @@ -46,8 +48,21 @@ def get_interface(self, interface) -> WlanDBUSInterface: self.interfaces[interface] = new_interface return self.interfaces[interface] + @staticmethod + def _fetch_system_interfaces() -> list[str]: + return run_command( + "ls /sys/class/ieee80211/*/device/net/", shell=True + ).grep_stdout_for_string("/", negate=True, split=True) + def fetch_interfaces(self, wpas_obj): available_interfaces = [] + for system_interface in self._fetch_system_interfaces(): + try: + self.get_interface(system_interface) + except WlanDBUSInterfaceException as e: + self.logger.warning( + f"Error trying to optimistically register interface {system_interface}: {e}" + ) ifaces = wpas_obj.Get( WPAS_DBUS_INTERFACE, "Interfaces", dbus_interface=self.DBUS_IFACE @@ -71,7 +86,7 @@ def get_systemd_network_interfaces(self, timeout: int = DEFAULT_TIMEOUT): """ wpas_obj = self.bus.get_object(WPAS_DBUS_SERVICE, WPAS_DBUS_OPATH) - self.logger.debug("Checking available interfaces", 3) + self.logger.debug("Checking available interfaces") available_interfaces = self.fetch_interfaces(wpas_obj) - self.logger.debug(f"Available interfaces: {available_interfaces}", 3) + self.logger.debug(f"Available interfaces: {available_interfaces}") return available_interfaces diff --git a/wlanpi_core/utils/g_lib_loop.py b/wlanpi_core/utils/g_lib_loop.py index 73ad398..2c59951 100644 --- a/wlanpi_core/utils/g_lib_loop.py +++ b/wlanpi_core/utils/g_lib_loop.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Callable, Optional from gi.repository import GLib @@ -10,7 +10,7 @@ def __init__( self, loop: Optional[GLib.MainLoop] = None, timeout_seconds: Optional[int] = None, - timeout_callback: Optional[callable] = None, + timeout_callback: Optional[Callable] = None, timeout_callback_args: Optional[list] = None, timeout_callback_kwargs: Optional[dict] = None, ): @@ -25,10 +25,10 @@ def __init__( def start_timeout( self, seconds: Optional[int] = None, - callback: Optional[callable] = None, - *args, - **kwargs - ): + callback: Optional[Callable] = None, + *args: tuple[any], + **kwargs: dict[str, any] + ) -> None: self.timeout_source = GLib.timeout_source_new_seconds( seconds if seconds else self.timeout_seconds ) @@ -41,16 +41,16 @@ def start_timeout( self.loop.get_context() ) - def stop_timeout(self): + def stop_timeout(self) -> None: if self.timeout_source and self.timeout_source_attachment: self.timeout_source.remove(self.timeout_source_attachment) - def finish(self): + def finish(self) -> None: self.stop_timeout() self.loop.quit() - def run(self, *args, **kwargs): - self.loop.run(*args, **kwargs) + def run(self, *args: tuple[any], **kwargs: [dict[str, any]]) -> None: + return self.loop.run(*args, **kwargs) def __enter__(self): return self diff --git a/wlanpi_core/utils/general.py b/wlanpi_core/utils/general.py index 9629b73..9f01ff1 100644 --- a/wlanpi_core/utils/general.py +++ b/wlanpi_core/utils/general.py @@ -223,3 +223,15 @@ def get_current_unix_timestamp() -> float: """ ms = datetime.datetime.now() return time.mktime(ms.timetuple()) * 1000 + + +def byte_array_to_string(s) -> str: + """Converts a byte array to string, replacing non-printable characters with spaces.""" + r = "" + for c in s: + if 32 <= c < 127: + r += "%c" % c + else: + r += " " + # r += urllib.quote(chr(c)) + return r From 92f78828054747523aadc38027f64333ca99cd2f Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Tue, 5 Nov 2024 12:44:48 -0500 Subject: [PATCH 26/41] Fix remnant of wlan0 service --- debian/rules | 1 - wlanpi_core/services/system_service.py | 1 - 2 files changed, 2 deletions(-) diff --git a/debian/rules b/debian/rules index 6edc6d4..edc5c3d 100755 --- a/debian/rules +++ b/debian/rules @@ -23,7 +23,6 @@ SDIST_DIR=debian/$(PACKAGE)-$(VERSION) # ensure that the systemd services are handled by systemd. override_dh_installsystemd: - dh_installsystemd --name=wpa_supplicant@wlan0 wpa_supplicant@wlan0.service dh_installsystemd wlanpi-core.service dh_installsystemd wlanpi-core.socket diff --git a/wlanpi_core/services/system_service.py b/wlanpi_core/services/system_service.py index 30dde00..7daeec4 100644 --- a/wlanpi_core/services/system_service.py +++ b/wlanpi_core/services/system_service.py @@ -40,7 +40,6 @@ "wlanpi-grafana-wipry-lp-6", "wlanpi-grafana-wipry-lp-stop", "wpa_supplicant", - "wpa_supplicant@wlan0", ] PLATFORM_UNKNOWN = "Unknown" From bdb44100bf8b8e4342072a61f0fe7db2214b0117 Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Tue, 5 Nov 2024 18:38:47 -0500 Subject: [PATCH 27/41] Improve error response of wlan endpoints when a non-existent interface is used. added a "current network" endpoint --- .../api/api_v1/endpoints/network_api.py | 19 ++++++++ wlanpi_core/models/network/wlan/exceptions.py | 2 + .../network/wlan/wlan_dbus_interface.py | 19 ++++++-- wlanpi_core/services/network_service.py | 45 ++++++++++++++++++- 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/wlanpi_core/api/api_v1/endpoints/network_api.py b/wlanpi_core/api/api_v1/endpoints/network_api.py index 878dd26..1884df4 100644 --- a/wlanpi_core/api/api_v1/endpoints/network_api.py +++ b/wlanpi_core/api/api_v1/endpoints/network_api.py @@ -331,6 +331,25 @@ async def get_all_wireless_networks(interface: str, timeout: int = API_DEFAULT_T return Response(content="Internal Server Error", status_code=500) +@router.get( + "/wlan/{interface}/networks/current", + response_model=Optional[SupplicantNetwork], + response_model_exclude_none=True, +) +async def get_current_network(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.current_network(interface) + 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/{interface}/networks/{network_id}", response_model=SupplicantNetwork, diff --git a/wlanpi_core/models/network/wlan/exceptions.py b/wlanpi_core/models/network/wlan/exceptions.py index 9c9b4c8..6c57a32 100644 --- a/wlanpi_core/models/network/wlan/exceptions.py +++ b/wlanpi_core/models/network/wlan/exceptions.py @@ -5,6 +5,8 @@ class WlanDBUSException(Exception): class WlanDBUSInterfaceException(WlanDBUSException): pass +class WlanDBUSInterfaceCreationError(WlanDBUSInterfaceException): + pass class WDIScanError(WlanDBUSInterfaceException): pass diff --git a/wlanpi_core/models/network/wlan/wlan_dbus_interface.py b/wlanpi_core/models/network/wlan/wlan_dbus_interface.py index c712024..add89c3 100644 --- a/wlanpi_core/models/network/wlan/wlan_dbus_interface.py +++ b/wlanpi_core/models/network/wlan/wlan_dbus_interface.py @@ -19,7 +19,7 @@ WDIConnectionException, WDIDisconnectedException, WDIScanError, - WlanDBUSInterfaceException, + WlanDBUSInterfaceException, WlanDBUSInterfaceCreationError, ) from wlanpi_core.schemas.network import ( NetworkSetupStatus, @@ -63,7 +63,7 @@ def __init__( ) except dbus.DBusException as exc: if not str(exc).startswith("fi.w1.wpa_supplicant1.InterfaceUnknown:"): - raise WlanDBUSInterfaceException(f"Interface unknown : {exc}") from exc + raise WlanDBUSInterfaceCreationError(f"Interface unknown : {exc}") from exc try: self.interface_dbus_path = self.wpa_supplicant.CreateInterface( {"Ifname": self.interface_name, "Driver": "nl80211"} @@ -71,7 +71,7 @@ def __init__( time.sleep(1) except dbus.DBusException as exc: if not str(exc).startswith("fi.w1.wpa_supplicant1.InterfaceExists:"): - raise WlanDBUSInterfaceException( + raise WlanDBUSInterfaceCreationError( f"Interface cannot be created : {exc}" ) from exc time.sleep(1) @@ -514,3 +514,16 @@ def get_network(self, network_id: int) -> SupplicantNetwork: "Properties", dbus_interface=dbus.PROPERTIES_IFACE, ) + + def current_network( + self, + ) -> Optional[SupplicantNetwork]: + """Returns the currently selected network, if any""" + net_path = self._get_from_wpa_supplicant_interface("CurrentNetwork") + if net_path == "/": + return None + return self._get_dbus_object(net_path).Get( + "fi.w1.wpa_supplicant1.Network", + "Properties", + dbus_interface=dbus.PROPERTIES_IFACE, + ) diff --git a/wlanpi_core/services/network_service.py b/wlanpi_core/services/network_service.py index 7e27441..e6557d4 100644 --- a/wlanpi_core/services/network_service.py +++ b/wlanpi_core/services/network_service.py @@ -2,7 +2,7 @@ from enum import Enum from typing import Optional -from wlanpi_core.models.network.wlan.exceptions import WlanDBUSException +from wlanpi_core.models.network.wlan.exceptions import WlanDBUSException, WlanDBUSInterfaceCreationError from wlanpi_core.models.network.wlan.wlan_dbus import WlanDBUS from wlanpi_core.models.network.wlan.wlan_dbus_interface import WlanDBUSInterface from wlanpi_core.models.validation_error import ValidationError @@ -23,6 +23,9 @@ async def get_systemd_network_interfaces(timeout: int): available_interfaces = wlan_dbus.get_systemd_network_interfaces(timeout=timeout) logging.info(f"Available interfaces: {available_interfaces}") return {"interfaces": available_interfaces} + except WlanDBUSInterfaceCreationError as error: + raise ValidationError("Could not create interface. Check that the requested interface exists.\n" + f"Original error: {str(error)}", status_code=400) except WlanDBUSException as err: # Need to Split exceptions into validation and actual failures raise ValidationError(str(err), status_code=400) from err @@ -49,6 +52,9 @@ async def get_wireless_network_scan_async( return { "nets": await interface_obj.get_network_scan(scan_type, timeout=timeout) } + except WlanDBUSInterfaceCreationError as error: + raise ValidationError("Could not create interface. Check that the requested interface exists.\n" + f"Original error: {str(error)}", status_code=400) except (WlanDBUSException, ValueError) as err: # Need to Split exceptions into validation and actual failures raise ValidationError(str(err), status_code=400) from err @@ -68,6 +74,9 @@ async def add_wireless_network( return await wlan_dbus.get_interface(interface).add_network( wlan_config=net_config, remove_others=remove_all_first, timeout=timeout ) + except WlanDBUSInterfaceCreationError as error: + raise ValidationError("Could not create interface. Check that the requested interface exists.\n" + f"Original error: {str(error)}", status_code=400) except ValueError as error: raise ValidationError(f"{error}", status_code=400) @@ -79,6 +88,9 @@ async def get_current_wireless_network_details(interface: str, timeout: int): try: wlan_dbus = WlanDBUS() return wlan_dbus.get_interface(interface).get_current_network_details() + except WlanDBUSInterfaceCreationError as error: + raise ValidationError("Could not create interface. Check that the requested interface exists.\n" + f"Original error: {str(error)}", status_code=400) except WlanDBUSException as err: raise ValidationError(str(err), status_code=400) from err @@ -93,6 +105,9 @@ async def disconnect_wireless_network( try: wlan_dbus = WlanDBUS() return wlan_dbus.get_interface(interface).disconnect() + except WlanDBUSInterfaceCreationError as error: + raise ValidationError("Could not create interface. Check that the requested interface exists.\n" + f"Original error: {str(error)}", status_code=400) except ValueError as error: raise ValidationError(f"{error}", status_code=400) @@ -106,6 +121,9 @@ async def remove_all_networks( try: wlan_dbus = WlanDBUS() return wlan_dbus.get_interface(interface).remove_all_networks() + except WlanDBUSInterfaceCreationError as error: + raise ValidationError("Could not create interface. Check that the requested interface exists.\n" + f"Original error: {str(error)}", status_code=400) except ValueError as error: raise ValidationError(f"{error}", status_code=400) @@ -120,6 +138,9 @@ async def remove_network( try: wlan_dbus = WlanDBUS() return wlan_dbus.get_interface(interface).remove_network(network_id) + except WlanDBUSInterfaceCreationError as error: + raise ValidationError("Could not create interface. Check that the requested interface exists.\n" + f"Original error: {str(error)}", status_code=400) except ValueError as error: raise ValidationError(f"{error}", status_code=400) @@ -134,6 +155,9 @@ async def get_network( try: wlan_dbus = WlanDBUS() return wlan_dbus.get_interface(interface).get_network(network_id) + except WlanDBUSInterfaceCreationError as error: + raise ValidationError("Could not create interface. Check that the requested interface exists.\n" + f"Original error: {str(error)}", status_code=400) except ValueError as error: raise ValidationError(f"{error}", status_code=400) @@ -147,5 +171,24 @@ async def networks( try: wlan_dbus = WlanDBUS() return wlan_dbus.get_interface(interface).networks() + except WlanDBUSInterfaceCreationError as error: + raise ValidationError("Could not create interface. Check that the requested interface exists.\n" + f"Original error: {str(error)}", status_code=400) + except ValueError as error: + raise ValidationError(f"{error}", status_code=400) + + +async def current_network( + interface: str, +) -> Optional[SupplicantNetwork]: + """ + Uses wpa_supplicant to connect to a WLAN network. + """ + try: + wlan_dbus = WlanDBUS() + return wlan_dbus.get_interface(interface).current_network() + except WlanDBUSInterfaceCreationError as error: + raise ValidationError("Could not create interface. Check that the requested interface exists.\n" + f"Original error: {str(error)}", status_code=400) except ValueError as error: raise ValidationError(f"{error}", status_code=400) From 93083020e5e6c0c966c5093474cf3eb0370d8859 Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Tue, 5 Nov 2024 18:39:03 -0500 Subject: [PATCH 28/41] Fixed formatting --- wlanpi_core/models/network/wlan/exceptions.py | 2 + .../network/wlan/wlan_dbus_interface.py | 7 +- wlanpi_core/services/network_service.py | 75 +++++++++++++------ 3 files changed, 61 insertions(+), 23 deletions(-) diff --git a/wlanpi_core/models/network/wlan/exceptions.py b/wlanpi_core/models/network/wlan/exceptions.py index 6c57a32..d9fe2d2 100644 --- a/wlanpi_core/models/network/wlan/exceptions.py +++ b/wlanpi_core/models/network/wlan/exceptions.py @@ -5,9 +5,11 @@ class WlanDBUSException(Exception): class WlanDBUSInterfaceException(WlanDBUSException): pass + class WlanDBUSInterfaceCreationError(WlanDBUSInterfaceException): pass + class WDIScanError(WlanDBUSInterfaceException): pass diff --git a/wlanpi_core/models/network/wlan/wlan_dbus_interface.py b/wlanpi_core/models/network/wlan/wlan_dbus_interface.py index add89c3..e9d7372 100644 --- a/wlanpi_core/models/network/wlan/wlan_dbus_interface.py +++ b/wlanpi_core/models/network/wlan/wlan_dbus_interface.py @@ -19,7 +19,8 @@ WDIConnectionException, WDIDisconnectedException, WDIScanError, - WlanDBUSInterfaceException, WlanDBUSInterfaceCreationError, + WlanDBUSInterfaceCreationError, + WlanDBUSInterfaceException, ) from wlanpi_core.schemas.network import ( NetworkSetupStatus, @@ -63,7 +64,9 @@ def __init__( ) except dbus.DBusException as exc: if not str(exc).startswith("fi.w1.wpa_supplicant1.InterfaceUnknown:"): - raise WlanDBUSInterfaceCreationError(f"Interface unknown : {exc}") from exc + raise WlanDBUSInterfaceCreationError( + f"Interface unknown : {exc}" + ) from exc try: self.interface_dbus_path = self.wpa_supplicant.CreateInterface( {"Ifname": self.interface_name, "Driver": "nl80211"} diff --git a/wlanpi_core/services/network_service.py b/wlanpi_core/services/network_service.py index e6557d4..f90012a 100644 --- a/wlanpi_core/services/network_service.py +++ b/wlanpi_core/services/network_service.py @@ -2,7 +2,10 @@ from enum import Enum from typing import Optional -from wlanpi_core.models.network.wlan.exceptions import WlanDBUSException, WlanDBUSInterfaceCreationError +from wlanpi_core.models.network.wlan.exceptions import ( + WlanDBUSException, + WlanDBUSInterfaceCreationError, +) from wlanpi_core.models.network.wlan.wlan_dbus import WlanDBUS from wlanpi_core.models.network.wlan.wlan_dbus_interface import WlanDBUSInterface from wlanpi_core.models.validation_error import ValidationError @@ -24,8 +27,11 @@ async def get_systemd_network_interfaces(timeout: int): logging.info(f"Available interfaces: {available_interfaces}") return {"interfaces": available_interfaces} except WlanDBUSInterfaceCreationError as error: - raise ValidationError("Could not create interface. Check that the requested interface exists.\n" - f"Original error: {str(error)}", status_code=400) + raise ValidationError( + "Could not create interface. Check that the requested interface exists.\n" + f"Original error: {str(error)}", + status_code=400, + ) except WlanDBUSException as err: # Need to Split exceptions into validation and actual failures raise ValidationError(str(err), status_code=400) from err @@ -53,8 +59,11 @@ async def get_wireless_network_scan_async( "nets": await interface_obj.get_network_scan(scan_type, timeout=timeout) } except WlanDBUSInterfaceCreationError as error: - raise ValidationError("Could not create interface. Check that the requested interface exists.\n" - f"Original error: {str(error)}", status_code=400) + raise ValidationError( + "Could not create interface. Check that the requested interface exists.\n" + f"Original error: {str(error)}", + status_code=400, + ) except (WlanDBUSException, ValueError) as err: # Need to Split exceptions into validation and actual failures raise ValidationError(str(err), status_code=400) from err @@ -75,8 +84,11 @@ async def add_wireless_network( wlan_config=net_config, remove_others=remove_all_first, timeout=timeout ) except WlanDBUSInterfaceCreationError as error: - raise ValidationError("Could not create interface. Check that the requested interface exists.\n" - f"Original error: {str(error)}", status_code=400) + raise ValidationError( + "Could not create interface. Check that the requested interface exists.\n" + f"Original error: {str(error)}", + status_code=400, + ) except ValueError as error: raise ValidationError(f"{error}", status_code=400) @@ -89,8 +101,11 @@ async def get_current_wireless_network_details(interface: str, timeout: int): wlan_dbus = WlanDBUS() return wlan_dbus.get_interface(interface).get_current_network_details() except WlanDBUSInterfaceCreationError as error: - raise ValidationError("Could not create interface. Check that the requested interface exists.\n" - f"Original error: {str(error)}", status_code=400) + raise ValidationError( + "Could not create interface. Check that the requested interface exists.\n" + f"Original error: {str(error)}", + status_code=400, + ) except WlanDBUSException as err: raise ValidationError(str(err), status_code=400) from err @@ -106,8 +121,11 @@ async def disconnect_wireless_network( wlan_dbus = WlanDBUS() return wlan_dbus.get_interface(interface).disconnect() except WlanDBUSInterfaceCreationError as error: - raise ValidationError("Could not create interface. Check that the requested interface exists.\n" - f"Original error: {str(error)}", status_code=400) + raise ValidationError( + "Could not create interface. Check that the requested interface exists.\n" + f"Original error: {str(error)}", + status_code=400, + ) except ValueError as error: raise ValidationError(f"{error}", status_code=400) @@ -122,8 +140,11 @@ async def remove_all_networks( wlan_dbus = WlanDBUS() return wlan_dbus.get_interface(interface).remove_all_networks() except WlanDBUSInterfaceCreationError as error: - raise ValidationError("Could not create interface. Check that the requested interface exists.\n" - f"Original error: {str(error)}", status_code=400) + raise ValidationError( + "Could not create interface. Check that the requested interface exists.\n" + f"Original error: {str(error)}", + status_code=400, + ) except ValueError as error: raise ValidationError(f"{error}", status_code=400) @@ -139,8 +160,11 @@ async def remove_network( wlan_dbus = WlanDBUS() return wlan_dbus.get_interface(interface).remove_network(network_id) except WlanDBUSInterfaceCreationError as error: - raise ValidationError("Could not create interface. Check that the requested interface exists.\n" - f"Original error: {str(error)}", status_code=400) + raise ValidationError( + "Could not create interface. Check that the requested interface exists.\n" + f"Original error: {str(error)}", + status_code=400, + ) except ValueError as error: raise ValidationError(f"{error}", status_code=400) @@ -156,8 +180,11 @@ async def get_network( wlan_dbus = WlanDBUS() return wlan_dbus.get_interface(interface).get_network(network_id) except WlanDBUSInterfaceCreationError as error: - raise ValidationError("Could not create interface. Check that the requested interface exists.\n" - f"Original error: {str(error)}", status_code=400) + raise ValidationError( + "Could not create interface. Check that the requested interface exists.\n" + f"Original error: {str(error)}", + status_code=400, + ) except ValueError as error: raise ValidationError(f"{error}", status_code=400) @@ -172,8 +199,11 @@ async def networks( wlan_dbus = WlanDBUS() return wlan_dbus.get_interface(interface).networks() except WlanDBUSInterfaceCreationError as error: - raise ValidationError("Could not create interface. Check that the requested interface exists.\n" - f"Original error: {str(error)}", status_code=400) + raise ValidationError( + "Could not create interface. Check that the requested interface exists.\n" + f"Original error: {str(error)}", + status_code=400, + ) except ValueError as error: raise ValidationError(f"{error}", status_code=400) @@ -188,7 +218,10 @@ async def current_network( wlan_dbus = WlanDBUS() return wlan_dbus.get_interface(interface).current_network() except WlanDBUSInterfaceCreationError as error: - raise ValidationError("Could not create interface. Check that the requested interface exists.\n" - f"Original error: {str(error)}", status_code=400) + raise ValidationError( + "Could not create interface. Check that the requested interface exists.\n" + f"Original error: {str(error)}", + status_code=400, + ) except ValueError as error: raise ValidationError(f"{error}", status_code=400) From cc45c052d78635a2db1e4ce8a19954caa45ead24 Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Wed, 6 Nov 2024 17:34:33 -0500 Subject: [PATCH 29/41] Draft of ap radio info stuff --- debian/changelog | 7 + .../api/api_v1/endpoints/network_api.py | 23 ++ wlanpi_core/services/network_service.py | 10 + wlanpi_core/utils/network.py | 224 +++++++++++++++++- 4 files changed, 263 insertions(+), 1 deletion(-) diff --git a/debian/changelog b/debian/changelog index c1f81bb..2b922f3 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,10 @@ +wlanpi-core (1.0.5-1.2) UNRELEASED; urgency=medium + + [ Michael Ketchel ] + * Draft build of new wlan control features + + -- Michael Ketchel Wed, 06 Nov 2024 22:33:31 +0000 + wlanpi-core (1.0.5-1) unstable; urgency=high * API hardening by blocking iptables rule added by pi-gen diff --git a/wlanpi_core/api/api_v1/endpoints/network_api.py b/wlanpi_core/api/api_v1/endpoints/network_api.py index 1884df4..c894d83 100644 --- a/wlanpi_core/api/api_v1/endpoints/network_api.py +++ b/wlanpi_core/api/api_v1/endpoints/network_api.py @@ -408,3 +408,26 @@ async def disconnect_wireless_network(interface: str, network_id: int): except Exception as ex: log.error(ex) return Response(content="Internal Server Error", status_code=500) + + +@router.get( + "/wlan/{interface}/phy", + response_model=Optional[dict[str, dict[str, any]]], + response_model_exclude_none=True, +) +@router.get( + "/wlan/phys", + response_model=Optional[dict[str, dict[str, any]]], + response_model_exclude_none=True, +) +async def get_interface_details(interface: Optional[str] = None): + """ + Gets interface details via iw. + """ + try: + return await network_service.interface_details(interface) + 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/services/network_service.py b/wlanpi_core/services/network_service.py index f90012a..dc0d042 100644 --- a/wlanpi_core/services/network_service.py +++ b/wlanpi_core/services/network_service.py @@ -1,3 +1,4 @@ +import json import logging from enum import Enum from typing import Optional @@ -11,6 +12,9 @@ from wlanpi_core.models.validation_error import ValidationError from wlanpi_core.schemas import network from wlanpi_core.schemas.network.network import SupplicantNetwork +from wlanpi_core.utils.network import ( + get_interface_details, +) """ These are the functions used to deliver the API @@ -225,3 +229,9 @@ async def current_network( ) except ValueError as error: raise ValidationError(f"{error}", status_code=400) + + +async def interface_details( + interface: Optional[str], +) -> Optional[dict[str, dict[str, any]]]: + return get_interface_details(interface) diff --git a/wlanpi_core/utils/network.py b/wlanpi_core/utils/network.py index 7b2840e..29c66e3 100644 --- a/wlanpi_core/utils/network.py +++ b/wlanpi_core/utils/network.py @@ -1,6 +1,9 @@ +import json import logging +import re import time -from typing import Any, Optional +from collections import deque +from typing import Any, Optional, Union from wlanpi_core.models.runcommand_error import RunCommandError from wlanpi_core.utils.general import run_command @@ -85,3 +88,222 @@ def renew_dhcp(interface) -> None: f"Failed to renew DHCP. Code:{err.return_code}, Error: {err.error_msg}" ) return None + + +class WlanChannelInfo: + def __init__( + self, + band, + frequency, + channel_number, + max_tx_power, + channel_widths, + disabled=False, + radar_detection=False, + dfs_state=None, + dfs_cac_time=None, + no_ir=False, + ): + self.band = band + self.frequency = frequency + self.channel_number = channel_number + self.max_tx_power = max_tx_power + self.channel_widths = channel_widths + self.disabled = disabled + self.radar_detection = radar_detection + self.dfs_state = dfs_state + self.dfs_cac_time = dfs_cac_time + self.no_ir = no_ir + + def __repr__(self): + return f"Band {self.band}: {self.frequency} MHz [{self.channel_number}]" + + def to_json(self): + """Returns a JSON representation of the channel information.""" + return json.dumps(self.__dict__, indent=2) # Use __dict__ to get all attributes + + +def parse_iw_phy_output(output): + """Parses the output of 'iw phy channels' into a list of ChannelInfo objects.""" + + channels = [] + current_band = None + for line in output.splitlines(): + line = line.strip() + + if line.startswith("Band"): + current_band = int(line.split(":")[0].split()[1]) + continue + + if line.startswith("*"): + match = re.match(r"\* (\d+) MHz \[(\d+)\](?: \((.*?)\))?", line) + if match: + frequency = int(match.group(1)) + channel_number = int(match.group(2)) + disabled = False + if match.group(3) and "disabled" in match.group(3): + disabled = True + + channel_info = WlanChannelInfo( + current_band, frequency, channel_number, None, [], disabled + ) + + channels.append(channel_info) + continue + + if "Maximum TX power:" in line: + channels[-1].max_tx_power = float(line.split(":")[1].strip().split()[0]) + if "Channel widths:" in line: + channels[-1].channel_widths = line.split(":")[1].strip().split() + if "Radar detection" in line: + channels[-1].radar_detection = True + if "DFS state:" in line: + channels[-1].dfs_state = line.split(":")[1].strip() + if "DFS CAC time:" in line: + channels[-1].dfs_cac_time = int(line.split(":")[1].strip().split()[0]) + if "No IR" in line: + channels[-1].no_ir = True + + return channels + + +def get_interface_phy_num(interface: str) -> Optional[int]: + lines = run_command(["iw", "dev", interface, "info"]).grep_stdout_for_string( + "wiphy", split=True + ) + if lines: + return int(lines[0].strip().split(" ")[1]) + return None + + +def get_phy_interface_name(phy_num: int) -> Optional[str]: + res = run_command( + ["ls", f"/sys/class/ieee80211/phy{phy_num}/device/net/"], raise_on_fail=False + ) + if res.success: + return res.stdout.strip() + else: + return None + + +def get_wlan_channels(interface: str) -> list[WlanChannelInfo]: + phy = get_interface_phy_num(interface) + if phy is None: + return [] + return parse_iw_phy_output( + run_command(["iw", "phy", f"phy{phy}", "channels"]).stdout + ) + + +def parse_indented_output(lines: Union[str, list]): + """Parses command output based on indentation, creating nested dicts/lists.""" + + def process_lines(lines_deque: deque[str], current_indent=0) -> Union[dict, list]: + """Recursively processes lines based on indentation.""" + pairs = [] + + while len(lines_deque): + # Bail out if the next line is a higher level. + next_indent = len(lines_deque[0]) - len(lines_deque[0].lstrip()) + if next_indent < current_indent: + break + if next_indent == current_indent: + line = lines_deque.popleft() + next_indent = len(lines_deque) and len(lines_deque[0]) - len( + lines_deque[0].lstrip() + ) + if next_indent > current_indent: + # This line has a sublevel, so we recurse to get the value. + sub_result = process_lines(lines_deque, next_indent) + pairs.append([line.strip(), sub_result]) + else: + pairs.append([line.strip(), None]) + return dict(pairs) + + if lines is str: + lines = lines.split("\n") + return process_lines(deque(lines)) + + +def parse_iw_list(lines: Union[str, list]): + """Parses iw list output based on indentation, creating nested dicts/lists.""" + + def process_lines(lines_deque: deque[str], current_indent=0) -> Union[dict, list]: + """Recursively processes lines based on indentation.""" + pairs = [] + + while len(lines_deque): + # Bail out if the next line is a higher level. + next_indent = len(lines_deque[0]) - len(lines_deque[0].lstrip()) + if next_indent < current_indent: + break + if next_indent == current_indent: + line = lines_deque.popleft() + # Handle an annoying multiline output case + if line.lstrip().startswith("*"): + while len(lines_deque) and ( + len(lines_deque[0]) - len(lines_deque[0].lstrip()) + > current_indent + ): + if not lines_deque[0].strip().startswith("*"): + line += " " + lines_deque.popleft().strip() + + next_indent = len(lines_deque) and len(lines_deque[0]) - len( + lines_deque[0].lstrip() + ) + if next_indent > current_indent: + # This line has a sublevel, so we recurse to get the value. + sub_result = process_lines(lines_deque, next_indent) + pairs.append([line.strip(), sub_result]) + else: + pairs.append([line.strip(), None]) + + # Detect dict-like structure + if any( + ": " in pair[0] or pair[0].rstrip().endswith(":") or pair[1] is not None + for pair in pairs + ): + data = {"flags": []} + for pair in pairs: + pair[0] = pair[0].lstrip("*").lstrip() + # We already have key-value data, so it must be a pair. + if pair[1] is not None: + data[pair[0].rstrip(":")] = pair[1] + elif ": " in pair[0]: + key, value = pair[0].split(": ", maxsplit=1) + if value: + data[key] = value.strip() + else: + data["flags"].append(pair[0]) + return data + # Almost definitely a list + else: + return [pair[0].lstrip("*").lstrip() for pair in pairs] + + if lines is str: + lines = lines.split("\n") + return process_lines(deque(lines)) + + +def get_interface_details( + interface: Optional[str] = None, +) -> Optional[dict[str, dict[str, any]]]: + if interface: + phy_num = get_interface_phy_num(interface=interface) + if phy_num is None: + return None + iw_list_data = parse_iw_list( + run_command(["iw", "phy", f"phy{phy_num}", "info"]).stdout.split("\n") + ) + else: + iw_list_data = parse_iw_list(run_command(["iw", "list"]).stdout.split("\n")) + + return { + get_phy_interface_name(k.split(" ")[1].split("phy")[1]): v + for k, v in iw_list_data.items() + if "phy" in k + } + + +if __name__ == "__main__": + print(json.dumps(get_interface_details())) From 7958b67d28f2bc947046a4b2053a1b046636075c Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Wed, 6 Nov 2024 17:48:46 -0500 Subject: [PATCH 30/41] fixed a tyoi --- debian/changelog | 5 +++-- wlanpi_core/api/api_v1/endpoints/network_api.py | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/debian/changelog b/debian/changelog index 2b922f3..1d8eb8a 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,9 +1,10 @@ -wlanpi-core (1.0.5-1.2) UNRELEASED; urgency=medium +wlanpi-core (1.0.5-2) UNRELEASED; urgency=medium [ Michael Ketchel ] * Draft build of new wlan control features + * Fixed an api definition typo - -- Michael Ketchel Wed, 06 Nov 2024 22:33:31 +0000 + -- Michael Ketchel Wed, 06 Nov 2024 22:48:34 +0000 wlanpi-core (1.0.5-1) unstable; urgency=high diff --git a/wlanpi_core/api/api_v1/endpoints/network_api.py b/wlanpi_core/api/api_v1/endpoints/network_api.py index c894d83..472689d 100644 --- a/wlanpi_core/api/api_v1/endpoints/network_api.py +++ b/wlanpi_core/api/api_v1/endpoints/network_api.py @@ -412,12 +412,14 @@ async def disconnect_wireless_network(interface: str, network_id: int): @router.get( "/wlan/{interface}/phy", - response_model=Optional[dict[str, dict[str, any]]], + response_model=None, response_model_exclude_none=True, ) @router.get( "/wlan/phys", - response_model=Optional[dict[str, dict[str, any]]], + # Want to make a nicer response model for this, but the data returned is very not conducive. + # response_model=Optional[dict[str, dict[str, any]]], + response_model=None, response_model_exclude_none=True, ) async def get_interface_details(interface: Optional[str] = None): From 5767c3b4390ed525678e090627cf2c2f5b1caf8a Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Wed, 6 Nov 2024 23:45:43 -0500 Subject: [PATCH 31/41] Cleanup commit, plus more data! --- .../api/api_v1/endpoints/network_api.py | 18 +++++++++--------- wlanpi_core/services/network_service.py | 4 +--- wlanpi_core/utils/network.py | 12 +++++++++++- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/wlanpi_core/api/api_v1/endpoints/network_api.py b/wlanpi_core/api/api_v1/endpoints/network_api.py index 472689d..f20743e 100644 --- a/wlanpi_core/api/api_v1/endpoints/network_api.py +++ b/wlanpi_core/api/api_v1/endpoints/network_api.py @@ -212,7 +212,7 @@ async def delete_ethernet_vlan( @router.get("/wlan/interfaces", response_model=network.Interfaces) async def get_wireless_interfaces(timeout: int = API_DEFAULT_TIMEOUT): """ - Queries systemd via dbus to get the details of the currently connected network. + Queries wpa_supplicant via dbus to get all interfaces known to the supplicant. """ try: @@ -233,7 +233,7 @@ async def do_wireless_network_scan( scan_type: str, interface: str, timeout: int = API_DEFAULT_TIMEOUT ): """ - Queries systemd via dbus to get a scan of the available networks. + Queries wpa_supplicant via dbus to get a scan of the available networks for an interface. """ try: @@ -254,7 +254,7 @@ async def add_wireless_network( timeout: int = API_DEFAULT_TIMEOUT, ): """ - Queries systemd via dbus to set a single network. + Queries wpa_supplicant via dbus to set a single network. """ try: @@ -277,7 +277,7 @@ async def get_current_wireless_network_details( interface: str, timeout: int = API_DEFAULT_TIMEOUT ): """ - Queries systemd via dbus to get the details of the currently connected network. + Queries wpa_supplicant via dbus to get the details of the currently connected network. """ try: @@ -300,7 +300,7 @@ async def disconnect_wireless_network( interface: str, timeout: int = API_DEFAULT_TIMEOUT ): """ - Queries systemd via dbus to get the details of the currently connected network. + Disconnects the currently connected network for the specified interface. """ try: @@ -319,7 +319,7 @@ async def disconnect_wireless_network( ) async def get_all_wireless_networks(interface: str, timeout: int = API_DEFAULT_TIMEOUT): """ - Queries systemd via dbus to get the details of the currently connected network. + Queries wpa_supplicant via dbus to get all network on an interface. """ try: @@ -338,7 +338,7 @@ async def get_all_wireless_networks(interface: str, timeout: int = API_DEFAULT_T ) async def get_current_network(interface: str, timeout: int = API_DEFAULT_TIMEOUT): """ - Queries systemd via dbus to get the details of the currently connected network. + Queries wpa_supplicant via dbus to get the details of the currently selected network. """ try: @@ -357,7 +357,7 @@ async def get_current_network(interface: str, timeout: int = API_DEFAULT_TIMEOUT ) async def get_wireless_network(interface: str, network_id: int): """ - Queries systemd via dbus to get the details of the currently connected network. + Queries wpa_supplicant via dbus to get the details of a specific network. """ try: @@ -395,7 +395,7 @@ async def remove_all_wireless_networks(interface: str): ) async def disconnect_wireless_network(interface: str, network_id: int): """ - Queries systemd via dbus to get the details of the currently connected network. + Disconnects the specified wireless network. """ try: diff --git a/wlanpi_core/services/network_service.py b/wlanpi_core/services/network_service.py index dc0d042..b3a6b3d 100644 --- a/wlanpi_core/services/network_service.py +++ b/wlanpi_core/services/network_service.py @@ -12,9 +12,7 @@ from wlanpi_core.models.validation_error import ValidationError from wlanpi_core.schemas import network from wlanpi_core.schemas.network.network import SupplicantNetwork -from wlanpi_core.utils.network import ( - get_interface_details, -) +from wlanpi_core.utils.network import get_interface_details """ These are the functions used to deliver the API diff --git a/wlanpi_core/utils/network.py b/wlanpi_core/utils/network.py index 29c66e3..7b9a2df 100644 --- a/wlanpi_core/utils/network.py +++ b/wlanpi_core/utils/network.py @@ -299,11 +299,21 @@ def get_interface_details( iw_list_data = parse_iw_list(run_command(["iw", "list"]).stdout.split("\n")) return { - get_phy_interface_name(k.split(" ")[1].split("phy")[1]): v + get_phy_interface_name(k.split(" ")[1].split("phy")[1]): { + "phy_name": k.split(" ")[1], + "mac": get_interface_mac( + get_phy_interface_name(k.split(" ")[1].split("phy")[1]) + ), + "details": v, + } for k, v in iw_list_data.items() if "phy" in k } +def get_interface_mac(interface: str) -> str: + return run_command(["jc", "ifconfig", interface]).output_from_json()[0]["mac_addr"] + + if __name__ == "__main__": print(json.dumps(get_interface_details())) From d98427973a9782e2a61f92dbdfdc0bdf42ee0911 Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Wed, 6 Nov 2024 23:48:06 -0500 Subject: [PATCH 32/41] bump changelog --- debian/changelog | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/debian/changelog b/debian/changelog index 1d8eb8a..4a97405 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,10 +1,11 @@ -wlanpi-core (1.0.5-2) UNRELEASED; urgency=medium +wlanpi-core (1.0.5-3) UNRELEASED; urgency=medium [ Michael Ketchel ] * Draft build of new wlan control features * Fixed an api definition typo + * More tweaks, and a version bump to fire dev package deploy - -- Michael Ketchel Wed, 06 Nov 2024 22:48:34 +0000 + -- Michael Ketchel Thu, 07 Nov 2024 04:47:52 +0000 wlanpi-core (1.0.5-1) unstable; urgency=high From 24a47cde93f92eeeac2fa398dd43b58211a4b6d8 Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Thu, 7 Nov 2024 13:01:09 -0500 Subject: [PATCH 33/41] fix imports and duplicate defs --- .../api/api_v1/endpoints/network_api.py | 48 +++++++++---------- wlanpi_core/services/network_service.py | 1 - 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/wlanpi_core/api/api_v1/endpoints/network_api.py b/wlanpi_core/api/api_v1/endpoints/network_api.py index f20743e..27fb5d8 100644 --- a/wlanpi_core/api/api_v1/endpoints/network_api.py +++ b/wlanpi_core/api/api_v1/endpoints/network_api.py @@ -290,28 +290,6 @@ async def get_current_wireless_network_details( log.error(ex) return Response(content="Internal Server Error", status_code=500) - -@router.post( - "/wlan/{interface}/disconnect", - response_model=None, - response_model_exclude_none=True, -) -async def disconnect_wireless_network( - interface: str, timeout: int = API_DEFAULT_TIMEOUT -): - """ - Disconnects the currently connected network for the specified interface. - """ - - try: - return await network_service.disconnect_wireless_network(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.get( "/wlan/{interface}/networks", response_model=dict[int, SupplicantNetwork], @@ -388,14 +366,36 @@ async def remove_all_wireless_networks(interface: str): return Response(content="Internal Server Error", status_code=500) +@router.post( + "/wlan/{interface}/disconnect", + response_model=None, + response_model_exclude_none=True, +) +async def disconnect_wireless_network( + interface: str, timeout: int = API_DEFAULT_TIMEOUT +): + """ + Disconnects the currently connected network for the specified interface. + """ + + try: + return await network_service.disconnect_wireless_network(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.delete( "/wlan/{interface}/networks/{network_id}", response_model=None, response_model_exclude_none=True, ) -async def disconnect_wireless_network(interface: str, network_id: int): +async def remove_wireless_network(interface: str, network_id: int): """ - Disconnects the specified wireless network. + Removes the specified wireless network config. """ try: diff --git a/wlanpi_core/services/network_service.py b/wlanpi_core/services/network_service.py index b3a6b3d..b28dc69 100644 --- a/wlanpi_core/services/network_service.py +++ b/wlanpi_core/services/network_service.py @@ -1,4 +1,3 @@ -import json import logging from enum import Enum from typing import Optional From 24a1997c7f4e5ae9868a29c6a59456cb5a3d7ac9 Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Thu, 7 Nov 2024 14:47:03 -0500 Subject: [PATCH 34/41] Fix formatting --- wlanpi_core/api/api_v1/endpoints/network_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wlanpi_core/api/api_v1/endpoints/network_api.py b/wlanpi_core/api/api_v1/endpoints/network_api.py index 27fb5d8..b1ded7f 100644 --- a/wlanpi_core/api/api_v1/endpoints/network_api.py +++ b/wlanpi_core/api/api_v1/endpoints/network_api.py @@ -290,6 +290,7 @@ async def get_current_wireless_network_details( log.error(ex) return Response(content="Internal Server Error", status_code=500) + @router.get( "/wlan/{interface}/networks", response_model=dict[int, SupplicantNetwork], @@ -387,7 +388,6 @@ async def disconnect_wireless_network( return Response(content="Internal Server Error", status_code=500) - @router.delete( "/wlan/{interface}/networks/{network_id}", response_model=None, From abbf8e2c4c5ac9e878451c0589d7f4223761ab64 Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Thu, 7 Nov 2024 16:13:30 -0500 Subject: [PATCH 35/41] More cleanup, and armored up the network API against command injection via interface name --- wlanpi_core/__main__.py | 7 +- .../api/api_v1/endpoints/network_api.py | 91 ++++++++++++++----- wlanpi_core/models/network/wlan/wlan_dbus.py | 9 +- .../services/network_ethernet_service.py | 6 +- wlanpi_core/services/network_info_service.py | 2 +- wlanpi_core/services/system_service.py | 6 +- wlanpi_core/utils/general.py | 40 ++++++-- wlanpi_core/utils/network.py | 15 ++- 8 files changed, 129 insertions(+), 47 deletions(-) diff --git a/wlanpi_core/__main__.py b/wlanpi_core/__main__.py index 3e16003..1cf69f3 100644 --- a/wlanpi_core/__main__.py +++ b/wlanpi_core/__main__.py @@ -18,6 +18,7 @@ import os import platform import sys +from typing import Union # third party imports import uvicorn @@ -26,7 +27,7 @@ from .__version__ import __version__ -def port(port) -> int: +def check_port(port: Union[int, str]) -> int: """Check if the provided port is valid""" try: # make sure port is an int @@ -52,7 +53,7 @@ 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("--port", "-p", dest="port", type=check_port, default=8000) parser.add_argument( "--version", "-V", "-v", action="version", version=f"{__version__}" @@ -111,7 +112,7 @@ def init() -> None: ) if __name__ == "__main__": - return sys.exit(main()) + sys.exit(main()) init() diff --git a/wlanpi_core/api/api_v1/endpoints/network_api.py b/wlanpi_core/api/api_v1/endpoints/network_api.py index b1ded7f..ae532bf 100644 --- a/wlanpi_core/api/api_v1/endpoints/network_api.py +++ b/wlanpi_core/api/api_v1/endpoints/network_api.py @@ -14,25 +14,49 @@ SupplicantNetwork, ) from wlanpi_core.services import network_ethernet_service, network_service +from wlanpi_core.utils.network import list_ethernet_interfaces, list_wlan_interfaces router = APIRouter() log = logging.getLogger("uvicorn") +def validate_wlan_interface(interface: Optional[str], required: bool = True) -> None: + if (required or interface is not None) and interface not in list_wlan_interfaces(): + raise ValidationError( + f"Invalid/unavailable interface specified: #{interface}", status_code=400 + ) + + +def validate_ethernet_interface( + interface: Optional[str], required: bool = True +) -> None: + if ( + required or interface is not None + ) and interface not in list_ethernet_interfaces(): + raise ValidationError( + f"Invalid/unavailable interface specified: #{interface}", status_code=400 + ) + + ################################ # General Network Management # ################################ @router.get("/interfaces", response_model=dict[str, list[IPInterface]]) @router.get("/interfaces/{interface}", response_model=dict[str, list[IPInterface]]) -async def show_all_interfaces(interface: Optional[str] = None): +async def show_all_interfaces( + interface: Optional[str] = None, +): """ Returns all network interfaces. """ - if interface and interface.lower() == "all": - interface = None try: + if interface and interface.lower() == "all": + interface = None + else: + validate_ethernet_interface(interface, required=False) + return await network_ethernet_service.get_interfaces(interface=interface) except ValidationError as ve: return Response(content=ve.error_msg, status_code=ve.status_code) @@ -52,10 +76,12 @@ async def show_all_ethernet_interfaces(interface: Optional[str] = None): """ Returns all ethernet interfaces. """ - if interface and interface.lower() == "all": - interface = None try: + if interface and interface.lower() == "all": + interface = None + else: + validate_ethernet_interface(interface, required=False) def filterfunc(i): iface_obj = i.model_dump() @@ -96,24 +122,29 @@ async def show_all_ethernet_vlans( """ Returns all VLANS for a given ethernet interface. """ - custom_filter = lambda i: True - if not interface or interface.lower() == "all": - interface = None - if vlan and vlan.lower() == "all": - vlan = None - if vlan and vlan.lower() != "all": - - def filterfunc(i): - return i.model_dump().get("linkinfo", {}).get( - "info_kind" - ) == "vlan" and i.model_dump().get("linkinfo", {}).get("info_data", {}).get( - "id" - ) == int( - vlan - ) - - custom_filter = filterfunc try: + custom_filter = lambda i: True + if not interface or interface.lower() == "all": + interface = None + else: + validate_ethernet_interface(interface, required=False) + if vlan and vlan.lower() == "all": + vlan = None + if vlan and vlan.lower() != "all": + + def filterfunc(i): + return i.model_dump().get("linkinfo", {}).get( + "info_kind" + ) == "vlan" and i.model_dump().get("linkinfo", {}).get( + "info_data", {} + ).get( + "id" + ) == int( + vlan + ) + + custom_filter = filterfunc + return await network_ethernet_service.get_vlans( interface=interface, custom_filter=custom_filter ) @@ -147,6 +178,7 @@ async def create_ethernet_vlan( return Response(content=ve.error_msg, status_code=ve.status_code) try: + validate_ethernet_interface(interface, required=True) await network_ethernet_service.remove_vlan( interface=interface, vlan_id=vlan, allow_missing=True ) @@ -187,6 +219,7 @@ async def delete_ethernet_vlan( return Response(content=ve.error_msg, status_code=ve.status_code) try: + validate_ethernet_interface(interface, required=True) await network_ethernet_service.remove_vlan( interface=interface, vlan_id=vlan, allow_missing=allow_missing ) @@ -210,7 +243,7 @@ async def delete_ethernet_vlan( @router.get("/wlan/interfaces", response_model=network.Interfaces) -async def get_wireless_interfaces(timeout: int = API_DEFAULT_TIMEOUT): +async def get_all_wireless_interfaces(timeout: int = API_DEFAULT_TIMEOUT): """ Queries wpa_supplicant via dbus to get all interfaces known to the supplicant. """ @@ -237,6 +270,7 @@ async def do_wireless_network_scan( """ try: + validate_wlan_interface(interface) return await network_service.get_wireless_network_scan_async( scan_type, interface, timeout ) @@ -258,6 +292,7 @@ async def add_wireless_network( """ try: + validate_wlan_interface(interface) return await network_service.add_wireless_network( interface, setup.netConfig, setup.removeAllFirst, timeout ) @@ -281,6 +316,7 @@ async def get_current_wireless_network_details( """ try: + validate_wlan_interface(interface) return await network_service.get_current_wireless_network_details( interface, timeout ) @@ -302,6 +338,7 @@ async def get_all_wireless_networks(interface: str, timeout: int = API_DEFAULT_T """ try: + validate_wlan_interface(interface) return await network_service.networks(interface) except ValidationError as ve: return Response(content=ve.error_msg, status_code=ve.status_code) @@ -321,6 +358,7 @@ async def get_current_network(interface: str, timeout: int = API_DEFAULT_TIMEOUT """ try: + validate_wlan_interface(interface) return await network_service.current_network(interface) except ValidationError as ve: return Response(content=ve.error_msg, status_code=ve.status_code) @@ -340,6 +378,7 @@ async def get_wireless_network(interface: str, network_id: int): """ try: + validate_wlan_interface(interface) return await network_service.get_network(interface, network_id) except ValidationError as ve: return Response(content=ve.error_msg, status_code=ve.status_code) @@ -359,6 +398,7 @@ async def remove_all_wireless_networks(interface: str): """ try: + validate_wlan_interface(interface) return await network_service.remove_all_networks(interface) except ValidationError as ve: return Response(content=ve.error_msg, status_code=ve.status_code) @@ -380,6 +420,7 @@ async def disconnect_wireless_network( """ try: + validate_wlan_interface(interface) return await network_service.disconnect_wireless_network(interface, timeout) except ValidationError as ve: return Response(content=ve.error_msg, status_code=ve.status_code) @@ -399,6 +440,7 @@ async def remove_wireless_network(interface: str, network_id: int): """ try: + validate_wlan_interface(interface) return await network_service.remove_network( interface, network_id, @@ -426,7 +468,10 @@ async def get_interface_details(interface: Optional[str] = None): """ Gets interface details via iw. """ + try: + validate_wlan_interface(interface, required=False) + return await network_service.interface_details(interface) except ValidationError as ve: return Response(content=ve.error_msg, status_code=ve.status_code) diff --git a/wlanpi_core/models/network/wlan/wlan_dbus.py b/wlanpi_core/models/network/wlan/wlan_dbus.py index f63f5e0..27c99db 100644 --- a/wlanpi_core/models/network/wlan/wlan_dbus.py +++ b/wlanpi_core/models/network/wlan/wlan_dbus.py @@ -12,6 +12,7 @@ from wlanpi_core.models.network.wlan.exceptions import WlanDBUSInterfaceException from wlanpi_core.models.network.wlan.wlan_dbus_interface import WlanDBUSInterface from wlanpi_core.utils.general import run_command +from wlanpi_core.utils.network import list_wlan_interfaces class WlanDBUS: @@ -48,15 +49,9 @@ def get_interface(self, interface) -> WlanDBUSInterface: self.interfaces[interface] = new_interface return self.interfaces[interface] - @staticmethod - def _fetch_system_interfaces() -> list[str]: - return run_command( - "ls /sys/class/ieee80211/*/device/net/", shell=True - ).grep_stdout_for_string("/", negate=True, split=True) - def fetch_interfaces(self, wpas_obj): available_interfaces = [] - for system_interface in self._fetch_system_interfaces(): + for system_interface in list_wlan_interfaces(): try: self.get_interface(system_interface) except WlanDBUSInterfaceException as e: diff --git a/wlanpi_core/services/network_ethernet_service.py b/wlanpi_core/services/network_ethernet_service.py index 9e84f45..0f13771 100644 --- a/wlanpi_core/services/network_ethernet_service.py +++ b/wlanpi_core/services/network_ethernet_service.py @@ -3,7 +3,7 @@ from ..models.network import common from ..models.network.vlan import LiveVLANs from ..models.network.vlan.vlan_file import VLANFile -from ..schemas.network.network import IPInterfaceAddress +from ..schemas.network.network import IPInterface, IPInterfaceAddress from ..schemas.network.types import CustomIPInterfaceFilter # https://man.cx/interfaces(5) @@ -53,10 +53,10 @@ async def remove_vlan(interface: str, vlan_id: Union[str, int], allow_missing=Fa async def get_interfaces( - interface: str, + interface: Optional[str], allow_missing=False, custom_filter: Optional[CustomIPInterfaceFilter] = None, -): +) -> dict[str, list[IPInterface]]: """ Returns definitions for all network interfaces known by the `ip` command. """ diff --git a/wlanpi_core/services/network_info_service.py b/wlanpi_core/services/network_info_service.py index 2f7418a..54e05e1 100644 --- a/wlanpi_core/services/network_info_service.py +++ b/wlanpi_core/services/network_info_service.py @@ -123,7 +123,7 @@ def show_wlan_interfaces(): try: interfaces = run_command( - f"{IW_FILE} dev 2>&1", shell=True + f"{IW_FILE} dev 2>&1", shell=True, use_shlex=False ).grep_stdout_for_pattern(r"interface", flags=re.I, split=True) interfaces = map(lambda x: x.strip().split(" ")[1], interfaces) except Exception as e: diff --git a/wlanpi_core/services/system_service.py b/wlanpi_core/services/system_service.py index 7daeec4..5175265 100644 --- a/wlanpi_core/services/system_service.py +++ b/wlanpi_core/services/system_service.py @@ -176,14 +176,14 @@ def get_stats(): # determine mem useage cmd = "free -m | awk 'NR==2{printf \"%s/%sMB %.2f%%\", $3,$2,$3*100/$2 }'" try: - MemUsage = run_command(cmd, shell=True).stdout.strip() + MemUsage = run_command(cmd, shell=True, use_shlex=False).stdout.strip() except Exception: MemUsage = "unknown" # determine disk util cmd = 'df -h | awk \'$NF=="/"{printf "%d/%dGB %s", $3,$2,$5}\'' try: - Disk = run_command(cmd, shell=True).stdout.strip() + Disk = run_command(cmd, shell=True, use_shlex=False).stdout.strip() except Exception: Disk = "unknown" @@ -200,7 +200,7 @@ def get_stats(): # determine uptime cmd = "uptime -p | sed -r 's/up|,//g' | sed -r 's/\s*week[s]?/w/g' | sed -r 's/\s*day[s]?/d/g' | sed -r 's/\s*hour[s]?/h/g' | sed -r 's/\s*minute[s]?/m/g'" try: - uptime = run_command(cmd, shell=True).stdout.strip() + uptime = run_command(cmd, shell=True, use_shlex=False).stdout.strip() except Exception: uptime = "unknown" diff --git a/wlanpi_core/utils/general.py b/wlanpi_core/utils/general.py index 9f01ff1..e40fc78 100644 --- a/wlanpi_core/utils/general.py +++ b/wlanpi_core/utils/general.py @@ -18,6 +18,7 @@ def run_command( stdin: Optional[TextIO] = None, shell=False, raise_on_fail=True, + use_shlex=True, ) -> CommandResult: """Run a single CLI command with subprocess and returns the output""" """ @@ -35,6 +36,8 @@ def run_command( If True, then the entire command string will be executed in a shell. Otherwise, the command and its arguments are executed separately. raise_on_fail: Whether to raise an error if the command fails or not. Default is True. + shlex: If shlex should be used to protect input. Set to false if you need support + for some shell features like wildcards. Returns: A CommandResult object containing the output of the command, along with a boolean indicating @@ -50,13 +53,15 @@ def run_command( error_msg="You cannot use both 'input' and 'stdin' on the same call.", return_code=-1, ) - - # Todo: explore using shlex to always split to protect against injections + if not use_shlex: + logging.getLogger().warning( + f"shlex protection disabled for command--make sure this command is otherwise protected from injections:\n {cmd}" + ) if shell: # If a list was passed in shell mode, safely join using shlex to protect against injection. if isinstance(cmd, list): cmd: list - cmd: str = shlex.join(cmd) + cmd: str = shlex.join(cmd) if use_shlex else " ".join(cmd) cmd: str logging.getLogger().warning( f"Command {cmd} being run as a shell script. This could present " @@ -66,7 +71,7 @@ def run_command( # If a string was passed in non-shell mode, safely split it using shlex to protect against injection. if isinstance(cmd, str): cmd: str - cmd: list[str] = shlex.split(cmd) + cmd: list[str] = shlex.split(cmd) if use_shlex else cmd.split() cmd: list[str] with subprocess.Popen( cmd, @@ -94,6 +99,7 @@ async def run_command_async( stdin: Optional[TextIO] = None, shell=False, raise_on_fail=True, + use_shlex=True, ) -> CommandResult: """Run a single CLI command with subprocess and returns the output""" """ @@ -111,6 +117,8 @@ async def run_command_async( If True, then the entire command string will be executed in a shell. Otherwise, the command and its arguments are executed separately. raise_on_fail: Whether to raise an error if the command fails or not. Default is True. + shlex: If shlex should be used to protect input. Set to false if you need support + for some shell features like wildcards. Returns: A CommandResult object containing the output of the command, along with a boolean indicating @@ -126,6 +134,26 @@ async def run_command_async( error_msg="You cannot use both 'input' and 'stdin' on the same call.", return_code=-1, ) + if not use_shlex: + logging.getLogger().warning( + f"shlex protection disabled for command--make sure this command is otherwise protected from injections:\n {cmd}" + ) + if shell: + # If a list was passed in shell mode, safely join using shlex to protect against injection. + if isinstance(cmd, list): + cmd: list + cmd: str = shlex.join(cmd) if use_shlex else " ".join(cmd) + cmd: str + logging.getLogger().warning( + f"Command {cmd} being run as a shell script. This could present " + f"an injection vulnerability. Consider whether you really need to do this." + ) + else: + # If a string was passed in non-shell mode, safely split it using shlex to protect against injection. + if isinstance(cmd, str): + cmd: str + cmd: list[str] = shlex.split(cmd) if use_shlex else cmd.split() + cmd: list[str] # Prepare input data for communicate if input: @@ -143,7 +171,7 @@ async def run_command_async( # If a list was passed in shell mode, safely join using shlex to protect against injection. if isinstance(cmd, list): cmd: list - cmd: str = shlex.join(cmd) + cmd: str = use_shlex.join(cmd) cmd: str logging.getLogger().warning( f"Command {cmd} being run as a shell script. This could present " @@ -162,7 +190,7 @@ async def run_command_async( # If a string was passed in non-shell mode, safely split it using shlex to protect against injection. if isinstance(cmd, str): cmd: str - cmd: list[str] = shlex.split(cmd) + cmd: list[str] = use_shlex.split(cmd) cmd: list[str] proc = await asyncio.subprocess.create_subprocess_exec( cmd[0], diff --git a/wlanpi_core/utils/network.py b/wlanpi_core/utils/network.py index 7b9a2df..ab13ccd 100644 --- a/wlanpi_core/utils/network.py +++ b/wlanpi_core/utils/network.py @@ -186,6 +186,19 @@ def get_phy_interface_name(phy_num: int) -> Optional[str]: return None +def list_wlan_interfaces() -> list[str]: + return run_command( # type: ignore + ["ls", "-1", "/sys/class/ieee80211/*/device/net/"], use_shlex=False, shell=True + ).grep_stdout_for_pattern(r"^$|/", negate=True, split=True) + + +def list_ethernet_interfaces() -> list[str]: + res = run_command( # type: ignore + ["ls", "-1", "/sys/class/net/*/device/net/"], use_shlex=False, shell=True + ).grep_stdout_for_pattern(r"^$|/", negate=True, split=True) + return [x for x in res if "eth" in x] + + def get_wlan_channels(interface: str) -> list[WlanChannelInfo]: phy = get_interface_phy_num(interface) if phy is None: @@ -316,4 +329,4 @@ def get_interface_mac(interface: str) -> str: if __name__ == "__main__": - print(json.dumps(get_interface_details())) + print(list_wlan_interfaces()) From a7dc3caafc9013c2bfd23ed132ac0c9f70c3dd6e Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Thu, 7 Nov 2024 16:14:50 -0500 Subject: [PATCH 36/41] Version bump --- debian/changelog | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/debian/changelog b/debian/changelog index 4a97405..64aa425 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,11 +1,12 @@ -wlanpi-core (1.0.5-3) UNRELEASED; urgency=medium +wlanpi-core (1.0.5-3.2) UNRELEASED; urgency=medium [ Michael Ketchel ] * Draft build of new wlan control features * Fixed an api definition typo * More tweaks, and a version bump to fire dev package deploy + * Increased resistance to injection attacks - -- Michael Ketchel Thu, 07 Nov 2024 04:47:52 +0000 + -- Michael Ketchel Thu, 07 Nov 2024 21:14:24 +0000 wlanpi-core (1.0.5-1) unstable; urgency=high From f220072b54b257a248f26416e0f0a259de4db8d1 Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Mon, 11 Nov 2024 16:16:08 -0500 Subject: [PATCH 37/41] Version bump and format --- debian/changelog | 7 +++++-- wlanpi_core/models/network/wlan/wlan_dbus.py | 1 - 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/debian/changelog b/debian/changelog index 64aa425..5c420e8 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,4 +1,4 @@ -wlanpi-core (1.0.5-3.2) UNRELEASED; urgency=medium +wlanpi-core (1.0.5-3.3) UNRELEASED; urgency=medium [ Michael Ketchel ] * Draft build of new wlan control features @@ -6,7 +6,10 @@ wlanpi-core (1.0.5-3.2) UNRELEASED; urgency=medium * More tweaks, and a version bump to fire dev package deploy * Increased resistance to injection attacks - -- Michael Ketchel Thu, 07 Nov 2024 21:14:24 +0000 + [ _ ] + * Version bump for build + + -- _ Mon, 11 Nov 2024 21:16:01 +0000 wlanpi-core (1.0.5-1) unstable; urgency=high diff --git a/wlanpi_core/models/network/wlan/wlan_dbus.py b/wlanpi_core/models/network/wlan/wlan_dbus.py index 27c99db..75c9028 100644 --- a/wlanpi_core/models/network/wlan/wlan_dbus.py +++ b/wlanpi_core/models/network/wlan/wlan_dbus.py @@ -11,7 +11,6 @@ ) from wlanpi_core.models.network.wlan.exceptions import WlanDBUSInterfaceException from wlanpi_core.models.network.wlan.wlan_dbus_interface import WlanDBUSInterface -from wlanpi_core.utils.general import run_command from wlanpi_core.utils.network import list_wlan_interfaces From d71c40dfa004f15f021054e03265efdc9411559b Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Wed, 13 Nov 2024 16:02:07 -0500 Subject: [PATCH 38/41] Version bump and format --- debian/changelog | 7 +- .../api/api_v1/endpoints/network_api.py | 28 ++++--- .../network/wlan/wlan_dbus_interface.py | 9 ++- wlanpi_core/utils/network.py | 74 ++++++++++++++++++- 4 files changed, 103 insertions(+), 15 deletions(-) diff --git a/debian/changelog b/debian/changelog index 5c420e8..0ed1013 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,15 +1,14 @@ -wlanpi-core (1.0.5-3.3) UNRELEASED; urgency=medium +wlanpi-core (1.0.5-4.1) UNRELEASED; urgency=medium [ Michael Ketchel ] * Draft build of new wlan control features * Fixed an api definition typo * More tweaks, and a version bump to fire dev package deploy * Increased resistance to injection attacks - - [ _ ] * Version bump for build + * Add automatic default gateway configuration for wlan api - -- _ Mon, 11 Nov 2024 21:16:01 +0000 + -- Michael Ketchel Wed, 13 Nov 2024 21:01:24 +0000 wlanpi-core (1.0.5-1) unstable; urgency=high diff --git a/wlanpi_core/api/api_v1/endpoints/network_api.py b/wlanpi_core/api/api_v1/endpoints/network_api.py index ae532bf..c7919f7 100644 --- a/wlanpi_core/api/api_v1/endpoints/network_api.py +++ b/wlanpi_core/api/api_v1/endpoints/network_api.py @@ -21,22 +21,32 @@ log = logging.getLogger("uvicorn") -def validate_wlan_interface(interface: Optional[str], required: bool = True) -> None: +def validate_wlan_interface( + interface: Optional[str], required: bool = True, raise_on_invalid: bool = True +) -> bool: if (required or interface is not None) and interface not in list_wlan_interfaces(): - raise ValidationError( - f"Invalid/unavailable interface specified: #{interface}", status_code=400 - ) + if raise_on_invalid: + raise ValidationError( + f"Invalid/unavailable interface specified: #{interface}", + status_code=400, + ) + return False + return True def validate_ethernet_interface( - interface: Optional[str], required: bool = True -) -> None: + interface: Optional[str], required: bool = True, raise_on_invalid: bool = True +) -> bool: if ( required or interface is not None ) and interface not in list_ethernet_interfaces(): - raise ValidationError( - f"Invalid/unavailable interface specified: #{interface}", status_code=400 - ) + if raise_on_invalid: + raise ValidationError( + f"Invalid/unavailable interface specified: #{interface}", + status_code=400, + ) + return False + return True ################################ diff --git a/wlanpi_core/models/network/wlan/wlan_dbus_interface.py b/wlanpi_core/models/network/wlan/wlan_dbus_interface.py index e9d7372..80b23cd 100644 --- a/wlanpi_core/models/network/wlan/wlan_dbus_interface.py +++ b/wlanpi_core/models/network/wlan/wlan_dbus_interface.py @@ -31,7 +31,12 @@ from wlanpi_core.schemas.network.network import SupplicantNetwork from wlanpi_core.utils.g_lib_loop import GLibLoop from wlanpi_core.utils.general import byte_array_to_string -from wlanpi_core.utils.network import get_ip_address, renew_dhcp +from wlanpi_core.utils.network import ( + add_default_route, + get_ip_address, + remove_default_routes, + renew_dhcp, +) class WlanDBUSInterface: @@ -302,7 +307,9 @@ def properties_changed_callback(properties): time.sleep(2) # Is sleeping here really the answer? if self.interface_name: + remove_default_routes(interface=self.interface_name) renew_dhcp(self.interface_name) + add_default_route(interface=self.interface_name) ipaddr = get_ip_address(self.interface_name) connection_events.append( network.NetworkEvent( diff --git a/wlanpi_core/utils/network.py b/wlanpi_core/utils/network.py index ab13ccd..00588c5 100644 --- a/wlanpi_core/utils/network.py +++ b/wlanpi_core/utils/network.py @@ -6,6 +6,7 @@ from typing import Any, Optional, Union from wlanpi_core.models.runcommand_error import RunCommandError +from wlanpi_core.models.validation_error import ValidationError from wlanpi_core.utils.general import run_command @@ -73,6 +74,77 @@ def get_ip_address(interface): return None +def remove_default_routes(interface: str): + """ + Removes the default route for an interface. Primarily used if you used add_default_route for the interface. + @param interface: The interface to remove routes for + @return: None + """ + + # Get existing routes for this adapter + routes: list[dict[str, Any]] = run_command( # type: ignore + ["ip", "--json", "route"] + ).output_from_json() + for route in routes: + if route["dev"] == interface and route["dst"] == "default": + run_command(["ip", "route", "del", "default", "dev", interface]) + return routes + + +def add_default_route( + interface: str, + router_address: Optional[str] = None, + metric: Optional[int] = None, +) -> str: + """ + Adds a default route to an interface + @param interface: The interface (e.g. 'wlan0') to add the route for + @param router_address: Optionally specify which IP this route is via. If left blank, it will be grabbed from the dhclient lease file. + @param metric: Optional metric for the route. If left as none, a lowest-priority metric starting at 200 will be calculated unless there are no other default routes. + @return: A string representing the new default route. + """ + + # Validate the interface to protect against arbitrarily reading fs data. + if interface not in [*list_wlan_interfaces(), *list_ethernet_interfaces()]: + raise ValidationError( + f"Invalid/unavailable interface specified: #{interface}", + status_code=400, + ) + + # Obtain the router address if not manually provided + if router_address is None: + with open(f"/var/lib/dhcp/dhclient.{interface}.leases", "r") as lease_file: + lease_data = lease_file.readlines() + router_address = ( + next((s for s in lease_data if "option routers" in s)) + .strip() + .strip(";") + .split(" ", 2)[-1] + ) + + # Calculate a new metric if needed + if metric is None: + routes: list[dict[str, Any]] = run_command( # type: ignore + ["ip", "--json", "route"] + ).output_from_json() + default_routes = [x["metric"] or 0 for x in routes if x["dst"] == "default"] + if len(default_routes): + metric = max(default_routes) + if metric < 200: + metric = 200 + else: + metric += 1 + + # Generate and set new default route + new_route = f"default via {router_address} dev {interface}" + if metric: + new_route += f" metric {metric}" + + command = ["ip", "route", "add", *new_route.split(" ")] + run_command(command) + return new_route + + def renew_dhcp(interface) -> None: """ Uses dhclient to release and request a new DHCP lease @@ -329,4 +401,4 @@ def get_interface_mac(interface: str) -> str: if __name__ == "__main__": - print(list_wlan_interfaces()) + print(add_default_route("wlan0")) From 406d885f1357eb6678bd73022af93fda6b74f429 Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Wed, 13 Nov 2024 16:22:52 -0500 Subject: [PATCH 39/41] Fix some auto gateway config quirks --- debian/changelog | 5 +++-- wlanpi_core/models/network/wlan/wlan_dbus_interface.py | 8 ++++++++ wlanpi_core/utils/network.py | 7 +------ 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/debian/changelog b/debian/changelog index 0ed1013..c7c3c7c 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,4 +1,4 @@ -wlanpi-core (1.0.5-4.1) UNRELEASED; urgency=medium +wlanpi-core (1.0.5-5) UNRELEASED; urgency=medium [ Michael Ketchel ] * Draft build of new wlan control features @@ -7,8 +7,9 @@ wlanpi-core (1.0.5-4.1) UNRELEASED; urgency=medium * Increased resistance to injection attacks * Version bump for build * Add automatic default gateway configuration for wlan api + * Fix some auto gateway config quirks - -- Michael Ketchel Wed, 13 Nov 2024 21:01:24 +0000 + -- Michael Ketchel Wed, 13 Nov 2024 21:22:34 +0000 wlanpi-core (1.0.5-1) unstable; urgency=high diff --git a/wlanpi_core/models/network/wlan/wlan_dbus_interface.py b/wlanpi_core/models/network/wlan/wlan_dbus_interface.py index 80b23cd..f8551f0 100644 --- a/wlanpi_core/models/network/wlan/wlan_dbus_interface.py +++ b/wlanpi_core/models/network/wlan/wlan_dbus_interface.py @@ -489,15 +489,23 @@ def disconnect(self) -> None: """Disconnects the given interface from any network it may be associated with""" self.logger.info("Disconnecting WLAN on %s", self.interface_name) self.supplicant_dbus_interface.Disconnect() + remove_default_routes(interface=self.interface_name) def remove_all_networks(self) -> None: """Removes all networks from the interface""" self.logger.info("Removing all Networks onon %s", self.interface_name) + + remove_default_routes(interface=self.interface_name) self.supplicant_dbus_interface.RemoveAllNetworks() def remove_network(self, network_id: int) -> None: """Removes a single network from the interface""" self.logger.info("Removing network %s on %s", network_id, self.interface_name) + if ( + str(network_id) + == self._get_from_wpa_supplicant_interface("CurrentNetwork").split("/")[-1] + ): + remove_default_routes(interface=self.interface_name) self.supplicant_dbus_interface.RemoveNetwork( f"{self.interface_dbus_path}/Networks/{network_id}" ) diff --git a/wlanpi_core/utils/network.py b/wlanpi_core/utils/network.py index 00588c5..6ae16a2 100644 --- a/wlanpi_core/utils/network.py +++ b/wlanpi_core/utils/network.py @@ -88,7 +88,6 @@ def remove_default_routes(interface: str): for route in routes: if route["dev"] == interface and route["dst"] == "default": run_command(["ip", "route", "del", "default", "dev", interface]) - return routes def add_default_route( @@ -127,7 +126,7 @@ def add_default_route( routes: list[dict[str, Any]] = run_command( # type: ignore ["ip", "--json", "route"] ).output_from_json() - default_routes = [x["metric"] or 0 for x in routes if x["dst"] == "default"] + default_routes = [x.get("metric", 0) for x in routes if x["dst"] == "default"] if len(default_routes): metric = max(default_routes) if metric < 200: @@ -398,7 +397,3 @@ def get_interface_details( def get_interface_mac(interface: str) -> str: return run_command(["jc", "ifconfig", interface]).output_from_json()[0]["mac_addr"] - - -if __name__ == "__main__": - print(add_default_route("wlan0")) From e1cbb914b4824586d4041bdb6fb491efe2c90235 Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Thu, 14 Nov 2024 16:25:13 -0500 Subject: [PATCH 40/41] Add iw link endpoint --- .../api/api_v1/endpoints/network_api.py | 26 ++++++++- wlanpi_core/utils/network.py | 58 +++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/wlanpi_core/api/api_v1/endpoints/network_api.py b/wlanpi_core/api/api_v1/endpoints/network_api.py index c7919f7..b7efc90 100644 --- a/wlanpi_core/api/api_v1/endpoints/network_api.py +++ b/wlanpi_core/api/api_v1/endpoints/network_api.py @@ -14,7 +14,11 @@ SupplicantNetwork, ) from wlanpi_core.services import network_ethernet_service, network_service -from wlanpi_core.utils.network import list_ethernet_interfaces, list_wlan_interfaces +from wlanpi_core.utils.network import ( + list_ethernet_interfaces, + list_wlan_interfaces, + get_iw_link, +) router = APIRouter() @@ -488,3 +492,23 @@ async def get_interface_details(interface: Optional[str] = None): except Exception as ex: log.error(ex) return Response(content="Internal Server Error", status_code=500) + + +@router.get( + "/wlan/{interface}/link", + response_model=None, + response_model_exclude_none=True, +) +async def get_interface_link_details(interface: str): + """ + Gets interface details via iw. + """ + + try: + validate_wlan_interface(interface, required=True) + return get_iw_link(interface) + 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/utils/network.py b/wlanpi_core/utils/network.py index 6ae16a2..9f88eab 100644 --- a/wlanpi_core/utils/network.py +++ b/wlanpi_core/utils/network.py @@ -397,3 +397,61 @@ def get_interface_details( def get_interface_mac(interface: str) -> str: return run_command(["jc", "ifconfig", interface]).output_from_json()[0]["mac_addr"] + + +def get_iw_link(interface: str) -> dict[str, Union[bool, str, int, float, None]]: + lines = run_command(["iw", "dev", interface, "link"]).stdout.split("\n") + state = lines[0].strip() + data: dict[str, Union[bool, str, int, float, None]] = { + "connected": state != "Not connected.", + "ssid": None, # SSID # Kronos-5 + "bssid": None, # from state line + "freq": None, # freq # 5220.0 + # "rx": None, # RX # 402298 bytes (2063 packets) + "rx_bytes": None, + "rx_packets": None, + # "tx": None, # TX # 19503 bytes (137 packets) + "tx_bytes": None, + "tx_packets": None, + # "signal": None, # signal # -70 dBm + "signal_dbm": None, + "rx_bitrate": None, # rx bitrate # 54.0 MBit/s VHT-MCS 1 40MHz VHT-NSS 2 + "tx_bitrate": None, # tx bitrate # 351.0 MBit/s VHT-MCS 4 80MHz VHT-NSS 2 + "bss_flags": None, # bss flags # short-slot-time + "dtim_period": None, # dtim period # 3 + "beacon_int": None, # beacon int # 100 + } + + if not data["connected"]: + return data + + # Populate the lines as much as possible + data["bssid"] = lines[0].strip().split(" ")[2] + for line in [x for x in lines[1:] if x != ""]: + key, val = line.split(":", 1) + data[key.strip().lower().replace(" ", "_")] = val.strip() + + # Rebuild him better, faster, stronger + data["freq"] = float(data["freq"]) + data["signal_dbm"] = int(data["signal"].split()[0]) + del data["signal"] + + rx_split = data["rx"].split() + data["rx_bytes"] = int(rx_split[0]) + data["rx_packets"] = int(rx_split[2][1:]) + del data["rx"] + + tx_split = data["tx"].split() + data["tx_bytes"] = int(tx_split[0]) + data["tx_packets"] = int(tx_split[2][1:]) + del data["tx"] + + data["dtim_period"] = int(data["dtim_period"]) + data["beacon_int"] = int(data["beacon_int"]) + + return data + + +if __name__ == "__main__": + print(get_iw_link("wlan0")) + print(get_iw_link("wlan1")) From bcb66307aa75bb0ed02e357e0aaf1dcd1b13001e Mon Sep 17 00:00:00 2001 From: Michael Ketchel Date: Thu, 14 Nov 2024 16:54:12 -0500 Subject: [PATCH 41/41] Relocate iw link endpoint code --- .../api/api_v1/endpoints/network_api.py | 8 +-- wlanpi_core/services/network_service.py | 62 ++++++++++++++++++- wlanpi_core/utils/network.py | 58 ----------------- 3 files changed, 62 insertions(+), 66 deletions(-) diff --git a/wlanpi_core/api/api_v1/endpoints/network_api.py b/wlanpi_core/api/api_v1/endpoints/network_api.py index b7efc90..9e58b60 100644 --- a/wlanpi_core/api/api_v1/endpoints/network_api.py +++ b/wlanpi_core/api/api_v1/endpoints/network_api.py @@ -14,11 +14,7 @@ SupplicantNetwork, ) from wlanpi_core.services import network_ethernet_service, network_service -from wlanpi_core.utils.network import ( - list_ethernet_interfaces, - list_wlan_interfaces, - get_iw_link, -) +from wlanpi_core.utils.network import list_ethernet_interfaces, list_wlan_interfaces router = APIRouter() @@ -506,7 +502,7 @@ async def get_interface_link_details(interface: str): try: validate_wlan_interface(interface, required=True) - return get_iw_link(interface) + return network_service.get_iw_link(interface) except ValidationError as ve: return Response(content=ve.error_msg, status_code=ve.status_code) except Exception as ex: diff --git a/wlanpi_core/services/network_service.py b/wlanpi_core/services/network_service.py index b28dc69..c9b6f55 100644 --- a/wlanpi_core/services/network_service.py +++ b/wlanpi_core/services/network_service.py @@ -1,6 +1,6 @@ import logging from enum import Enum -from typing import Optional +from typing import Optional, Union from wlanpi_core.models.network.wlan.exceptions import ( WlanDBUSException, @@ -11,7 +11,8 @@ from wlanpi_core.models.validation_error import ValidationError from wlanpi_core.schemas import network from wlanpi_core.schemas.network.network import SupplicantNetwork -from wlanpi_core.utils.network import get_interface_details +from wlanpi_core.utils.general import run_command +from wlanpi_core.utils.network import get_interface_details, list_wlan_interfaces """ These are the functions used to deliver the API @@ -232,3 +233,60 @@ async def interface_details( interface: Optional[str], ) -> Optional[dict[str, dict[str, any]]]: return get_interface_details(interface) + + +def get_iw_link(interface: str) -> dict[str, Union[bool, str, int, float, None]]: + if interface not in list_wlan_interfaces(): + raise ValidationError( + f"{interface} is not a wireless interface", status_code=400 + ) + lines = run_command(["iw", "dev", interface, "link"]).stdout.split("\n") + state = lines[0].strip() + data: dict[str, Union[bool, str, int, float, None]] = { + "connected": state != "Not connected.", + "ssid": None, # SSID # Kronos-5 + "bssid": None, # from state line + "freq": None, # freq # 5220.0 + # "rx": None, # RX # 402298 bytes (2063 packets) + "rx_bytes": None, + "rx_packets": None, + # "tx": None, # TX # 19503 bytes (137 packets) + "tx_bytes": None, + "tx_packets": None, + # "signal": None, # signal # -70 dBm + "signal_dbm": None, + "rx_bitrate": None, # rx bitrate # 54.0 MBit/s VHT-MCS 1 40MHz VHT-NSS 2 + "tx_bitrate": None, # tx bitrate # 351.0 MBit/s VHT-MCS 4 80MHz VHT-NSS 2 + "bss_flags": None, # bss flags # short-slot-time + "dtim_period": None, # dtim period # 3 + "beacon_int": None, # beacon int # 100 + } + + if not data["connected"]: + return data + + # Populate the lines as much as possible + data["bssid"] = lines[0].strip().split(" ")[2] + for line in [x for x in lines[1:] if x != ""]: + key, val = line.split(":", 1) + data[key.strip().lower().replace(" ", "_")] = val.strip() + + # Rebuild him better, faster, stronger + data["freq"] = float(data["freq"]) + data["signal_dbm"] = int(data["signal"].split()[0]) + del data["signal"] + + rx_split = data["rx"].split() + data["rx_bytes"] = int(rx_split[0]) + data["rx_packets"] = int(rx_split[2][1:]) + del data["rx"] + + tx_split = data["tx"].split() + data["tx_bytes"] = int(tx_split[0]) + data["tx_packets"] = int(tx_split[2][1:]) + del data["tx"] + + data["dtim_period"] = int(data["dtim_period"]) + data["beacon_int"] = int(data["beacon_int"]) + + return data diff --git a/wlanpi_core/utils/network.py b/wlanpi_core/utils/network.py index 9f88eab..6ae16a2 100644 --- a/wlanpi_core/utils/network.py +++ b/wlanpi_core/utils/network.py @@ -397,61 +397,3 @@ def get_interface_details( def get_interface_mac(interface: str) -> str: return run_command(["jc", "ifconfig", interface]).output_from_json()[0]["mac_addr"] - - -def get_iw_link(interface: str) -> dict[str, Union[bool, str, int, float, None]]: - lines = run_command(["iw", "dev", interface, "link"]).stdout.split("\n") - state = lines[0].strip() - data: dict[str, Union[bool, str, int, float, None]] = { - "connected": state != "Not connected.", - "ssid": None, # SSID # Kronos-5 - "bssid": None, # from state line - "freq": None, # freq # 5220.0 - # "rx": None, # RX # 402298 bytes (2063 packets) - "rx_bytes": None, - "rx_packets": None, - # "tx": None, # TX # 19503 bytes (137 packets) - "tx_bytes": None, - "tx_packets": None, - # "signal": None, # signal # -70 dBm - "signal_dbm": None, - "rx_bitrate": None, # rx bitrate # 54.0 MBit/s VHT-MCS 1 40MHz VHT-NSS 2 - "tx_bitrate": None, # tx bitrate # 351.0 MBit/s VHT-MCS 4 80MHz VHT-NSS 2 - "bss_flags": None, # bss flags # short-slot-time - "dtim_period": None, # dtim period # 3 - "beacon_int": None, # beacon int # 100 - } - - if not data["connected"]: - return data - - # Populate the lines as much as possible - data["bssid"] = lines[0].strip().split(" ")[2] - for line in [x for x in lines[1:] if x != ""]: - key, val = line.split(":", 1) - data[key.strip().lower().replace(" ", "_")] = val.strip() - - # Rebuild him better, faster, stronger - data["freq"] = float(data["freq"]) - data["signal_dbm"] = int(data["signal"].split()[0]) - del data["signal"] - - rx_split = data["rx"].split() - data["rx_bytes"] = int(rx_split[0]) - data["rx_packets"] = int(rx_split[2][1:]) - del data["rx"] - - tx_split = data["tx"].split() - data["tx_bytes"] = int(tx_split[0]) - data["tx_packets"] = int(tx_split[2][1:]) - del data["tx"] - - data["dtim_period"] = int(data["dtim_period"]) - data["beacon_int"] = int(data["beacon_int"]) - - return data - - -if __name__ == "__main__": - print(get_iw_link("wlan0")) - print(get_iw_link("wlan1"))