steam-backlog-enforcer/steam_backlog_enforcer/_ctl_server.py
2026-10-10 16:07:42 +02:00

205 lines
6.9 KiB
Python

"""The control socket server: who may talk to the daemon, and how.
A Unix socket at ``/run/steam-backlog-enforcer/ctl.sock``, mode 0660, group =
the desktop user's primary group. Filesystem permissions are the first gate;
``SO_PEERCRED`` is the second and the one that counts: the kernel reports the
connecting process's real uid, and anything but the desktop user is closed
without a byte of reply.
Root is admitted too. Root can already do everything the ops do (``sudo
./run.sh gaming-reset``, ``guardctl``), so refusing it would protect nothing;
admitting it lets recovery and tests use the same interface.
The server must never be the thing that takes the enforcer down or wedges it:
every connection has a read deadline and a size cap, concurrency is bounded,
each connection runs on its own thread, and no exception escapes a handler.
"""
from __future__ import annotations
import logging
import os
import socket
import threading
import time
from typing import TYPE_CHECKING, Final
from steam_backlog_enforcer._ctl_ops import OPS
from steam_backlog_enforcer._ctl_protocol import (
INVALID_PARAMS,
MAX_REQUEST_BYTES,
OP_FAILED,
SOCKET_PATH,
UNKNOWN_OP,
CtlError,
error_response,
ok_response,
)
from steam_backlog_enforcer._ctl_wire import (
parse_request,
peer_uid,
read_line,
socket_is_live,
)
if TYPE_CHECKING:
from pathlib import Path
from steam_backlog_enforcer._ctl_context import CtlContext
logger = logging.getLogger(__name__)
SOCKET_MODE: Final = 0o660
WRITE_TIMEOUT_SECONDS: Final = 5.0
MAX_CONNECTIONS: Final = 8
_BACKLOG: Final = 8
_ACCEPT_POLL_SECONDS: Final = 1.0
_ROOT_UID: Final = 0
class CtlServer:
"""Accepts, authenticates and dispatches control-socket connections."""
def __init__(
self,
ctx: CtlContext,
*,
desktop_uid: int,
desktop_gid: int,
path: Path = SOCKET_PATH,
) -> None:
"""Prepare a server; nothing is bound until :meth:`start`.
Args:
ctx: Shared op context.
desktop_uid: The one non-root uid allowed to connect.
desktop_gid: Group that owns the socket file.
path: Socket path.
"""
self._ctx = ctx
self._allowed = frozenset({desktop_uid, _ROOT_UID})
self._gid = desktop_gid
self._path = path
self._stop = threading.Event()
self._slots = threading.BoundedSemaphore(MAX_CONNECTIONS)
self._sock: socket.socket | None = None
def start(self) -> bool:
"""Bind the socket and start accepting. Never raises.
Returns:
Whether the server is now listening.
"""
try:
self._path.parent.mkdir(parents=True, exist_ok=True)
if socket_is_live(self._path):
logger.error(
"Control socket %s is already served; not taking over", self._path
)
return False
self._path.unlink(missing_ok=True)
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.bind(str(self._path))
# Between bind() and here the file is root-owned 0755: nobody else
# has write permission, so nobody else can connect yet.
os.chown(self._path, 0, self._gid)
self._path.chmod(SOCKET_MODE)
sock.listen(_BACKLOG)
sock.settimeout(_ACCEPT_POLL_SECONDS)
except OSError:
logger.exception("Cannot start the control socket at %s", self._path)
return False
self._sock = sock
threading.Thread(target=self._serve, name="ctl-accept", daemon=True).start()
logger.info("Control socket listening on %s", self._path)
return True
def stop(self) -> None:
"""Stop accepting and remove the socket file."""
self._stop.set()
if self._sock is not None:
self._sock.close()
self._sock = None
self._path.unlink(missing_ok=True)
def _serve(self) -> None:
"""Accept loop; survives anything but being told to stop."""
while not self._stop.is_set():
sock = self._sock
if sock is None:
return
try:
conn, _ = sock.accept()
except TimeoutError:
continue
except OSError:
if self._stop.is_set():
return
logger.exception("Control socket accept failed")
time.sleep(_ACCEPT_POLL_SECONDS)
continue
self._admit(conn)
def _admit(self, conn: socket.socket) -> None:
"""Authenticate *conn* and hand it to a worker, or close it silently."""
try:
uid = peer_uid(conn)
except OSError:
conn.close()
return
if uid not in self._allowed:
logger.warning("Control socket: refused a connection from uid %d", uid)
conn.close()
return
if not self._slots.acquire(blocking=False):
logger.warning("Control socket: too many connections, dropping one")
conn.close()
return
threading.Thread(
target=self._handle, args=(conn, uid), name="ctl-conn", daemon=True
).start()
def _handle(self, conn: socket.socket, uid: int) -> None:
"""Serve one connection, always releasing its slot and socket."""
try:
line = read_line(conn)
if line is not None:
self._write(conn, self._dispatch(line, uid))
except Exception:
logger.debug("Control connection dropped", exc_info=True)
finally:
conn.close()
self._slots.release()
@staticmethod
def _write(conn: socket.socket, payload: bytes) -> None:
"""Send the response within the write deadline."""
conn.settimeout(WRITE_TIMEOUT_SECONDS)
conn.sendall(payload)
def _dispatch(self, line: bytes, uid: int) -> bytes:
"""Turn a request line into a response line. Never raises."""
try:
return ok_response(self._run(line, uid))
except CtlError as exc:
logger.info("Control socket: refused (%s)", exc.code)
return error_response(exc)
except Exception:
logger.exception("Control socket op crashed")
return error_response(CtlError(OP_FAILED, "internal error"))
def _run(self, line: bytes, uid: int) -> dict[str, object]:
"""Validate a request line and run its op.
Raises:
CtlError: For a malformed request, an unknown op, or a refused op.
"""
if len(line) > MAX_REQUEST_BYTES:
raise CtlError(INVALID_PARAMS, "request too large")
op, args = parse_request(line)
handler = OPS.get(op)
if handler is None:
raise CtlError(UNKNOWN_OP, f"unknown op {op!r}")
logger.info("Control socket: op=%s uid=%d", op, uid)
return handler(self._ctx, args)