mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 20:53:48 +01:00
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
kuhy 2026-11-11: game-design research needs Librarian (app 4197610) actually played during the 6-day total block. New root command total-block-research --until T opens ONE window per lock: <= 48 h, never past the lock end, a rerun never extends it. The command only validates and records (/var/lib/steam-backlog- enforcer, root 0700; lock id + deadline + two latches). The daemon opens and closes everything: Steam hosts names, the store block, the total-block chain (IP cache kept) and the steam package, reinstalled with only guard-lib's package-block hook masked for that one pacman call (--hookdir, proved in a guest). Lock file, hook and end date are never touched. Browser-game sites stay null-routed. Only Librarian may run: loop-local allowed set, strict mode (no whitelist exceptions, no zero-achievement games), launchers and counted games killed; state.json is never read or written. Expiry latches the record, then the ordinary tick re-applies hosts (now reconciled each tick), chain, uninstall and purge; the normal store block is restored once, unconditionally. Failure modes: missing/corrupt/unparseable record = no window and a corrupt one also refuses a new window (fail closed); a clock set back cannot reopen a latched window. Checked: 3290 tests at 100% coverage; whole-tree pylint 10/10 run in three parts (the hook exceeds the 60 s cap, so SKIP=pylint here); vmbox guest with real guardctl package-block and the real unit, window opened, Librarian-only enforced, clock moved past the deadline and the full block re-applied. Not verified on the host. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWWzuCEg1m87jpKyRrw
135 lines
4.9 KiB
Python
135 lines
4.9 KiB
Python
"""The research window's firewall half: drop the chain, keep the IP memory."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import socket
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from steam_backlog_enforcer._total_block_iptables import (
|
|
IPTABLES_CHAIN,
|
|
_load_cached_ips,
|
|
_save_cached_ips,
|
|
apply_total_block_iptables,
|
|
release_total_block_iptables,
|
|
total_block_chain_exists,
|
|
)
|
|
|
|
PKG = "steam_backlog_enforcer._total_block_iptables"
|
|
STEAM_IP = "23.1.1.1"
|
|
CDN_IP = "23.2.2.2"
|
|
SITE_IP = "172.67.1.1"
|
|
|
|
|
|
class FakeChain:
|
|
"""A fake iptables holding one chain, its rules and its OUTPUT hook."""
|
|
|
|
def __init__(self, ips: list[str], *, exists: bool = True) -> None:
|
|
self.rules = list(ips)
|
|
self.exists = exists
|
|
self.hooked = exists
|
|
self.calls: list[list[str]] = []
|
|
self.stuck = False
|
|
|
|
def run(self, cmd: list[str], **_kw: object) -> MagicMock:
|
|
"""Answer one iptables call."""
|
|
self.calls.append(cmd)
|
|
args = cmd[2:] # [sudo, iptables, <verb>, ...]
|
|
verb = args[0]
|
|
ok = MagicMock(returncode=0, stdout="")
|
|
if verb == "-S":
|
|
if not self.exists:
|
|
return MagicMock(returncode=1, stdout="")
|
|
lines = [f"-A {IPTABLES_CHAIN} -d {ip}/32 -j DROP" for ip in self.rules]
|
|
return MagicMock(returncode=0, stdout="\n".join(lines) + "\n")
|
|
if verb == "-C":
|
|
return MagicMock(returncode=0 if self.hooked else 1)
|
|
if verb == "-I":
|
|
self.hooked = True
|
|
elif verb == "-N":
|
|
self.exists = True
|
|
elif verb == "-D" and args[1] == "OUTPUT":
|
|
self.hooked = False
|
|
elif verb == "-F":
|
|
self.rules.clear()
|
|
elif verb == "-X" and not self.stuck:
|
|
self.exists = False
|
|
elif verb == "-A":
|
|
self.rules.append(args[args.index("-d") + 1])
|
|
return ok
|
|
|
|
|
|
def _dns(*names: str, ip: str = STEAM_IP) -> MagicMock:
|
|
def getaddrinfo(host: str, *_a: object, **_k: object) -> list[tuple[object, ...]]:
|
|
if host not in names:
|
|
raise socket.gaierror
|
|
return [(None, None, None, None, (ip, 443))]
|
|
|
|
return MagicMock(side_effect=getaddrinfo)
|
|
|
|
|
|
class TestChainExists:
|
|
"""The cheap per-tick probe."""
|
|
|
|
def test_present(self) -> None:
|
|
"""Test that a listed chain exists."""
|
|
with patch(f"{PKG}.subprocess.run", side_effect=FakeChain([STEAM_IP]).run):
|
|
assert total_block_chain_exists() is True
|
|
|
|
def test_absent(self) -> None:
|
|
"""Test that a missing chain does not."""
|
|
fake = FakeChain([], exists=False)
|
|
with patch(f"{PKG}.subprocess.run", side_effect=fake.run):
|
|
assert total_block_chain_exists() is False
|
|
|
|
|
|
class TestRelease:
|
|
"""Opening the window removes the chain but remembers what it dropped."""
|
|
|
|
def test_chain_and_hook_are_gone_and_cache_survives(self) -> None:
|
|
"""Test that the IP cache is the memory the expiry rebuild needs."""
|
|
_save_cached_ips({STEAM_IP, CDN_IP})
|
|
fake = FakeChain([STEAM_IP, CDN_IP])
|
|
with patch(f"{PKG}.subprocess.run", side_effect=fake.run):
|
|
assert release_total_block_iptables() is True
|
|
assert fake.exists is False
|
|
assert fake.hooked is False
|
|
assert _load_cached_ips() == {STEAM_IP, CDN_IP}
|
|
|
|
def test_absent_chain_is_already_released(self) -> None:
|
|
"""Test that releasing twice is fine."""
|
|
fake = FakeChain([], exists=False)
|
|
with patch(f"{PKG}.subprocess.run", side_effect=fake.run):
|
|
assert release_total_block_iptables() is True
|
|
|
|
def test_chain_that_will_not_go_is_false(self) -> None:
|
|
"""Test that a failed delete is reported, not assumed."""
|
|
fake = FakeChain([STEAM_IP])
|
|
fake.stuck = True
|
|
with patch(f"{PKG}.subprocess.run", side_effect=fake.run):
|
|
assert release_total_block_iptables() is False
|
|
|
|
def test_iptables_that_cannot_run_is_false(self) -> None:
|
|
"""Test that a missing iptables fails closed to 'not released'."""
|
|
with patch(f"{PKG}.subprocess.run", side_effect=OSError):
|
|
assert release_total_block_iptables() is False
|
|
|
|
|
|
class TestRebuildAfterRelease:
|
|
"""At expiry the ordinary tick rebuilds the whole chain from the cache."""
|
|
|
|
def test_cached_steam_ips_are_dropped_again(self) -> None:
|
|
"""Test that apply restores cached IPs plus freshly resolved ones."""
|
|
_save_cached_ips({STEAM_IP, CDN_IP})
|
|
fake = FakeChain([STEAM_IP, CDN_IP])
|
|
with patch(f"{PKG}.subprocess.run", side_effect=fake.run):
|
|
release_total_block_iptables()
|
|
dns = _dns("store.steampowered.com", ip=SITE_IP)
|
|
with (
|
|
patch(f"{PKG}.subprocess.run", side_effect=fake.run),
|
|
patch(f"{PKG}.socket.getaddrinfo", dns),
|
|
):
|
|
assert apply_total_block_iptables() is True
|
|
assert sorted(fake.rules) == sorted([STEAM_IP, CDN_IP, SITE_IP])
|
|
assert fake.exists is True
|
|
assert fake.hooked is True
|