From 21593203ce25aa30a8604b5926520f65522be4fd Mon Sep 17 00:00:00 2001
From: jotaen4tinypilot <83721279+jotaen4tinypilot@users.noreply.github.com>
Date: Fri, 5 Jul 2024 07:52:41 +0200
Subject: [PATCH] WiFi dialog: add backend endpoints and logic (#1812)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Related https://github.com/tiny-pilot/tinypilot/issues/131, part (c).
Stacked on https://github.com/tiny-pilot/tinypilot/pull/1811.
This PR adds the backend logic and endpoints that we need for the WiFi
dialog.
- There are endpoints for reading, writing, and deleting the WiFi
configuration. There is also one endpoint for checking the status of the
network connectivity, which allows us to display warnings or status
indicators in the frontend.
- As [mentioned in the code
comment](https://github.com/tiny-pilot/tinypilot/pull/1812/files#diff-43d8494a595e7d1f838bc01d5f2c6b18eebf61ca63bc3bbf2837dbcb27a8e109R62-R63),
we cannot read the `wpa_supplicant.conf` file directly due to file
ownership. Therefore, we use the [new `print-marker-sections` privileged
script](https://github.com/tiny-pilot/tinypilot/pull/1811) from the
previous PR.
- The scripts for enabling and disabling are executed in a “fire and
forget” manner, otherwise it may happen that the frontend requests hang
due to e.g. a change/interruption in the network (which would result in
an eternal loading spinner). As we only write a config file and trigger
service/daemon restarts, we wouldn’t get direct feedback about errors
anyway, but we’d have to poll for the resulting status, which can take
quite long, though.
- Note that we also can’t really verify the WiFi credentials, because
the desired WiFi might not be reachable at the time where the user wants
to enter the settings (e.g., because they want to enter the config ahead
of time).
- The validation of the incoming request data is relatively important,
because we will show the error messages in the frontend for input
validation. So we need to make sure that we detect all relevant
potential issues. The validation errors would be user-facing.
This PR can probably be tested best via the [subsequent frontend
PR](https://github.com/tiny-pilot/tinypilot/pull/1813) – that one isn’t
100% code-complete yet, but it’s pretty close in terms of functionality.
---------
Co-authored-by: Jan Heuermann
---
app/api.py | 88 ++++++++++
app/network.py | 128 ++++++++++++++
app/request_parsers/errors.py | 4 +
app/request_parsers/network.py | 48 ++++++
app/request_parsers/network_test.py | 163 ++++++++++++++++++
debian-pkg/etc/sudoers.d/tinypilot | 1 +
.../mock-scripts/print-marker-sections | 10 ++
7 files changed, 442 insertions(+)
create mode 100644 app/network.py
create mode 100644 app/request_parsers/network.py
create mode 100644 app/request_parsers/network_test.py
diff --git a/app/api.py b/app/api.py
index 503ec8279..70b5de9a8 100644
--- a/app/api.py
+++ b/app/api.py
@@ -6,8 +6,10 @@
import hostname
import json_response
import local_system
+import network
import request_parsers.errors
import request_parsers.hostname
+import request_parsers.network
import request_parsers.paste
import request_parsers.video_settings
import update.launcher
@@ -199,6 +201,92 @@ def hostname_set():
return json_response.error(e), 500
+@api_blueprint.route('/network/status', methods=['GET'])
+def network_status():
+ """Returns the current network status (i.e., which interfaces are active).
+
+ Returns:
+ On success, a JSON data structure with the following properties:
+ ethernet: bool.
+ wifi: bool
+
+ Example:
+ {
+ "ethernet": true,
+ "wifi": false
+ }
+ """
+ status = network.status()
+ return json_response.success({
+ 'ethernet': status.ethernet,
+ 'wifi': status.wifi,
+ })
+
+
+@api_blueprint.route('/network/settings/wifi', methods=['GET'])
+def network_wifi_get():
+ """Returns the current WiFi settings, if present.
+
+ Returns:
+ On success, a JSON data structure with the following properties:
+ countryCode: string.
+ ssid: string.
+
+ Example:
+ {
+ "countryCode": "US",
+ "ssid": "my-network"
+ }
+
+ Returns an error object on failure.
+ """
+ wifi_settings = network.determine_wifi_settings()
+ return json_response.success({
+ 'countryCode': wifi_settings.country_code,
+ 'ssid': wifi_settings.ssid,
+ })
+
+
+@api_blueprint.route('/network/settings/wifi', methods=['PUT'])
+def network_wifi_enable():
+ """Enables a wireless network connection.
+
+ Expects a JSON data structure in the request body that contains a country
+ code, an SSID, and optionally a password; all as strings. Example:
+ {
+ "countryCode": "US",
+ "ssid": "my-network",
+ "psk": "sup3r-s3cr3t!"
+ }
+
+ Returns:
+ Empty response on success, error object otherwise.
+ """
+ try:
+ wifi_settings = request_parsers.network.parse_wifi_settings(
+ flask.request)
+ network.enable_wifi(wifi_settings)
+ return json_response.success()
+ except request_parsers.errors.Error as e:
+ return json_response.error(e), 400
+ except network.Error as e:
+ return json_response.error(e), 500
+
+
+@api_blueprint.route('/network/settings/wifi', methods=['DELETE'])
+def network_wifi_disable():
+ """Disables the WiFi network connection.
+
+ Returns:
+ Empty response on success, error object otherwise.
+ """
+ try:
+ network.disable_wifi()
+ return json_response.success()
+ except network.Error as e:
+ return json_response.error(e), 500
+
+
@api_blueprint.route('/status', methods=['GET'])
def status_get():
"""Checks the status of TinyPilot.
diff --git a/app/network.py b/app/network.py
new file mode 100644
index 000000000..75f404e32
--- /dev/null
+++ b/app/network.py
@@ -0,0 +1,128 @@
+import dataclasses
+import re
+import subprocess
+
+_WIFI_COUNTRY_PATTERN = re.compile(r'^\s*country=(.+)$')
+_WIFI_SSID_PATTERN = re.compile(r'^\s*ssid="(.+)"$')
+
+
+class Error(Exception):
+ pass
+
+
+class NetworkError(Error):
+ pass
+
+
+@dataclasses.dataclass
+class NetworkStatus:
+ ethernet: bool
+ wifi: bool
+
+
+@dataclasses.dataclass
+class WifiSettings:
+ country_code: str
+ ssid: str
+ psk: str # Optional.
+
+
+def status():
+ """Checks the connectivity of the network interfaces.
+
+ Returns:
+ NetworkStatus
+ """
+ network_status = NetworkStatus(False, False)
+ try:
+ with open('/sys/class/net/eth0/operstate', encoding='utf-8') as file:
+ eth0 = file.read().strip()
+ network_status.ethernet = eth0 == 'up'
+ except OSError:
+ pass # We treat this as if the interface was down altogether.
+ try:
+ with open('/sys/class/net/wlan0/operstate', encoding='utf-8') as file:
+ wlan0 = file.read().strip()
+ network_status.wifi = wlan0 == 'up'
+ except OSError:
+ pass # We treat this as if the interface was down altogether.
+ return network_status
+
+
+def determine_wifi_settings():
+ """Determines the current WiFi settings (if set).
+
+ Returns:
+ WifiSettings: if the `ssid` and `country_code` attributes are `None`,
+ there is no WiFi configuration present. The `psk` property is
+ always `None` for security reasons.
+ """
+ try:
+ # We cannot read the wpa_supplicant.conf file directly, because it is
+ # owned by the root user.
+ config_lines = subprocess.check_output([
+ 'sudo', '/opt/tinypilot-privileged/scripts/print-marker-sections',
+ '/etc/wpa_supplicant/wpa_supplicant.conf'
+ ],
+ stderr=subprocess.STDOUT,
+ universal_newlines=True)
+ except subprocess.CalledProcessError as e:
+ raise NetworkError(str(e.output).strip()) from e
+
+ wifi = WifiSettings(None, None, None)
+ for line in config_lines.splitlines():
+ match_country = _WIFI_COUNTRY_PATTERN.search(line.strip())
+ if match_country:
+ wifi.country_code = match_country.group(1)
+ continue
+ match_ssid = _WIFI_SSID_PATTERN.search(line.strip())
+ if match_ssid:
+ wifi.ssid = match_ssid.group(1)
+ continue
+ return wifi
+
+
+def enable_wifi(wifi_settings):
+ """Enables a wireless network connection.
+
+ Note: The function is executed in a "fire and forget" manner, to prevent
+ the HTTP request from failing erratically due to a network interruption.
+
+ Args:
+ wifi_settings: The new, desired settings (of type WifiSettings)
+
+ Raises:
+ NetworkError
+ """
+ args = [
+ 'sudo', '/opt/tinypilot-privileged/scripts/enable-wifi', '--country',
+ wifi_settings.country_code, '--ssid', wifi_settings.ssid
+ ]
+ if wifi_settings.psk:
+ args.extend(['--psk', wifi_settings.psk])
+ try:
+ # Ignore pylint since we're not managing the child process.
+ # pylint: disable=consider-using-with
+ subprocess.Popen(args)
+ except subprocess.CalledProcessError as e:
+ raise NetworkError(str(e.output).strip()) from e
+
+
+def disable_wifi():
+ """Removes the WiFi settings and disables the wireless connection.
+
+ Note: The function is executed in a "fire and forget" manner, to prevent
+ the HTTP request from failing erratically due to a network interruption.
+
+ Raises:
+ NetworkError
+ """
+ try:
+ # Ignore pylint since we're not managing the child process.
+ # pylint: disable=consider-using-with
+ subprocess.Popen([
+ 'sudo',
+ '/opt/tinypilot-privileged/scripts/disable-wifi',
+ ])
+ except subprocess.CalledProcessError as e:
+ raise NetworkError(str(e.output).strip()) from e
diff --git a/app/request_parsers/errors.py b/app/request_parsers/errors.py
index 30c7080a7..05c7d3315 100644
--- a/app/request_parsers/errors.py
+++ b/app/request_parsers/errors.py
@@ -14,6 +14,10 @@ class InvalidHostnameError(Error):
code = 'INVALID_HOSTNAME'
+class InvalidWifiSettings(Error):
+ code = 'INVALID_WIFI_SETTINGS'
+
+
class InvalidVideoSettingError(Error):
pass
diff --git a/app/request_parsers/network.py b/app/request_parsers/network.py
new file mode 100644
index 000000000..bf22baa94
--- /dev/null
+++ b/app/request_parsers/network.py
@@ -0,0 +1,48 @@
+import network
+from request_parsers import errors
+from request_parsers import json
+
+
+def parse_wifi_settings(request):
+ """Parses WiFi settings from the request.
+
+ Returns:
+ WifiSettings
+
+ Raises:
+ InvalidWifiSettings
+ """
+ # pylint: disable=unbalanced-tuple-unpacking
+ (
+ country_code,
+ ssid,
+ psk,
+ ) = json.parse_json_body(request,
+ required_fields=['countryCode', 'ssid', 'psk'])
+
+ if not isinstance(country_code, str):
+ raise errors.InvalidWifiSettings('The country code must be a string.')
+ if len(country_code) != 2:
+ # The ISO 3166-1 alpha-2 standard theoretically allows any 2-digit
+ # combination, so we don’t have to eagerly restrict this.
+ raise errors.InvalidWifiSettings(
+ 'The country code must consist of 2 characters.')
+ if not country_code.isalpha():
+ raise errors.InvalidWifiSettings(
+ 'The country code must only contain letters.')
+
+ if not isinstance(ssid, str):
+ raise errors.InvalidWifiSettings('The SSID must be a string.')
+ if len(ssid) == 0:
+ raise errors.InvalidWifiSettings('The SSID cannot be empty.')
+
+ if psk is not None:
+ if not isinstance(psk, str):
+ raise errors.InvalidWifiSettings('The password must be a string.')
+ if len(psk) < 8 or len(psk) > 63:
+ # Note: this constraint is imposed by the WPA2 standard. We need
+ # to enforce this to prevent underlying commands from failing.
+ raise errors.InvalidWifiSettings(
+ 'The password must consist of 8-63 characters.')
+
+ return network.WifiSettings(country_code.upper(), ssid, psk)
diff --git a/app/request_parsers/network_test.py b/app/request_parsers/network_test.py
new file mode 100644
index 000000000..caec53d7e
--- /dev/null
+++ b/app/request_parsers/network_test.py
@@ -0,0 +1,163 @@
+import unittest
+from unittest import mock
+
+from request_parsers import errors
+from request_parsers import network
+
+
+def make_mock_request(json_data):
+ mock_request = mock.Mock()
+ mock_request.get_json.return_value = json_data
+ return mock_request
+
+
+class NetworkValidationTest(unittest.TestCase):
+
+ def test_accepts_valid_wifi_credentials_with_psk(self):
+ wifi = network.parse_wifi_settings(
+ make_mock_request({
+ 'countryCode': 'US',
+ 'ssid': 'my-network',
+ 'psk': 's3cr3t!!!'
+ }))
+ self.assertEqual('US', wifi.country_code)
+ self.assertEqual('my-network', wifi.ssid)
+ self.assertEqual('s3cr3t!!!', wifi.psk)
+
+ def test_accepts_valid_wifi_credentials_without_psk(self):
+ wifi = network.parse_wifi_settings(
+ make_mock_request({
+ 'countryCode': 'DE',
+ 'ssid': 'SomeWiFiHotspot_123',
+ 'psk': None
+ }))
+ self.assertEqual('DE', wifi.country_code)
+ self.assertEqual('SomeWiFiHotspot_123', wifi.ssid)
+ self.assertEqual(None, wifi.psk)
+
+ def test_normalizes_country_code_to_uppercase(self):
+ wifi = network.parse_wifi_settings(
+ make_mock_request({
+ 'countryCode': 'us',
+ 'ssid': 'SomeWiFiHotspot_123',
+ 'psk': None
+ }))
+ self.assertEqual('US', wifi.country_code)
+
+ def test_rejects_absent_required_fields(self):
+ with self.assertRaises(errors.MissingFieldError):
+ network.parse_wifi_settings(
+ make_mock_request({
+ 'ssid': 'my-network',
+ 'psk': 's3cr3t!!!'
+ }))
+ with self.assertRaises(errors.MissingFieldError):
+ network.parse_wifi_settings(
+ make_mock_request({
+ 'countryCode': 'US',
+ 'psk': 's3cr3t!!!'
+ }))
+ with self.assertRaises(errors.MissingFieldError):
+ network.parse_wifi_settings(
+ make_mock_request({
+ 'countryCode': 'US',
+ 'ssid': 'my-network'
+ }))
+
+ def test_rejects_country_code_with_incorrect_type(self):
+ with self.assertRaises(errors.InvalidWifiSettings):
+ network.parse_wifi_settings(
+ make_mock_request({
+ 'countryCode': 12,
+ 'ssid': 'my-network',
+ 'psk': 's3cr3t!!!'
+ }))
+ with self.assertRaises(errors.InvalidWifiSettings):
+ network.parse_wifi_settings(
+ make_mock_request({
+ 'countryCode': None,
+ 'ssid': 'my-network',
+ 'psk': 's3cr3t!!!'
+ }))
+
+ def test_rejects_country_code_with_incorrect_length(self):
+ with self.assertRaises(errors.InvalidWifiSettings):
+ network.parse_wifi_settings(
+ make_mock_request({
+ 'countryCode': 'A',
+ 'ssid': 'my-network',
+ 'psk': 's3cr3t!!!'
+ }))
+ with self.assertRaises(errors.InvalidWifiSettings):
+ network.parse_wifi_settings(
+ make_mock_request({
+ 'countryCode': 'ABC',
+ 'ssid': 'my-network',
+ 'psk': 's3cr3t!!!'
+ }))
+
+ def test_rejects_country_code_non_alpha(self):
+ with self.assertRaises(errors.InvalidWifiSettings):
+ network.parse_wifi_settings(
+ make_mock_request({
+ 'countryCode': '12',
+ 'ssid': 'my-network',
+ 'psk': 's3cr3t!!!'
+ }))
+ with self.assertRaises(errors.InvalidWifiSettings):
+ network.parse_wifi_settings(
+ make_mock_request({
+ 'countryCode': 'A*',
+ 'ssid': 'my-network',
+ 'psk': 's3cr3t!!!'
+ }))
+
+ def test_rejects_ssid_with_incorrect_type(self):
+ with self.assertRaises(errors.InvalidWifiSettings):
+ network.parse_wifi_settings(
+ make_mock_request({
+ 'countryCode': 'US',
+ 'ssid': 123,
+ 'psk': 's3cr3t!!!'
+ }))
+ with self.assertRaises(errors.InvalidWifiSettings):
+ network.parse_wifi_settings(
+ make_mock_request({
+ 'countryCode': 'US',
+ 'ssid': None,
+ 'psk': 's3cr3t!!!'
+ }))
+
+ def test_rejects_psk_with_incorrect_type(self):
+ with self.assertRaises(errors.InvalidWifiSettings):
+ network.parse_wifi_settings(
+ make_mock_request({
+ 'countryCode': 'US',
+ 'ssid': 'my-network',
+ 'psk': 123,
+ }))
+
+ def test_rejects_ssid_with_incorrect_length(self):
+ with self.assertRaises(errors.InvalidWifiSettings):
+ network.parse_wifi_settings(
+ make_mock_request({
+ 'countryCode': 'US',
+ 'ssid': '',
+ 'psk': 's3cr3t!!!'
+ }))
+
+ def test_rejects_psk_with_incorrect_length(self):
+ with self.assertRaises(errors.InvalidWifiSettings):
+ network.parse_wifi_settings(
+ make_mock_request({
+ 'countryCode': 'US',
+ 'ssid': 'Hotspot123',
+ 'psk': 'x' * 7
+ }))
+ with self.assertRaises(errors.InvalidWifiSettings):
+ network.parse_wifi_settings(
+ make_mock_request({
+ 'countryCode': 'US',
+ 'ssid': 'Hotspot123',
+ 'psk': 'x' * 64
+ }))
diff --git a/debian-pkg/etc/sudoers.d/tinypilot b/debian-pkg/etc/sudoers.d/tinypilot
index 88ecea9d3..8ef302ed8 100644
--- a/debian-pkg/etc/sudoers.d/tinypilot
+++ b/debian-pkg/etc/sudoers.d/tinypilot
@@ -3,6 +3,7 @@ tinypilot ALL=(ALL) NOPASSWD: /opt/tinypilot-privileged/scripts/collect-debug-lo
tinypilot ALL=(ALL) NOPASSWD: /opt/tinypilot-privileged/scripts/configure-janus
tinypilot ALL=(ALL) NOPASSWD: /opt/tinypilot-privileged/scripts/disable-wifi
tinypilot ALL=(ALL) NOPASSWD: /opt/tinypilot-privileged/scripts/enable-wifi
+tinypilot ALL=(ALL) NOPASSWD: /opt/tinypilot-privileged/scripts/print-marker-sections /etc/wpa_supplicant/wpa_supplicant.conf
tinypilot ALL=(ALL) NOPASSWD: /opt/tinypilot-privileged/scripts/read-update-log
tinypilot ALL=(ALL) NOPASSWD: /opt/tinypilot-privileged/scripts/update
tinypilot ALL=(ALL) NOPASSWD: /sbin/shutdown
diff --git a/dev-scripts/mock-scripts/print-marker-sections b/dev-scripts/mock-scripts/print-marker-sections
index 947e0f489..e3e073952 100755
--- a/dev-scripts/mock-scripts/print-marker-sections
+++ b/dev-scripts/mock-scripts/print-marker-sections
@@ -1,3 +1,13 @@
#!/bin/bash
# Mock version of /opt/tinypilot-privileged/scripts/print-marker-sections
+
+if [[ "$1" == '/etc/wpa_supplicant/wpa_supplicant.conf' ]]; then
+ cat << EOF
+country=US
+network={
+ ssid="TinyPilot Office"
+ psk=4efa961963891a4adb1f96ed645e96cd096cfb9bf5af086cd46c21c85b5bb98b
+}
+EOF
+fi