-
-
Notifications
You must be signed in to change notification settings - Fork 266
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
WiFi dialog: add backend endpoints and logic (#1812)
Related #131, part (c). Stacked on #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](#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](#1813) – that one isn’t 100% code-complete yet, but it’s pretty close in terms of functionality. <a data-ca-tag href="https://codeapprove.com/pr/tiny-pilot/tinypilot/1812"><img src="https://codeapprove.com/external/github-tag-allbg.png" alt="Review on CodeApprove" /></a> --------- Co-authored-by: Jan Heuermann <[email protected]>
- Loading branch information
1 parent
01ac3f3
commit 2159320
Showing
7 changed files
with
442 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
Oops, something went wrong.