Files
StarPilot/openpilot/starpilot/software/update_control.py
T

186 lines
6.5 KiB
Python

"""Same-user, process-bound local control for the running updater."""
from __future__ import annotations
import os
import re
import socket
import struct
import threading
from collections.abc import Callable
from pathlib import Path
TIMEOUT = 0.5
MAX_COMMAND = 256
_BRANCH = re.compile(r"[A-Za-z0-9_][A-Za-z0-9._/+@-]{0,127}\Z")
_COMMIT = re.compile(r"[0-9a-f]{40}\Z")
_CREDENTIALS = struct.Struct('3i')
class UpdaterControlError(RuntimeError):
pass
def process_start(pid: int) -> int:
try:
raw = (Path('/proc') / str(pid) / 'stat').read_text()
fields = raw[raw.rindex(')') + 2:].split()
start = int(fields[19])
if start <= 0:
raise ValueError
return start
except (OSError, ValueError, IndexError):
raise UpdaterControlError('Updater process identity is unavailable') from None
def _address(pid: int, start: int, uid: int) -> str:
if type(pid) is not int or type(start) is not int or pid <= 1 or start <= 0:
raise UpdaterControlError('Invalid updater identity')
return f'\0starpilot-updater-v1-{uid}-{pid}-{start}'
def _peer(connection: socket.socket) -> tuple[int, int]:
if not hasattr(socket, 'SO_PEERCRED'):
raise UpdaterControlError('Peer credentials are unavailable')
pid, uid, _ = _CREDENTIALS.unpack(connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, _CREDENTIALS.size))
return pid, uid
def _line(connection: socket.socket) -> bytes:
data = bytearray()
while len(data) < MAX_COMMAND:
chunk = connection.recv(1)
if not chunk:
break
data.extend(chunk)
if chunk == b'\n':
return bytes(data)
raise UpdaterControlError('Invalid updater control message')
def _exchange(pid: int, start: int, command: bytes) -> None:
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
connection.settimeout(TIMEOUT)
connection.connect(_address(pid, start, os.geteuid()))
if _peer(connection) != (pid, os.geteuid()) or process_start(pid) != start:
raise UpdaterControlError('Updater process changed')
connection.sendall(command + b'\n')
if _line(connection) != b'ok\n':
raise UpdaterControlError('Updater declined control request')
except (OSError, TimeoutError):
raise UpdaterControlError('Updater control is unavailable') from None
def available(pid: int, start: int) -> bool:
try:
_exchange(pid, start, b'status')
return True
except UpdaterControlError:
return False
def _command(action: str, branch=None, commit=None, selected_commit=None) -> bytes:
if action in ('version', 'download-version') and type(selected_commit) is str and _COMMIT.fullmatch(selected_commit):
base = _command('fast', branch, commit).decode('ascii').split(' ', 1)[1]
return f'{action} {base} {selected_commit}'.encode('ascii')
if selected_commit is not None:
raise UpdaterControlError('Invalid updater version target')
if action in ('check', 'download') and branch is None and commit is None:
return action.encode('ascii')
if (action in ('update', 'fast', 'rollback', 'versions') and type(branch) is str and type(commit) is str and
_BRANCH.fullmatch(branch) and _COMMIT.fullmatch(commit)):
return f'{action} {branch} {commit}'.encode('ascii')
raise UpdaterControlError('Invalid updater control action')
def _parse(command: bytes):
if command in (b'status\n', b'check\n', b'download\n'):
return command[:-1].decode('ascii'), None, None
try:
parts = command[:-1].decode('ascii').split(' ')
if len(parts) == 4 and parts[0] in ('version', 'download-version') and command.endswith(b'\n'):
action, branch, commit, selected = parts
if _command(action, branch, commit, selected) + b'\n' == command:
return action, branch, commit, selected
action, branch, commit = parts
except (UnicodeError, ValueError):
raise UpdaterControlError('Invalid updater control message') from None
if not command.endswith(b'\n') or action not in ('update', 'fast', 'rollback', 'versions') or _command(action, branch, commit) + b'\n' != command:
raise UpdaterControlError('Invalid updater control message')
return action, branch, commit
def send(pid: int, start: int, action: str, *, branch=None, commit=None, selected_commit=None) -> None:
_exchange(pid, start, _command(action, branch, commit, selected_commit))
class UpdaterControlServer:
def __init__(self, request: Callable[..., None]):
self.request = request
self.socket: socket.socket | None = None
self.thread: threading.Thread | None = None
self.stopped = threading.Event()
def start(self) -> None:
if self.socket is not None:
raise UpdaterControlError('Updater control already started')
pid = os.getpid()
address = _address(pid, process_start(pid), os.geteuid())
listener = None
try:
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
listener.settimeout(TIMEOUT)
listener.bind(address)
listener.listen(4)
except OSError:
if listener is not None:
listener.close()
raise UpdaterControlError('Updater control cannot listen') from None
self.socket = listener
self.thread = threading.Thread(target=self._run, args=(listener,), name='updater-control', daemon=True)
self.thread.start()
def _run(self, listener: socket.socket) -> None:
while not self.stopped.is_set():
try:
connection, _ = listener.accept()
except TimeoutError:
continue
except OSError:
break
with connection:
connection.settimeout(TIMEOUT)
try:
_, uid = _peer(connection)
command = _line(connection)
if uid != os.geteuid():
connection.sendall(b'error\n')
else:
parsed = _parse(command)
action, branch, commit = parsed[:3]
if action in ('version', 'download-version'):
self.request(action, branch=branch, commit=commit, selected_commit=parsed[3])
elif action in ('update', 'fast', 'rollback', 'versions'):
self.request(action, branch=branch, commit=commit)
elif action != 'status':
self.request(action)
connection.sendall(b'ok\n')
except (RuntimeError, ValueError):
try:
connection.sendall(b'error\n')
except OSError:
pass
except OSError:
pass
def close(self) -> None:
self.stopped.set()
if self.socket is not None:
self.socket.close()
self.socket = None
if self.thread is not None:
self.thread.join(timeout=TIMEOUT * 2)
self.thread = None