mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-12 23:43:44 +08:00
a531afd02a
Expose pairing readiness and coordinate adapter hand-offs with Android Auto. Show app pairings separately, support individual removal, and improve pairing controls on both comma screens. Add Galaxy companion endpoints and web asset manifests, preserve the plain camera look, and fix headless speed-limit shapes.
224 lines
9.2 KiB
Python
224 lines
9.2 KiB
Python
"""Bounded Galaxy HTTP connections and shared-source shutdown."""
|
|
|
|
from contextlib import ExitStack
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from functools import lru_cache
|
|
import gzip
|
|
from ipaddress import IPv4Address, ip_address, ip_network
|
|
import json
|
|
import mimetypes
|
|
from pathlib import Path
|
|
import threading
|
|
import time
|
|
|
|
|
|
class _LocalHTTPServer(ThreadingHTTPServer):
|
|
"""Bound idle browser connections without blocking other local requests."""
|
|
|
|
MAX_CONNECTIONS = 8
|
|
# comma's own services call back over loopback while a browser request waits on them.
|
|
LOOPBACK_CONNECTIONS = 4
|
|
REQUEST_TIMEOUT = 4.0
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self._slots = threading.BoundedSemaphore(self.MAX_CONNECTIONS)
|
|
self._loopback_slots = threading.BoundedSemaphore(self.LOOPBACK_CONNECTIONS)
|
|
self._draining = False
|
|
super().__init__(*args, **kwargs)
|
|
|
|
def get_request(self):
|
|
request, address = super().get_request()
|
|
request.settimeout(self.REQUEST_TIMEOUT)
|
|
return request, address
|
|
|
|
def loopback(self, request) -> bool:
|
|
if getattr(self, 'remote_transport', False):
|
|
return False
|
|
try:
|
|
return request.getpeername()[0] == request.getsockname()[0] == '127.0.0.1'
|
|
except (OSError, IndexError, TypeError):
|
|
return False
|
|
|
|
def _admit(self, request) -> threading.BoundedSemaphore | None:
|
|
"""The pool this request got a slot from; loopback overflows into its reserve instead of waiting."""
|
|
if self._draining:
|
|
return None
|
|
if self.loopback(request):
|
|
if self._slots.acquire(blocking=False):
|
|
return self._slots
|
|
return self._loopback_slots if self._loopback_slots.acquire(timeout=self.REQUEST_TIMEOUT) else None
|
|
return self._slots if self._slots.acquire(timeout=self.REQUEST_TIMEOUT) else None
|
|
|
|
def process_request_thread(self, request, client_address):
|
|
# Wait on the request's own thread so a full browser pool never blocks accepting.
|
|
slots = self._admit(request)
|
|
if slots is None:
|
|
self.shutdown_request(request)
|
|
return
|
|
try:
|
|
if self._draining:
|
|
self.shutdown_request(request)
|
|
else:
|
|
super().process_request_thread(request, client_address)
|
|
finally:
|
|
slots.release()
|
|
|
|
def drain_requests(self, timeout: float) -> bool:
|
|
"""Wait for accepted requests before closing their shared data sources."""
|
|
self._draining = True
|
|
deadline = time.monotonic() + timeout
|
|
acquired: list[threading.BoundedSemaphore] = []
|
|
try:
|
|
for slots, count in ((self._slots, self.MAX_CONNECTIONS), (self._loopback_slots, self.LOOPBACK_CONNECTIONS)):
|
|
for _ in range(count):
|
|
if not slots.acquire(timeout=max(0.0, deadline - time.monotonic())):
|
|
return False
|
|
acquired.append(slots)
|
|
return True
|
|
finally:
|
|
for slots in acquired:
|
|
slots.release()
|
|
|
|
def service_actions(self):
|
|
super().service_actions()
|
|
maintain = getattr(self, 'maintain_android_auto', None)
|
|
if maintain is not None:
|
|
try:
|
|
maintain()
|
|
except Exception:
|
|
pass # Optional housekeeping must never stop serve_forever.
|
|
|
|
def server_close(self, *, close_sources: bool = True):
|
|
if not close_sources:
|
|
# Managed shutdown can abandon a stuck daemon request. Its readers must
|
|
# remain alive until that process exits; only retire the listening socket.
|
|
media = getattr(self, 'recording_media_source', None)
|
|
if media is not None:
|
|
media.stop_child()
|
|
super().server_close()
|
|
return
|
|
# Always close every owned source, even if another cleanup raises. The
|
|
# owned sources must retire before their supporting readers.
|
|
with ExitStack() as cleanup:
|
|
cleanup.callback(super().server_close)
|
|
media = getattr(self, 'recording_media_source', None)
|
|
if media is not None:
|
|
cleanup.callback(media.close)
|
|
for name in ('map_source', 'settings_source', 'vehicle_selection_source', 'toyota_tools_source', 'model_source', 'plots_source',
|
|
'bluetooth_authority', 'bluetooth_source', 'model_manager_source', 'model_authority', 'layout_authority', 'favorites_source',
|
|
'sound_authority', 'sound_source', 'software_operations_source', 'drive_stats_authority', 'drive_stats_source',
|
|
'pairing_authority', 'evidence_source', 'device_state_source', 'navigation_source', 'drive_physical_source', 'notification_source',
|
|
'android_auto_setup_source'):
|
|
close = getattr(getattr(self, name, None), 'close', None)
|
|
if callable(close):
|
|
cleanup.callback(close)
|
|
|
|
|
|
LOCAL_NETWORKS = tuple(ip_network(network) for network in ('127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '169.254.0.0/16'))
|
|
|
|
|
|
def direct_local_connection(peer: str, destination: str, headers) -> bool:
|
|
"""Passwordless access is limited to direct local sockets, never forwarded requests."""
|
|
proxy_headers = {'forwarded', 'via', 'x-real-ip', 'x-client-ip', 'x-original-forwarded-for', 'cf-connecting-ip', 'true-client-ip'}
|
|
if any(name.lower() in proxy_headers or name.lower().startswith('x-forwarded-') for name in headers):
|
|
return False
|
|
try:
|
|
return all(isinstance(address, IPv4Address) and any(address in network for network in LOCAL_NETWORKS)
|
|
for address in (ip_address(peer), ip_address(destination)))
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def install_origin_allowed(peer: str, destination: str, headers, remote_transport: bool = False) -> bool:
|
|
"""Large installs are for the tunnel or a real LAN/hotspot client, never the phone Bluetooth bridge (loopback)."""
|
|
if remote_transport:
|
|
return True
|
|
try:
|
|
return not ip_address(peer).is_loopback and direct_local_connection(peer, destination, headers)
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def allowed_authority(host: str, local_address: str, port: int) -> bool:
|
|
"""Accept only the literal address reached by this socket, or loopback's name."""
|
|
if host == f'localhost:{port}' and local_address == '127.0.0.1':
|
|
return True
|
|
if host != f'{local_address}:{port}':
|
|
return False
|
|
try:
|
|
address = ip_address(local_address)
|
|
except ValueError:
|
|
return False
|
|
return isinstance(address, IPv4Address) and not address.is_unspecified
|
|
|
|
|
|
@lru_cache(maxsize=128)
|
|
def static_asset(path: str, identity: tuple[int, int, int]):
|
|
body = Path(path).read_bytes()
|
|
content_type = mimetypes.guess_type(path)[0] or 'application/octet-stream'
|
|
compressible = content_type.startswith('text/') or content_type in ('application/javascript', 'application/json', 'image/svg+xml')
|
|
packed = gzip.compress(body, compresslevel=5, mtime=0) if compressible and len(body) > 1024 else body
|
|
if len(packed) >= len(body):
|
|
packed = body
|
|
return body, packed, content_type
|
|
|
|
|
|
class _LocalHTTPHandler(BaseHTTPRequestHandler):
|
|
protocol_version = 'HTTP/1.1'
|
|
|
|
def parse_request(self):
|
|
parsed = super().parse_request()
|
|
# Rejected writes can leave unread bodies; only bodyless reads are reusable.
|
|
if parsed and (self.command not in ('GET', 'HEAD') or 'Content-Length' in self.headers or 'Transfer-Encoding' in self.headers):
|
|
self.close_connection = True
|
|
return parsed
|
|
|
|
def end_headers(self):
|
|
if self.close_connection:
|
|
self.send_header('Connection', 'close')
|
|
super().end_headers()
|
|
|
|
def log_message(self, format, *args): # noqa: A002 - Match BaseHTTPRequestHandler's keyword signature.
|
|
pass
|
|
|
|
def handle(self):
|
|
try:
|
|
super().handle()
|
|
except (BrokenPipeError, ConnectionResetError):
|
|
# The browser can retire an in-flight fetch or speculative connection.
|
|
self.close_connection = True
|
|
|
|
def respond(self, status, body, content_type='application/json', cookie=None, *, asset_headers=None, cache_control=None):
|
|
self.send_response(status)
|
|
self.send_header('Content-Type', content_type)
|
|
if status != 304:
|
|
self.send_header('Content-Length', str(len(body)))
|
|
self.send_header('Cache-Control', cache_control or ('private, max-age=0, must-revalidate' if asset_headers else 'no-store'))
|
|
for key, value in (asset_headers or {}).items():
|
|
self.send_header(key, value)
|
|
self.send_header('X-Content-Type-Options', 'nosniff')
|
|
self.send_header('Referrer-Policy', 'no-referrer')
|
|
if cookie is not None:
|
|
self.send_header('Set-Cookie', cookie)
|
|
self.send_header('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; " +
|
|
"img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")
|
|
self.end_headers()
|
|
if self.command != 'HEAD':
|
|
self.wfile.write(body)
|
|
|
|
def json(self, status, value):
|
|
self.respond(status, json.dumps(value, allow_nan=False).encode())
|
|
|
|
def attachment(self, filename, body, content_type):
|
|
"""A download the browser saves as ``filename`` rather than displays."""
|
|
self.send_response(200)
|
|
self.send_header('Content-Type', content_type)
|
|
self.send_header('Content-Length', str(len(body)))
|
|
self.send_header('Content-Disposition', f'attachment; filename="{filename}"')
|
|
self.send_header('Cache-Control', 'no-store')
|
|
self.send_header('X-Content-Type-Options', 'nosniff')
|
|
self.send_header('Referrer-Policy', 'no-referrer')
|
|
self.end_headers()
|
|
if self.command != 'HEAD':
|
|
self.wfile.write(body)
|