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