mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-06 20:43:46 +08:00
186 lines
6.5 KiB
Python
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
|