Files
StarPilot/openpilot/starpilot/galaxy_bluetooth/bridge/proxy.py
T

151 lines
7.1 KiB
Python

"""Authenticated, bounded bridge to SecretGood's local Galaxy server."""
import base64
from http.cookies import SimpleCookie
import json
import time
import urllib.error
import urllib.parse
import urllib.request
if __package__:
from .protocol import MAX_BODY # imported by the comma UI
else:
from protocol import MAX_BODY # run as the bridge script
METHODS = {'GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'}
REQUEST_HEADERS = {'content-type', 'accept'}
RESPONSE_HEADERS = {'content-type'}
# Pairing credentials, the phone uplink and APK downloads are only offered over a
# direct Wi-Fi/LAN session; Bluetooth is never an uplink or media transport.
WIFI_PATHS = ('/api/recordings', '/api/replay', '/api/cameras', '/api/maps/tiles', '/api/navigation/map/tiles',
'/api/starport/upload', '/api/starport/apk', '/api/phone-internet',
'/api/companion/uplink', '/api/companion/app.apk',
'/api/auth/login', '/api/auth/logout', '/api/spotify/upload')
def validate_target(target):
if not isinstance(target, str) or len(target) > 8192 or any(ord(c) < 32 or ord(c) == 127 for c in target):
raise ValueError('Invalid request path')
parsed = urllib.parse.urlsplit(target)
decoded = urllib.parse.unquote(parsed.path)
if parsed.scheme or parsed.netloc or parsed.fragment or not decoded.startswith('/'):
raise ValueError('Only relative Galaxy paths are allowed')
if '\\' in decoded or '//' in decoded or any(p in ('.', '..') for p in decoded.split('/')):
raise ValueError('Invalid path segments')
if any(ord(c) < 32 or ord(c) == 127 for c in decoded) or '%' in decoded:
raise ValueError('Invalid encoded path')
if not (decoded.startswith('/api/') or decoded == '/data/runtime.json'):
raise ValueError('Path is outside the Galaxy API')
return target
def error_response(request_id, status, message):
return {'id': request_id, 'status': status, 'headers': {'content-type': 'application/json'},
'body': base64.b64encode(json.dumps({'error': message}).encode()).decode()}
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
class GalaxyProxy:
def __init__(self, port=8082):
if type(port) is not int or not 1 <= port <= 65535:
raise ValueError('Invalid Galaxy port')
self.port = port
self.base = f'http://127.0.0.1:{port}'
self.opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
self.cookie = ''
def new_session(self):
return GalaxyProxy(self.port)
def send(self, target, method, body, headers):
request = urllib.request.Request(self.base + target, data=body if body else None, method=method, headers=headers)
try:
response = self.opener.open(request, timeout=8)
except urllib.error.HTTPError as error:
response = error
with response:
content_type = response.headers.get('content-type', '')
if content_type.startswith(('video/', 'multipart/', 'text/event-stream')):
return 501, {'content-type': 'application/json'}, json.dumps({'error': 'This feature requires local Wi-Fi.'}).encode(), ''
chunks, total = [], 0
deadline = time.monotonic() + 10
while total <= MAX_BODY:
if time.monotonic() > deadline:
raise TimeoutError('Galaxy response deadline exceeded')
chunk = response.read1(min(65536, MAX_BODY + 1 - total))
if not chunk:
break
chunks.append(chunk)
total += len(chunk)
if total > MAX_BODY:
raise ValueError('Galaxy response exceeds the 1 MiB Bluetooth limit')
if 300 <= (response.status or 0) < 400:
raise ValueError('Galaxy redirects are not supported')
headers = {k.lower(): v for k, v in response.headers.items() if k.lower() in RESPONSE_HEADERS}
return response.status, headers, b''.join(chunks), response.headers.get('Set-Cookie', '')
def authenticate(self):
# This cookie belongs to one encrypted BLE handshake. Never accept a
# browser-supplied cookie or expose it outside this loopback connection.
status, _, raw, cookie = self.send('/api/auth/session', 'GET', b'', {'Cookie': self.cookie} if self.cookie else {})
auth = json.loads(raw)
parsed = SimpleCookie()
parsed.load(cookie)
token = parsed.get('galaxy_session')
if status != 200 or auth.get('authenticated') is not True or auth.get('localAccess') is not True or token is None or not token.value:
self.cookie = ''
raise ValueError('Local Galaxy session is unavailable')
self.cookie = 'galaxy_session=' + token.value
def handle(self, request):
request_id = str(request.get('id', ''))
try:
if request.get('path') == '/_bridge/health' and request.get('method') == 'GET':
health = {'protocol': 1, 'transport': 'bluetooth', 'readStream': True, 'notificationStream': True, 'galaxy': 'secretgood'}
return {'id': request_id, 'status': 200, 'headers': {'content-type': 'application/json'},
'body': base64.b64encode(json.dumps(health).encode()).decode()}
target = validate_target(request.get('path'))
path = urllib.parse.unquote(urllib.parse.urlsplit(target).path)
if any(path == prefix or path.startswith(prefix + '/') for prefix in WIFI_PATHS):
return error_response(request_id, 501, 'This feature requires local Wi-Fi. Connect your phone and comma to the same network.')
method = request.get('method', 'GET')
if method not in METHODS:
raise ValueError('Unsupported HTTP method')
body = base64.b64decode(request.get('body', ''), validate=True)
if len(body) > MAX_BODY:
return error_response(request_id, 413, 'Upload exceeds the 1 MiB Bluetooth limit')
supplied = request.get('headers', {})
if not isinstance(supplied, dict):
raise ValueError('Invalid headers')
headers = {str(k): str(v) for k, v in supplied.items() if str(k).lower() in REQUEST_HEADERS}
if any('\r' in v or '\n' in v for v in headers.values()):
raise ValueError('Invalid header value')
if not self.cookie:
self.authenticate()
if method not in ('GET', 'HEAD'):
headers['Origin'] = self.base
headers['Content-Type'] = 'application/json'
for attempt in range(2):
headers['Cookie'] = self.cookie
status, response_headers, data, _ = self.send(target, method, body, headers)
# Only the explicit pre-execution authentication rejection can
# be retried. Never replay a mutation after a network failure.
if status == 401 and attempt == 0:
try:
expired = json.loads(data).get('error') == 'Refresh the local Galaxy session'
except (ValueError, AttributeError):
expired = False
if expired:
self.authenticate()
continue
return {'id': request_id, 'status': status, 'headers': response_headers,
'body': base64.b64encode(data).decode()}
except (ValueError, TypeError, KeyError) as error:
return error_response(request_id, 400, str(error))
except Exception:
return error_response(request_id, 502, 'Local Galaxy did not answer. Reconnect and check the outcome before retrying a change.')