mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 19:27:04 +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
Set [tool.pylint.similarities] min-similarity-lines = 8 (as diet-guard) and refactor the remaining R0801 hits: the store and total-block iptables modules now share create_fresh_chain / add_drop_rule / drop_chain in _store_tools. No duplicate-code disable or exclude. Run pylint serially (pyproject jobs = 1, hook --jobs=1): parallel mode pickles PyLinter with dill, which crashes on Python 3.15 (co_lnotab). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
151 lines
5.1 KiB
Python
151 lines
5.1 KiB
Python
"""iptables egress blocking for the total gaming block.
|
|
|
|
The belt to the hosts file's braces: /etc/hosts only stops name resolution,
|
|
so an app with a hard-coded IP (or its own resolver) would still connect.
|
|
This chain drops the traffic itself, which is why the resolved IPs are
|
|
cached -- once the hosts block is applied, the domains no longer resolve to
|
|
their real addresses and could not be re-derived.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import json
|
|
import logging
|
|
import socket
|
|
import subprocess
|
|
|
|
from steam_backlog_enforcer._store_tools import (
|
|
IPTABLES,
|
|
SUDO,
|
|
add_drop_rule,
|
|
create_fresh_chain,
|
|
drop_chain,
|
|
ensure_chain_in_output,
|
|
)
|
|
from steam_backlog_enforcer._total_block_domains import (
|
|
_ALL_TOTAL_BLOCK_DOMAINS,
|
|
NULL_ROUTE_IP,
|
|
)
|
|
from steam_backlog_enforcer.config import CONFIG_DIR, _atomic_write
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
IPTABLES_CHAIN = "STEAM_TOTAL_BLOCK"
|
|
|
|
_IPTABLES_IP_CACHE_FILE = CONFIG_DIR / "total_block_ip_cache.json"
|
|
|
|
|
|
def _load_cached_ips() -> set[str]:
|
|
"""Return the accumulated set of previously-resolved total-block IPs."""
|
|
if not _IPTABLES_IP_CACHE_FILE.exists():
|
|
return set()
|
|
try:
|
|
data = json.loads(_IPTABLES_IP_CACHE_FILE.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError, OSError, ValueError:
|
|
return set()
|
|
if not isinstance(data, list):
|
|
return set()
|
|
return {str(ip) for ip in data}
|
|
|
|
|
|
def _save_cached_ips(ips: set[str]) -> None:
|
|
"""Persist the accumulated total-block IP set to disk."""
|
|
_atomic_write(_IPTABLES_IP_CACHE_FILE, json.dumps(sorted(ips)) + "\n")
|
|
|
|
|
|
def _iptables_chain_intact(expected_ips: set[str]) -> bool:
|
|
"""Cheap check for whether the chain and its OUTPUT hook are intact.
|
|
|
|
One `-S` + one `-C` call (two forks), versus the ~30 forks a full
|
|
rebuild costs - this is what keeps :func:`_apply_total_block_iptables`
|
|
from re-resolving DNS and re-forking a subprocess per IP on every
|
|
3-second enforce tick.
|
|
"""
|
|
listing = subprocess.run(
|
|
[SUDO, IPTABLES, "-S", IPTABLES_CHAIN],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
check=False,
|
|
)
|
|
if listing.returncode != 0:
|
|
return False
|
|
|
|
current_ips: set[str] = set()
|
|
for line in listing.stdout.splitlines():
|
|
parts = line.split()
|
|
if "-d" in parts:
|
|
idx = parts.index("-d")
|
|
if idx + 1 < len(parts):
|
|
current_ips.add(parts[idx + 1].split("/")[0])
|
|
|
|
if not expected_ips.issubset(current_ips):
|
|
return False
|
|
|
|
hook = subprocess.run(
|
|
[SUDO, IPTABLES, "-C", "OUTPUT", "-j", IPTABLES_CHAIN],
|
|
capture_output=True,
|
|
timeout=5,
|
|
check=False,
|
|
)
|
|
return hook.returncode == 0
|
|
|
|
|
|
def apply_total_block_iptables() -> bool:
|
|
"""Ensure the total-block iptables chain blocks the known domain IPs.
|
|
|
|
Resolves domains and (re)builds the chain only when a cheap check
|
|
(:func:`_iptables_chain_intact`) shows it's actually needed - an
|
|
already-intact chain returns immediately. This matters for two
|
|
reasons: re-resolving via DNS every enforce tick (every 3s) would
|
|
otherwise fork ~30 subprocesses/tick indefinitely for a multi-day
|
|
block, and once /etc/hosts's entries take effect, these same domains
|
|
resolve to 0.0.0.0 locally, which would collapse a from-scratch
|
|
rebuild to that one trivial address and silently drop the real
|
|
upstream IPs blocked on the first, pre-hosts-block resolution -
|
|
resolving only when actually needed keeps the accumulated IP cache
|
|
from growing unboundedly too.
|
|
|
|
Callers MUST call this before :func:`_apply_total_block_hosts` the
|
|
first time (see :func:`start_total_block`): once the hosts entries
|
|
are in place, DNS resolution for every blocked domain returns 0.0.0.0
|
|
right here on this machine, and no real upstream IP is ever learned.
|
|
"""
|
|
cached = _load_cached_ips()
|
|
if cached and _iptables_chain_intact(cached):
|
|
return True
|
|
|
|
resolved_ips: set[str] = set()
|
|
try:
|
|
create_fresh_chain(IPTABLES_CHAIN)
|
|
|
|
for domain in _ALL_TOTAL_BLOCK_DOMAINS:
|
|
with contextlib.suppress(socket.gaierror):
|
|
for _, _, _, _, addr in socket.getaddrinfo(domain, 443, socket.AF_INET):
|
|
resolved_ips.add(str(addr[0]))
|
|
|
|
blocked_ips = (cached | resolved_ips) - {NULL_ROUTE_IP}
|
|
_save_cached_ips(blocked_ips)
|
|
|
|
for ip in blocked_ips:
|
|
add_drop_rule(IPTABLES_CHAIN, ip)
|
|
|
|
ensure_chain_in_output(IPTABLES_CHAIN)
|
|
except OSError, subprocess.SubprocessError:
|
|
logger.exception("Failed to apply total-block iptables rules")
|
|
return False
|
|
logger.info("Total block: %d domain IP(s) blocked via iptables.", len(blocked_ips))
|
|
return True
|
|
|
|
|
|
def remove_total_block_iptables() -> bool:
|
|
"""Remove the total-block iptables chain and its OUTPUT hook."""
|
|
try:
|
|
drop_chain(IPTABLES_CHAIN)
|
|
except OSError, subprocess.SubprocessError:
|
|
logger.exception("Failed to remove total-block iptables rules")
|
|
return False
|
|
_IPTABLES_IP_CACHE_FILE.unlink(missing_ok=True)
|
|
return True
|