Files
StarPilot/openpilot/starpilot/galaxy/remote.py
T
firestar5683 a82935f326 User interfaces and Android Auto
Integrate onroad views, configurable widgets, local settings, steering-profile contracts, Bluetooth and Android Auto with their device and runtime services.

Co-authored-by: Adeeb Shihadeh <adeebshihadeh@gmail.com>
Co-authored-by: Daniel Koepping <elkoled@gmail.com>
Co-authored-by: Danny <dannyirwin31@yahoo.com>
Co-authored-by: DimitriChrysafis <Dimitri.chrysafis@gmail.com>
Co-authored-by: Dom <168790843+firestar5683@users.noreply.github.com>
Co-authored-by: Harald Schäfer <harald.the.engineer@gmail.com>
Co-authored-by: Prabhaav Pillai <prabhaav@msu.edu>
Co-authored-by: Shane Smiskol <shane@smiskol.com>
Co-authored-by: dirwin31 <83434411+dirwin31@users.noreply.github.com>
Co-authored-by: firestarsdog <229254897+firestarsdog@users.noreply.github.com>
Co-authored-by: stef <19478336+stefpi@users.noreply.github.com>
Co-authored-by: whoisdomi <dcantu83@gmail.com>

Co-authored-by: Intelli <contact@intelli.software>
Co-authored-by: pharmacomaniac <blittle65@gmail.com>

Co-authored-by: prabhaavp <prabhaav@msu.edu>
2026-10-14 20:02:39 -05:00

311 lines
11 KiB
Python

"""Private pairing identity and FRPC configuration for Galaxy remote access."""
from __future__ import annotations
import base64
import binascii
from contextlib import contextmanager
import fcntl
import json
import hmac
import os
from pathlib import Path
import re
import secrets
import stat
import tempfile
from urllib.parse import unquote
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from collections.abc import Callable
import threading
from typing import TypedDict
SLUG = re.compile(r"[A-Za-z0-9]{16}\Z")
HASH = re.compile(r"[0-9a-f]{64}\Z")
class RemoteTokens(TypedDict):
slug: str
session: str
class RemoteRecord(RemoteTokens):
version: int
authHash: str
def gateway_cookie_valid(cookie: str | None, record: RemoteTokens) -> bool:
"""Validate this comma's token in either generation of the gateway cookie."""
if cookie is None or not 1 <= len(cookie) <= 4096:
return False
try:
decoded = unquote(cookie, errors="strict")
if not decoded.isascii():
return False
if decoded.startswith(record["slug"] + ":"):
token = decoded[len(record["slug"]) + 1:]
else:
if re.fullmatch(r"[A-Za-z0-9_-]+={0,2}", decoded) is None:
return False
sessions = json.loads(base64.b64decode(decoded + "=" * (-len(decoded) % 4), altchars=b"-_", validate=True))
if type(sessions) is not dict or len(sessions) > 40:
return False
token = sessions.get(record["slug"])
return type(token) is str and HASH.fullmatch(token) is not None and hmac.compare_digest(token, record["session"])
except (ValueError, UnicodeError, binascii.Error):
return False
class RemotePairing:
FILE = "remote-v1.json"
UNPAIRED = "remote-unpaired-v1"
def __init__(self, root: Path):
self.root = root
@contextmanager
def _locked(self):
self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
info = self.root.lstat()
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077:
raise ValueError("Pairing directory is unsafe")
fd = os.open(self.root / '.remote.lock', os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077:
raise ValueError("Pairing lock is unsafe")
fcntl.flock(fd, fcntl.LOCK_EX)
yield
finally:
os.close(fd)
def _sync_directory(self):
fd = os.open(self.root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
try:
os.fsync(fd)
finally:
os.close(fd)
def read(self) -> RemoteRecord | None:
try:
directory = self.root.lstat()
if not stat.S_ISDIR(directory.st_mode) or directory.st_mode & 0o077:
return None
path = self.root / self.FILE
info = path.lstat()
if not stat.S_ISREG(info.st_mode) or info.st_mode & 0o077 or info.st_size > 512:
return None
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
with os.fdopen(fd, encoding="utf-8") as stream:
record = json.load(stream)
if type(record) is not dict or set(record) != {"version", "slug", "authHash", "session"} or record["version"] != 1:
return None
if not (type(record["slug"]) is str and SLUG.fullmatch(record["slug"]) and
type(record["authHash"]) is str and HASH.fullmatch(record["authHash"]) and
type(record["session"]) is str and HASH.fullmatch(record["session"])):
return None
return record
except (OSError, ValueError, UnicodeError, json.JSONDecodeError):
return None
@staticmethod
def _legacy_record(root: Path, auth_hash: str | None = None) -> RemoteRecord | None:
values = {}
try:
directory = root.lstat()
if not stat.S_ISDIR(directory.st_mode) or directory.st_uid != os.geteuid() or directory.st_mode & 0o022:
return None
for name in ('glxyauth', 'glxysession', 'glxyslug'):
fd = os.open(root / name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
with os.fdopen(fd, 'r', encoding='ascii') as stream:
info = os.fstat(stream.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o022 or info.st_size > 128:
return None
values[name] = stream.read(129).strip()
if not HASH.fullmatch(values['glxyauth']) or (auth_hash is not None and not hmac.compare_digest(values['glxyauth'], auth_hash)) or \
not SLUG.fullmatch(values['glxyslug']) or not HASH.fullmatch(values['glxysession']):
return None
return {'version': 1, 'slug': values['glxyslug'], 'authHash': values['glxyauth'], 'session': values['glxysession']}
except (OSError, UnicodeError):
return None
def pair(self, auth_hash: str, *, legacy_root: Path | None = None) -> str | None:
if not HASH.fullmatch(auth_hash):
return None
try:
with self._locked():
if os.path.lexists(self.root / self.FILE):
return None
record: RemoteRecord | None = None
if legacy_root is not None and not os.path.lexists(self.root / self.UNPAIRED):
record = self._legacy_record(legacy_root, auth_hash)
if record is None:
record = {"version": 1, "slug": ''.join(secrets.choice("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") for _ in range(16)),
"authHash": auth_hash, "session": secrets.token_hex(32)}
return self._store(record)
except (OSError, ValueError):
return None
def migrate_legacy(self, legacy_root: Path | None) -> bool:
from openpilot.starpilot.galaxy.access import GalaxyAccessOwner
if legacy_root is None:
return False
try:
with self._locked():
if any(os.path.lexists(self.root / name) for name in (self.FILE, self.UNPAIRED, GalaxyAccessOwner.FILE)):
return False
record = self._legacy_record(legacy_root)
if record is None or record != self._legacy_record(legacy_root):
return False
return self._store(record) is not None
except (OSError, ValueError):
return False
def _store(self, record: RemoteRecord) -> str | None:
fd, temporary = tempfile.mkstemp(prefix=".remote-", dir=self.root)
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as stream:
json.dump(record, stream, separators=(",", ":"))
stream.flush()
os.fsync(stream.fileno())
os.link(temporary, self.root / self.FILE)
self._sync_directory()
return record["slug"]
except (OSError, ValueError):
return None
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def unpair(self) -> bool:
try:
with self._locked():
if not os.path.lexists(self.root / self.FILE):
return False
fd = os.open(self.root / self.UNPAIRED, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077:
return False
os.fsync(fd)
finally:
os.close(fd)
self._sync_directory()
(self.root / self.FILE).unlink()
self._sync_directory()
return True
except (OSError, ValueError):
return False
@staticmethod
def url(slug: str) -> str:
return f"https://galaxy.firestar.link/{slug}"
@staticmethod
def frpc_config(slug: str, remote_port: int, auth_port: int) -> str:
if not SLUG.fullmatch(slug):
raise ValueError("Invalid Galaxy slug")
return f'''serverAddr = "galaxy.firestar.link"
serverPort = 7000
[transport]
tls.enable = true
poolCount = 2
[[proxies]]
name = "{slug}_galaxy"
type = "http"
localIP = "127.0.0.1"
localPort = {remote_port}
customDomains = ["{slug}.devices.local"]
transport.useCompression = true
[[proxies]]
name = "{slug}_auth"
type = "http"
localIP = "127.0.0.1"
localPort = {auth_port}
customDomains = ["auth-{slug}.devices.local"]
'''
def default_remote_pairing() -> RemotePairing:
from openpilot.starpilot.galaxy.access import legacy_galaxy_root
from openpilot.starpilot.storage import galaxy_storage_root
pairing = RemotePairing(galaxy_storage_root())
pairing.migrate_legacy(legacy_galaxy_root())
return pairing
class _GatewayAuthServer(ThreadingHTTPServer):
daemon_threads = True
block_on_close = False
def __init__(self, *args, **kwargs):
self.slots = threading.BoundedSemaphore(8)
super().__init__(*args, **kwargs)
def get_request(self):
request, address = super().get_request()
request.settimeout(4)
return request, address
def process_request(self, request, client_address):
if not self.slots.acquire(blocking=False):
self.shutdown_request(request)
return
try:
super().process_request(request, client_address)
except BaseException:
self.slots.release()
raise
def process_request_thread(self, request, client_address):
try:
super().process_request_thread(request, client_address)
finally:
self.slots.release()
def make_gateway_auth_server(pairing: RemotePairing, dongle_id: str | Callable[[], str], *, port: int = 8083):
"""Original Galaxy gateway's two loopback auth endpoints, without local web authority."""
class Handler(BaseHTTPRequestHandler):
def log_message(self, format, *args): # noqa: A002 - Match BaseHTTPRequestHandler.
pass
def do_POST(self):
if self.path not in ('/glxylogin', '/glxyverify'):
self.send_error(404)
return
record = pairing.read()
try:
length = int(self.headers.get('Content-Length', ''))
except ValueError:
length = -1
if record is None or not 1 <= length <= 128 or self.headers.get('Transfer-Encoding') is not None:
self.send_error(403)
return
try:
body = self.rfile.read(length).decode('ascii').strip()
except (OSError, UnicodeError):
self.send_error(403)
return
expected = record['authHash'] if self.path == '/glxylogin' else record['session']
if not hmac.compare_digest(body, expected):
self.send_error(403)
return
device_id = dongle_id() if callable(dongle_id) else dongle_id
if self.path == '/glxylogin' and not device_id:
self.send_error(503)
return
data = json.dumps({'dongle_id': device_id, 'token': record['session']}).encode() if self.path == '/glxylogin' else b''
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(data)))
self.send_header('Cache-Control', 'no-store')
self.end_headers()
self.wfile.write(data)
return _GatewayAuthServer(('127.0.0.1', port), Handler)