mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 22:13:48 +01:00
Each of these was one file doing several jobs, so they are split along what they actually do rather than at arbitrary line offsets: - _total_block: the lock file and the status derived from it stay in _total_block; making Steam *not installed* moves to _total_block_purge, making it *unreachable* to _total_block_hosts / _total_block_iptables. The generic pacman verbs (owner, installed, remove) become _pacman, since nothing about them is total-block-specific. Domain and process name tables become data modules. - _playtime: budget state and persistence (_playtime_state), pure transitions (_playtime_budget), cmdline process detection (_playtime_procs), the warn/cutoff/kill ladder (_playtime_cutoff), desktop notifications (_playtime_notify), process-tree walking and killing (_playtime_kill), mountinfo parsing (_playtime_mountinfo) and the shared privileged-command helper (_playtime_run). - _mcp: read-only tools (_mcp_query) split from mutating ones (_mcp_actions, _mcp_gaming_time), so "what can this server change?" is answerable from one file. _mcp keeps the server object and entry point. No re-export shims: callers import from the module that defines the name. That keeps a stale patch target loud (AttributeError) instead of letting it pass while intercepting nothing. `ruff check --select F821,F401` is run after every extraction -- an earlier mechanical attempt moved functions without the module-level names they referenced, which imported fine and only failed when called (349 errors), so it was reverted and redone by hand. The autouse fixtures in conftest are now imported by name and held by __all__; ruff --unsafe-fixes had deleted them as unused, since pytest resolves fixtures by name. 1324 tests pass at 100.00% coverage in the clean venv; `main status` is byte-identical to a worktree at HEAD. 49 -> 39 files over the cap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPpmMpTSkp8UziRc5u8B7P
245 lines
8.4 KiB
Python
245 lines
8.4 KiB
Python
"""Make Steam and game launchers unlaunchable once the daily budget is spent.
|
|
|
|
The block is a **read-only bind mount** of a refusal stub over every launcher
|
|
entry point. Nothing else here is negotiable, and future changes must not
|
|
substitute an alternative:
|
|
|
|
* ``chmod 000`` and rename-aside both mutate persistent on-disk state that
|
|
outlives this process. A crash mid-block leaves Steam unexecutable with no
|
|
automatic recovery.
|
|
* ``guardctl file-guard`` is an anti-*drift* keeper, not a swapper: it snapshots
|
|
the target as canonical and copies canonical back over the target. Pointing it
|
|
at a stub would permanently overwrite ``/usr/bin/steam``.
|
|
|
|
A bind mount mutates zero bytes of the real binary, ``umount`` is a perfect
|
|
restore, and a reboot clears every mount unconditionally. That last property is
|
|
the whole safety argument: the worst case is "reboot fixes it".
|
|
|
|
Four targets, because the first three are bypasses of one another —
|
|
``/usr/bin/steam`` is a two-line ``exec /usr/lib/steam/steam "$@"`` wrapper, and
|
|
Steam ships its own executable bootstrap inside the user's home directory.
|
|
|
|
``/proc/self/mountinfo`` is the authority on what is currently masked, never the
|
|
saved state. That is what makes the block self-healing: a stale mount is
|
|
released even if the state file was deleted, and a mount lost to a reboot is
|
|
re-applied on the next tick.
|
|
|
|
**The daemon must keep a shared mount namespace.** Adding ``PrivateMounts=``,
|
|
``PrivateTmp=`` or ``ProtectSystem=`` to the systemd unit would make these
|
|
mounts invisible outside the service, silently turning the block into a no-op
|
|
while the counter kept running. :func:`mounts_are_visible` checks for exactly
|
|
that at runtime.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
import shutil
|
|
|
|
from steam_backlog_enforcer._playtime_mountinfo import _mountpoints
|
|
from steam_backlog_enforcer._playtime_run import _run
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
BLOCK_TARGETS: tuple[Path, ...] = (
|
|
# Every steam.desktop action uses this path; it is only a wrapper.
|
|
Path("/usr/bin/steam"),
|
|
# The real bootstrap. Named directly rather than via the
|
|
# /usr/lib/steam/steam symlink because mount(2) resolves symlinks, so
|
|
# mounting the link would mount here anyway under a misleading name.
|
|
Path("/usr/lib/steam/bin_steam.sh"),
|
|
# Steam's self-updating bootstrap, owned by the user and independently
|
|
# executable.
|
|
Path.home() / ".local" / "share" / "Steam" / "steam.sh",
|
|
Path("/usr/bin/lutris"),
|
|
)
|
|
|
|
# The stub lives on root-owned tmpfs, NOT in CONFIG_DIR. A bind mount shares the
|
|
# inode: a stub in a user-writable directory could be rewritten to exec the real
|
|
# bootstrap, and the block would evaporate without a single mount changing.
|
|
_STUB_DIR = Path("/run/steam-backlog-enforcer")
|
|
STUB_PATH = _STUB_DIR / "gaming-blocked"
|
|
|
|
MOUNTINFO_PATH = Path("/proc/self/mountinfo")
|
|
_INIT_MOUNTINFO_PATH = Path("/proc/1/mountinfo")
|
|
|
|
# pacman holds this for the whole transaction. Re-mounting mid-transaction is
|
|
# what would actually cause the EBUSY the PreTransaction hook exists to avoid.
|
|
PACMAN_LOCK = Path("/var/lib/pacman/db.lck")
|
|
|
|
_PROC = Path("/proc")
|
|
|
|
# Bind mounts stack, so one umount need not be enough. guard-lib's
|
|
# collapse_bind_mount() uses the same bounded loop for the same reason.
|
|
_MAX_UMOUNT_PASSES = 20
|
|
|
|
_MOUNTINFO_MIN_FIELDS = 5
|
|
_MOUNTPOINT_FIELD = 4
|
|
|
|
|
|
_STUB_MODE = 0o755
|
|
|
|
_STUB_SCRIPT = """#!/bin/sh
|
|
# Installed by steam-backlog-enforcer. Bind-mounted over the real launcher
|
|
# while the daily gaming budget is exhausted; removed automatically at 06:00.
|
|
echo "Gaming blocked: daily budget used up. Unblocks at 06:00." >&2
|
|
exit 1
|
|
"""
|
|
|
|
|
|
def block_targets() -> tuple[Path, ...]:
|
|
"""Return the launcher paths the block masks.
|
|
|
|
An accessor rather than a direct import of ``BLOCK_TARGETS``: module-level
|
|
constants are bound by value at import time, so a second binding elsewhere
|
|
would need its own test patch to stay isolated. Routing every reader through
|
|
one function keeps that to a single patch point.
|
|
|
|
Returns:
|
|
The masked launcher paths.
|
|
"""
|
|
return BLOCK_TARGETS
|
|
|
|
|
|
def mounted_targets() -> set[Path]:
|
|
"""Return the block targets currently masked by a mount.
|
|
|
|
Read in-process from mountinfo rather than shelling out to ``findmnt``:
|
|
this runs on every enforce tick, and a fork per tick is exactly the polling
|
|
cost this codebase avoids elsewhere.
|
|
|
|
Anything mounted on a block target is treated as ours — nothing else on this
|
|
system mounts over those paths.
|
|
|
|
Returns:
|
|
The masked targets.
|
|
"""
|
|
return _mountpoints(MOUNTINFO_PATH) & set(block_targets())
|
|
|
|
|
|
def mounts_are_visible() -> bool:
|
|
"""Whether mounts made here are visible to the rest of the system.
|
|
|
|
A private mount namespace (``PrivateMounts=``, ``ProtectSystem=`` and
|
|
friends) would keep every mount inside the service, so the block would do
|
|
nothing while the counter kept running — a silent fail-open. Compared
|
|
against PID 1's namespace, which is the one that matters.
|
|
|
|
Returns:
|
|
True if no mount we made is missing from PID 1's view.
|
|
"""
|
|
ours = mounted_targets()
|
|
if not ours:
|
|
return True
|
|
return bool(ours & _mountpoints(_INIT_MOUNTINFO_PATH))
|
|
|
|
|
|
def _ensure_stub() -> bool:
|
|
"""Write the refusal stub, replacing any existing copy.
|
|
|
|
Returns:
|
|
True if the stub is present and executable.
|
|
"""
|
|
try:
|
|
_STUB_DIR.mkdir(parents=True, exist_ok=True)
|
|
STUB_PATH.write_text(_STUB_SCRIPT, encoding="utf-8")
|
|
STUB_PATH.chmod(_STUB_MODE)
|
|
except OSError:
|
|
logger.exception("Could not write the playtime refusal stub.")
|
|
return False
|
|
return True
|
|
|
|
|
|
def apply_block() -> list[Path]:
|
|
"""Mask every unmasked block target with the refusal stub.
|
|
|
|
Skipped entirely while pacman holds its database lock: re-mounting during a
|
|
transaction is what makes package extraction fail ``EBUSY``. The kill loop is
|
|
not gated this way, so enforcement continues regardless.
|
|
|
|
Returns:
|
|
The targets newly masked by this call.
|
|
"""
|
|
if PACMAN_LOCK.exists():
|
|
logger.info("pacman transaction in progress; deferring playtime mounts.")
|
|
return []
|
|
if not _ensure_stub():
|
|
return []
|
|
|
|
already = mounted_targets()
|
|
newly: list[Path] = []
|
|
for target in block_targets():
|
|
if target in already or not target.exists():
|
|
continue
|
|
if not _run([_mount_bin(), "--bind", str(STUB_PATH), str(target)]):
|
|
continue
|
|
if _run([_mount_bin(), "-o", "remount,ro,bind", str(target)]):
|
|
newly.append(target)
|
|
return newly
|
|
|
|
|
|
def release_block() -> list[Path]:
|
|
"""Unmask every masked block target.
|
|
|
|
Loops per target because bind mounts stack; bounded so a target that cannot
|
|
be unmounted cannot spin forever.
|
|
|
|
Returns:
|
|
The targets released by this call.
|
|
"""
|
|
released: list[Path] = []
|
|
for target in mounted_targets():
|
|
for _ in range(_MAX_UMOUNT_PASSES):
|
|
if target not in mounted_targets():
|
|
released.append(target)
|
|
break
|
|
if not _run([_umount_bin(), "-l", str(target)]):
|
|
logger.warning("Could not release playtime mount on %s.", target)
|
|
break
|
|
return released
|
|
|
|
|
|
def reconcile(*, should_block: bool) -> tuple[list[Path], list[Path]]:
|
|
"""Drive the on-disk mount state toward *should_block*.
|
|
|
|
Idempotent and driven by mountinfo, so it costs one file read per tick when
|
|
nothing needs changing. Because it reads the live mount table rather than
|
|
saved state, it releases a stale block even if the state file was deleted,
|
|
and re-applies a block that a reboot cleared.
|
|
|
|
Args:
|
|
should_block: Whether the launchers should currently be masked.
|
|
|
|
Returns:
|
|
A ``(newly_masked, newly_released)`` pair.
|
|
"""
|
|
if not should_block:
|
|
return [], release_block()
|
|
|
|
masked = apply_block()
|
|
if masked and not mounts_are_visible():
|
|
logger.error(
|
|
"Playtime mounts are not visible outside this service — the block "
|
|
"is not in force. Check the unit for PrivateMounts/ProtectSystem.",
|
|
)
|
|
return masked, []
|
|
|
|
|
|
def _mount_bin() -> str:
|
|
"""Return the ``mount`` executable path.
|
|
|
|
Returns:
|
|
Absolute path to ``mount``.
|
|
"""
|
|
return shutil.which("mount") or "/usr/bin/mount"
|
|
|
|
|
|
def _umount_bin() -> str:
|
|
"""Return the ``umount`` executable path.
|
|
|
|
Returns:
|
|
Absolute path to ``umount``.
|
|
"""
|
|
return shutil.which("umount") or "/usr/bin/umount"
|