Files
StarPilot/openpilot/starpilot/galaxy/hotspot.py
T

119 lines
4.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Saved, opt-in local Wi-Fi access. No network effects in the HTTP process."""
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import re
import secrets
import tempfile
import threading
import time
INTERFACE = 'galaxy0'
ADDRESS = '172.31.254.1'
NETWORK = '172.31.254.0/24'
STATUS_PATH = Path('/tmp/starpilot-galaxy-hotspot-status.json')
DEFAULT = {'enabled': False, 'ssid': 'Galaxy', 'password': ''}
def device_dongle_id():
from openpilot.common.params import Params
from openpilot.starpilot.connect.provider import galaxy_device_id
return galaxy_device_id(Params())
def hotspot_name(dongle_id):
if type(dongle_id) is not str or not re.fullmatch(r'[a-fA-F0-9]{16}', dongle_id):
raise ValueError('A registered comma dongle ID is required for the hotspot.')
return 'TheGalaxy-' + dongle_id[-4:].lower()
def validate_config(value):
if (type(value) is not dict or set(value) != set(DEFAULT) or type(value['enabled']) is not bool or
type(value['ssid']) is not str or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9 _-]{0,31}', value['ssid']) or
type(value['password']) is not str or not re.fullmatch(r'[\x21-\x7e]{12,63}', value['password'])):
raise ValueError('Use a 1–32 character network name and a 12–63 character password without spaces.')
return dict(value)
def atomic_json(path, value, mode=0o600):
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
fd, temporary = tempfile.mkstemp(prefix='.' + path.name, dir=path.parent)
try:
os.fchmod(fd, mode)
with os.fdopen(fd, 'w') as handle:
json.dump(value, handle)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def bounded_json(path):
fd = os.open(path, os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0))
with os.fdopen(fd, 'r') as handle:
raw = handle.read(4097)
if len(raw) > 4096:
raise ValueError('Hotspot document is too large')
return json.loads(raw)
class HotspotSettings:
def __init__(self, root=None, status_path=STATUS_PATH, clock=time.monotonic, dongle_id=device_dongle_id):
if root is None:
from openpilot.starpilot.storage import galaxy_storage_root
root = galaxy_storage_root()
self.path = Path(root) / 'hotspot.json'
self.status_path, self.clock = status_path, clock
self.dongle_id = dongle_id
self.lock = threading.Lock()
def read(self):
# Resolve the physical comma identity, including when another cloud provider
# is active. Older configurable SSIDs are normalized without losing secrets.
ssid = hotspot_name(self.dongle_id())
try:
return {**validate_config(bounded_json(self.path)), 'ssid': ssid}
except FileNotFoundError:
return {**DEFAULT, 'ssid': ssid}
@staticmethod
def revision(config):
return hashlib.sha256(json.dumps(config, sort_keys=True).encode()).hexdigest()
def snapshot(self, parked):
config = self.read()
runtime = {'active': False, 'state': 'unavailable', 'reason': 'Hotspot service has not reported its status.'}
try:
saved = bounded_json(self.status_path)
if (type(saved) is dict and type(saved.get('updated')) in (int, float) and
0 <= self.clock() - saved['updated'] < 10 and
saved.get('revision') == self.revision(config)):
runtime = {key: saved[key] for key in ('active', 'state', 'reason', 'channel') if key in saved}
except (OSError, ValueError, TypeError):
pass
return {'version': 1, 'config': config, 'revision': self.revision(config), 'editable': bool(parked),
'url': f'http://{ADDRESS}:8082/', **runtime}
def save(self, payload, permitted):
if type(payload) is not dict or set(payload) != {'revision', 'config'}:
raise ValueError('Invalid hotspot settings')
with self.lock:
current = self.read()
if payload['revision'] != self.revision(current):
raise FileExistsError('Hotspot settings changed; refresh and try again.')
candidate = payload['config']
if type(candidate) is dict and candidate.get('password') == '':
candidate = {**candidate, 'password': current['password'] or secrets.token_hex(12)}
candidate = validate_config(candidate)
if candidate['ssid'] != current['ssid']:
raise ValueError('The hotspot network name is fixed by the comma dongle ID.')
if not permitted():
raise PermissionError('Park and sign in before changing the hotspot.')
atomic_json(self.path, candidate)
return self.snapshot(permitted())