Phase 4: kids' Windows computer client (AUD-97)

Adds windows/, the Python client for the kids' Windows computers, and proves
both halves of the Phase 4 definition of done on the real box
(DESKTOP-TE2RHV2).

The client:
- subscribes, retained, to its own config on the 8883 TLS device plane with its
  own broker account (FR-3, FR-6);
- keeps the last valid config on disk and loads it before connecting, so a
  broker outage changes nothing (FR-14);
- terminates every process on the blocklist while the device is effectively
  locked, with the allowlist winning and the client's own tree plus critical
  system processes protected (FR-4, FR-16);
- publishes presence and daily per-app usage; the day's totals are held on disk
  and republished when the link returns (FR-7);
- shows an always-on-top warning window naming what is blocked, why and until
  when (FR-9).

install-scheduled-task.ps1 registers the logon Scheduled Task for the kid's
account with an interactive token, and grants that account Modify on the state
directory: a file created from an elevated shell inherits Administrators only,
and the limited task is otherwise refused with Access denied.

Evidence (windows/EVIDENCE.md, from the machine's own log): notepad.exe killed
inside a scheduled window (pid 16600, then 9724), the device online in the
Control Service with locked/until/reason, a telemetry row for the device
(app=690s), enforcement continuing with the broker unreachable
(192.168.20.13:8884), the warning window on the interactive desktop, and the
Scheduled Task registered at logon as New Comp.

Domains are deliberately not reported: Windows has no cheap per-process DNS
attribution, so the client sends "domains": [] and the gap is documented rather
than guessed.

54 unit tests, run with `make windows-test` (no broker, no Windows, no psutil).

AGENTS.md §11 is now 10 of 10.
This commit is contained in:
2026-10-14 15:00:22 +11:00
parent eb300b6701
commit 1275b1baaa
22 changed files with 2799 additions and 13 deletions

View File

@@ -0,0 +1,12 @@
"""Kids' Windows computer client for Internet Use Control (AGENTS.md §11 Phase 4).
The client is a device, not a control plane. It subscribes to its own retained
config on the MQTT 8883 plane, blocks listed processes while the device is
locked, reports presence and daily usage, warns the child, and keeps enforcing
its last config when the broker is unreachable (FR-14). It never changes its own
rules (AGENTS.md §10 rule 7).
Run it as ``python -m webrestrict`` (see ``windows/README.md``).
"""
__version__ = "1.0.0-phase4"

View File

@@ -0,0 +1,96 @@
"""Command line entry point: ``python -m webrestrict`` (AGENTS.md §11 Phase 4).
Usage::
python -m webrestrict run # the client (the Scheduled Task runs this)
python -m webrestrict check # one-shot: cached config, effective state, victims
python -m webrestrict version
``--config PATH`` overrides the config file (default
``%PROGRAMDATA%\\WebRestrict\\client.json``, or ``WEBRESTRICT_CONFIG``).
"""
from __future__ import annotations
import argparse
import json
import logging
import logging.handlers
import sys
from . import __version__
from .agent import Agent, check_once, install_signal_handlers
from .settings import Settings, default_config_path
LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s %(message)s"
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="webrestrict", description="Kids' Windows computer client")
parser.add_argument("--config", help="path to client.json (default: %(default)s)")
parser.add_argument("--verbose", action="store_true", help="debug logging")
sub = parser.add_subparsers(dest="command")
sub.add_parser("run", help="run the client (default)")
sub.add_parser("check", help="print the cached config and what would be blocked, then exit")
sub.add_parser("version", help="print the client version")
return parser
def configure_logging(settings: Settings, verbose: bool) -> None:
"""Log to the console and to a rotating file next to the state.
A Scheduled Task runs with no visible console, so the file is the record
that survives: it holds the config that arrived and every process blocked.
"""
root = logging.getLogger()
root.setLevel(logging.DEBUG if verbose else logging.INFO)
root.handlers.clear()
console = logging.StreamHandler(sys.stdout)
console.setFormatter(logging.Formatter(LOG_FORMAT))
root.addHandler(console)
try:
settings.state_path.mkdir(parents=True, exist_ok=True)
rotating = logging.handlers.RotatingFileHandler(
settings.log_path(), maxBytes=2 * 1024 * 1024, backupCount=3, encoding="utf-8"
)
rotating.setFormatter(logging.Formatter(LOG_FORMAT))
root.addHandler(rotating)
except OSError as exc: # pragma: no cover - depends on the machine
root.warning("cannot write the log file %s: %s", settings.log_path(), exc)
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
command = args.command or "run"
if command == "version":
print(f"webrestrict-windows {__version__}")
return 0
settings_path = args.config or str(default_config_path())
try:
settings = Settings.load(settings_path)
except (OSError, ValueError, json.JSONDecodeError) as exc:
print(f"webrestrict: cannot load {settings_path}: {exc}", file=sys.stderr)
return 2
configure_logging(settings, args.verbose)
if command == "check":
print(json.dumps(check_once(settings), indent=2, sort_keys=True))
return 0
agent = Agent(settings)
install_signal_handlers(agent)
try:
return agent.run()
except KeyboardInterrupt: # pragma: no cover - Ctrl-C
agent.shutdown()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,317 @@
"""The client agent: one loop that enforces, warns, reports and never gives up.
Wiring, in one place:
* the cached config is loaded **before** the broker link is attempted, so the
device is enforcing the moment the process starts, broker or no broker
(FR-14);
* the MQTT link subscribes to the retained config and publishes presence;
* every few seconds the enforcer terminates blocked processes while the device
is effectively locked (FR-3, FR-4);
* the overlay shows the child what is blocked and until when (FR-9);
* every minute the usage sample is rolled up and published (FR-7), and the
status report keeps the dashboard's presence fresh (FR-6).
Nothing here changes the rules. The client obeys and reports (AGENTS.md §10
rule 7).
"""
from __future__ import annotations
import json
import logging
import signal
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from .config import ConfigCache, DeviceConfig
from .mqtt_link import MqttLink, iso_now
from .overlay import Notice, Overlay
from .process_watch import EnforcementReport, Enforcer, foreground_app, visible_apps
from .settings import Settings
from .telemetry import TelemetryStore, UsageSampler, build_payload, day_key
log = logging.getLogger("webrestrict")
def format_until(config: DeviceConfig) -> str:
"""A short local rendering of the config's end time, for the log and overlay."""
for value in (config.grant_until, config.until):
if not value:
continue
from .config import parse_time # noqa: PLC0415 - small helper, local use
moment = parse_time(value)
return moment.astimezone().strftime("%a %H:%M") if moment else value
return ""
@dataclass
class AgentState:
"""The moving parts, so tests can drive them without a broker or a desktop."""
settings: Settings
cache: ConfigCache
config: DeviceConfig
telemetry: TelemetryStore
sampler: UsageSampler
overlay: Overlay
enforcer: Enforcer
link: MqttLink | None = None
last_report: EnforcementReport | None = None
started_at: float = field(default_factory=time.monotonic)
class Agent:
"""The long-running kids' client."""
def __init__(self, settings: Settings, logger: logging.Logger | None = None):
self.settings = settings
self.log = logger or log
self.stop_event = threading.Event()
telemetry = TelemetryStore(settings.telemetry_path())
self.state = AgentState(
settings=settings,
cache=ConfigCache(settings.config_path()),
config=DeviceConfig(),
telemetry=telemetry,
sampler=UsageSampler(telemetry),
overlay=Overlay(enabled=settings.overlay),
enforcer=Enforcer(settings.protected, self.log),
)
# -- setup -------------------------------------------------------------
def load_config(self) -> DeviceConfig:
"""Load the cached config, seeding a permissive first-run config.
The seed is **unlocked with no lists**: a client that has never heard
from the broker must never invent a lock. The first retained config
replaces it.
"""
cached = self.state.cache.load()
if cached is None:
cached = DeviceConfig(platform="windows", locked=False)
self.state.cache.save(cached)
self.log.info("no cached config yet; seeded an unlocked config (FR-14)")
self.state.config = cached
self.log.info(
"startup config locked=%s until=%s block=%d allow=%d cached_at=%s",
cached.locked,
cached.until,
len(cached.block),
len(cached.allow),
cached.generated_at,
)
return cached
# -- callbacks ---------------------------------------------------------
def apply_config(self, payload: str) -> None:
"""Handle a config message: store it if valid, then apply it at once."""
accepted = self.state.cache.accept(payload)
if accepted is None:
self.log.warning("config ignored (older or unreadable); keeping the cached one")
return
self.state.config = accepted
self.log.info(
"config applied locked=%s until=%s reason=%s block=%s allow=%s generated_at=%s",
accepted.locked,
accepted.until,
accepted.reason or "-",
accepted.block,
accepted.allow,
accepted.generated_at,
)
# Act on it now instead of waiting for the next tick: a lock must land
# within the poll interval, and the Android client behaves the same way.
self.tick()
def on_connected(self) -> None:
config = self.state.config
self.log.info("link up: publishing presence and today's usage")
self.publish_status(config)
self.flush_telemetry()
def on_lost(self, reason: str) -> None:
self.log.warning(
"broker link lost (%s): enforcing the cached config (FR-14) block=%s until=%s",
reason,
self.state.config.block,
self.state.config.until,
)
# -- one tick ----------------------------------------------------------
def tick(self) -> EnforcementReport:
config = self.state.config
now = datetime.now(timezone.utc)
blocking = config.is_blocking(now)
report = self.state.enforcer.enforce(config, now)
self.state.last_report = report
self.state.overlay.update(self.notice(config, report, now) if blocking else None)
return report
def notice(self, config: DeviceConfig, report: EnforcementReport, now: datetime) -> Notice | None:
if not report.blocked_names:
return None
return Notice(
reason=config.reason_text or "screen-time rules are active",
until=format_until(config),
names=report.blocked_names,
)
# -- reporting ---------------------------------------------------------
def publish_status(self, config: DeviceConfig | None = None, app: str | None = None) -> None:
link = self.state.link
if link is None:
return
current = config or self.state.config
link.publish_status(current.is_blocking(), app if app is not None else foreground_app())
def flush_telemetry(self) -> None:
link = self.state.link
if link is None:
return
day = day_key()
apps = self.state.telemetry.day(day)
if apps and link.publish_telemetry(day, apps):
self.log.info("telemetry republished day=%s apps=%d", day, len(apps))
def sample_usage(self) -> None:
"""One usage sample: visible-window apps get the elapsed seconds."""
apps = visible_apps()
totals = self.state.sampler.sample(apps)
if not totals:
return
day = day_key()
self.log.debug("usage sample day=%s apps=%d", day, len(totals))
link = self.state.link
if link is not None and link.publish_telemetry(day, totals):
self.log.info(
"telemetry day=%s apps=%d top=%s",
day,
len(totals),
json.dumps(build_payload(day, totals)["apps"][:3]),
)
# -- the loop ----------------------------------------------------------
def run(self) -> int:
self.load_config()
self.state.overlay.start()
link = MqttLink(
self.settings,
on_config=self.apply_config,
on_connected=self.on_connected,
on_lost=self.on_lost,
)
self.state.link = link
try:
link.start()
except Exception as exc: # noqa: BLE001 - run without a link, keep the rules
self.log.error("mqtt could not start (%s); enforcing the cached config only", exc)
self.state.started_at = time.monotonic()
next_telemetry = time.monotonic() + self.settings.telemetry_seconds
next_status = time.monotonic() + self.settings.status_seconds
self.log.info(
"client running: poll=%ss telemetry=%ss status=%ss device=%s state_dir=%s",
self.settings.poll_seconds,
self.settings.telemetry_seconds,
self.settings.status_seconds,
self.settings.device_token,
self.settings.state_path,
)
while not self.stop_event.is_set():
try:
report = self.tick()
if report.killed:
self.publish_status(self.state.config)
except Exception as exc: # noqa: BLE001 - one bad tick must not stop the client
self.log.exception("enforcement tick failed: %s", exc)
now = time.monotonic()
if now >= next_telemetry:
next_telemetry = now + self.settings.telemetry_seconds
try:
self.sample_usage()
except Exception as exc: # noqa: BLE001
self.log.warning("usage sample failed: %s", exc)
if now >= next_status:
next_status = now + self.settings.status_seconds
try:
self.publish_status(self.state.config)
self.log.info(
"status published locked=%s blocking=%s broker=%s cached_config=%s",
self.state.config.locked,
self.state.config.is_blocking(),
"connected" if (self.state.link and self.state.link.connected) else "unreachable",
bool(self.state.config.generated_at),
)
except Exception as exc: # noqa: BLE001
self.log.warning("status publish failed: %s", exc)
self.stop_event.wait(self.settings.poll_seconds)
self.shutdown()
return 0
def shutdown(self) -> None:
self.log.info("client stopping: publishing offline status")
link = self.state.link
if link is not None:
try:
link.publish_offline()
time.sleep(0.2) # let the retained offline message leave
link.stop()
except Exception as exc: # noqa: BLE001
self.log.debug("shutdown: %s", exc)
self.state.overlay.stop()
def request_stop(self, *_args) -> None:
self.stop_event.set()
def install_signal_handlers(agent: Agent) -> None:
"""Stop cleanly on Ctrl-C and on a Scheduled Task 'End task'."""
for name in ("SIGINT", "SIGTERM"):
handler = getattr(signal, name, None)
if handler is None:
continue
try:
signal.signal(handler, agent.request_stop)
except (ValueError, OSError): # pragma: no cover - not the main thread
pass
def check_once(settings: Settings) -> dict[str, object]:
"""A one-shot report for the operator and for the phase proof.
Prints the cached config, the effective block decision, and the processes it
would terminate right now. It changes nothing.
"""
cache = ConfigCache(settings.config_path())
config = cache.load() or DeviceConfig(platform="windows", locked=False)
enforcer = Enforcer(settings.protected)
from .blocker import select_victims # noqa: PLC0415 - only needed here
from .process_watch import snapshot_processes
now = datetime.now(timezone.utc)
victims = select_victims(
snapshot_processes(),
config,
now=now,
protected_names=enforcer.protected_names,
protected_pids=enforcer.protected_pids,
)
return {
"device_topic": settings.device_topic,
"state_dir": str(settings.state_path),
"checked_at": iso_now(),
"config": config.to_dict(),
"blocking": config.is_blocking(now),
"blocked_names": sorted(config.blocked_names()),
"victims": [{"pid": v.pid, "name": v.name, "rule": v.matched} for v in victims],
}

View File

@@ -0,0 +1,87 @@
"""Pure block-selection logic (AGENTS.md FR-3, FR-4, FR-16).
No psutil and no Windows here, so the decision can be unit tested anywhere.
:mod:`webrestrict.process_watch` supplies the running process list and does the
killing; this module only decides **which** processes are blocked.
The rules are deliberately small, because the client must never widen or narrow
them on its own (AGENTS.md §10 rule 7):
* nothing is blocked while the config is not effectively locked;
* a process is blocked when its normalised name is on the blocklist;
* the allowlist always wins (FR-4);
* the client never kills itself, its parent chain, or a protected system
process, whatever the blocklist says.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Iterable
from .config import DeviceConfig, bare
@dataclass(frozen=True)
class Victim:
"""One process that must be terminated."""
pid: int
name: str # as reported by the OS, e.g. "notepad.exe"
matched: str # the normalised blocklist entry that matched
def select_victims(
processes: Iterable[tuple[int, str]],
config: DeviceConfig,
*,
now: datetime | None = None,
protected_names: Iterable[str] = (),
protected_pids: Iterable[int] = (),
) -> list[Victim]:
"""Choose the processes to terminate for one enforcement tick.
``processes`` is an iterable of ``(pid, name)`` pairs, so the caller can pass
a psutil snapshot, a fake list in a test, or a single process. The result is
ordered by pid and contains no duplicates, so the caller can log it.
"""
if not config.is_blocking(now):
return []
blocked = config.blocked_names()
if not blocked:
return []
allowed = config.allowed_names()
protected = {bare(item) for item in protected_names if item and str(item).strip()}
do_not_touch = {int(pid) for pid in protected_pids}
victims: list[Victim] = []
seen: set[int] = set()
for pid, name in processes:
try:
pid = int(pid)
except (TypeError, ValueError):
continue
value = bare(name)
if not value or pid in seen:
continue
seen.add(pid)
if pid in do_not_touch or value in protected or value in allowed:
continue
if value in blocked:
victims.append(Victim(pid=pid, name=name, matched=value))
victims.sort(key=lambda victim: (victim.matched, victim.pid))
return victims
def self_protection(pid: int, image_names: Iterable[str], parents: Iterable[int]) -> tuple[set[int], set[str]]:
"""The pids and names the enforcer must never touch for its own process tree.
The client runs as ``python.exe`` on the kid's machine, so a blocklist that
named Python would otherwise make the client kill itself and stop reporting.
"""
pids = {int(pid)} | {int(parent) for parent in parents}
names = {str(name) for name in image_names if str(name).strip()}
return pids, names

View File

@@ -0,0 +1,268 @@
"""The device config and its on-disk cache (AGENTS.md FR-14).
``restrict/<device>/config`` carries the desired state, exactly as
``internal/mqtt/payload.go`` publishes it. This module parses that payload and
keeps the last valid copy on disk, so a device that cannot reach the broker
still knows its rules. Only a valid, not-older config replaces the cache:
a malformed or stale message never leaves the device without rules.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
# The schema version this client understands. A payload with a higher version is
# rejected rather than guessed at; the cached config stays in force.
CONFIG_VERSION = 1
def parse_time(value: str | None) -> datetime | None:
"""Parse an RFC 3339 timestamp as the Control Service writes it.
Accepts a trailing ``Z`` and an optional fractional part. Returns ``None``
for an empty or unparseable value, so a bad timestamp downgrades to "no end
time" instead of raising.
"""
if not value:
return None
text = value.strip()
if text.endswith("Z") or text.endswith("z"):
text = text[:-1] + "+00:00"
try:
moment = datetime.fromisoformat(text)
except ValueError:
return None
if moment.tzinfo is None:
moment = moment.replace(tzinfo=timezone.utc)
return moment
def bare(name: str) -> str:
"""Normalise a process or list entry for comparison.
``notepad.exe``, ``Notepad.EXE`` and ``notepad`` are the same app. The
Windows process list reports ``.exe``; a blocklist entry may or may not.
"""
value = (name or "").strip().casefold()
if value.endswith(".exe"):
value = value[:-4]
return value
@dataclass
class DeviceConfig:
"""The desired state for one device (TRANSPORT_DESIGN.md, Phase 3)."""
v: int = CONFIG_VERSION
device_id: int = 0
name: str = ""
platform: str = "windows"
locked: bool = False
until: str | None = None
allow: list[str] = field(default_factory=list)
block: list[str] = field(default_factory=list)
reason: str = ""
grant_until: str | None = None
generated_at: str | None = None
# -- parsing -----------------------------------------------------------
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "DeviceConfig":
"""Parse a payload, ignoring unknown fields (forward compatible).
Raises ``ValueError`` on a payload this client cannot honour, so the
caller keeps the last good config.
"""
version = raw.get("v", CONFIG_VERSION)
try:
version = int(version)
except (TypeError, ValueError) as exc:
raise ValueError(f"config v is not an integer: {version!r}") from exc
if version > CONFIG_VERSION:
raise ValueError(f"config v={version} is newer than this client ({CONFIG_VERSION})")
return cls(
v=version,
device_id=int(raw.get("device_id") or 0),
name=str(raw.get("name") or ""),
platform=str(raw.get("platform") or "windows"),
locked=bool(raw.get("locked")),
until=_str_or_none(raw.get("until")),
allow=_string_list(raw.get("allow")),
block=_string_list(raw.get("block")),
reason=str(raw.get("reason") or ""),
grant_until=_str_or_none(raw.get("grant_until")),
generated_at=_str_or_none(raw.get("generated_at")),
)
@classmethod
def from_json(cls, payload: str | bytes) -> "DeviceConfig":
if isinstance(payload, (bytes, bytearray)):
payload = payload.decode("utf-8", "replace")
data = json.loads(payload)
if not isinstance(data, dict):
raise ValueError("config payload is not a JSON object")
return cls.from_dict(data)
def to_json(self) -> str:
return json.dumps(self.to_dict(), sort_keys=True)
def to_dict(self) -> dict[str, Any]:
return {
"v": self.v,
"device_id": self.device_id,
"name": self.name,
"platform": self.platform,
"locked": self.locked,
"until": self.until,
"allow": list(self.allow),
"block": list(self.block),
"reason": self.reason,
"grant_until": self.grant_until,
"generated_at": self.generated_at,
}
# -- effective state --------------------------------------------------
def until_at(self) -> datetime | None:
return parse_time(self.until)
def grant_at(self) -> datetime | None:
return parse_time(self.grant_until)
def is_blocking(self, now: datetime | None = None) -> bool:
"""Whether the device must block right now.
This mirrors the Control Service's *effective* state
(``internal/store/schedules.go`` ``EffectiveState``): the payload already
carries the resolved ``locked`` flag, so the client only has to apply
the clock. An expired ``until`` lifts the block locally — the same
instant the Control Service would clear it — a live ``grant_until``
never blocks, and an indefinite lock (``until`` absent) never lifts.
"""
if not self.locked:
return False
moment = now or datetime.now(timezone.utc)
grant = self.grant_at()
if grant is not None and grant > moment:
return False
until = self.until_at()
if until is not None and moment >= until:
return False
return True
def blocked_names(self) -> set[str]:
"""The blocklist as normalised names, minus the allowlist (FR-4)."""
allowed = {bare(item) for item in self.allow}
return {bare(item) for item in self.block if bare(item)} - allowed
def allowed_names(self) -> set[str]:
return {bare(item) for item in self.allow if bare(item)}
def is_blocked_name(self, process_name: str) -> bool:
"""Whether one process name is blocked, with the allowlist winning."""
value = bare(process_name)
if not value:
return False
if value in self.allowed_names():
return False
return value in self.blocked_names()
@property
def reason_text(self) -> str:
"""A short human reason for the overlay (FR-9)."""
return {
"schedule": "it is inside a scheduled screen-time window",
"manual": "a parent locked this computer",
"grant": "",
}.get(self.reason, self.reason or "screen-time rules are active")
def generation_key(self) -> tuple[str, int]:
"""A sortable key for "is this config newer?".
The Control Service republishes a config whenever the state changes, so
``generated_at`` is normally enough. The fallback keeps a payload with a
missing timestamp acceptable rather than dropping a real update.
"""
return (self.generated_at or "", self.device_id)
def _str_or_none(value: Any) -> str | None:
return None if value is None else str(value)
def _string_list(value: Any) -> list[str]:
if value is None:
return []
if isinstance(value, str):
return [value]
if isinstance(value, Iterable):
return [str(item) for item in value if str(item).strip()]
return []
class ConfigCache:
"""The last valid config on disk (FR-14 "keep last state").
A device that cannot reach the broker keeps enforcing what it already has.
The cache is written atomically, so a power cut cannot leave a half-written
file, and a cache that cannot be read is treated as empty rather than fatal.
"""
def __init__(self, path: str | os.PathLike[str]):
self.path = Path(path)
self._current: DeviceConfig | None = None
@property
def current(self) -> DeviceConfig | None:
"""The in-memory config, loading from disk on first use."""
if self._current is None:
self._current = self._read()
return self._current
def load(self) -> DeviceConfig | None:
return self.current
def save(self, config: DeviceConfig) -> None:
"""Persist a config and make it current."""
self.path.parent.mkdir(parents=True, exist_ok=True)
temp = self.path.with_suffix(self.path.suffix + ".tmp")
with open(temp, "w", encoding="utf-8") as handle:
handle.write(config.to_json())
handle.flush()
os.fsync(handle.fileno())
os.replace(temp, self.path)
self._current = config
def accept(self, payload: str | bytes) -> DeviceConfig | None:
"""Validate and store a payload.
Returns the stored config, or ``None`` when the payload is rejected (a
malformed message, a newer schema, or an older ``generated_at`` than the
cache). A rejection leaves the current config untouched.
"""
try:
candidate = DeviceConfig.from_json(payload)
except (ValueError, TypeError):
# json.JSONDecodeError is a ValueError. A broker bug or a hostile
# payload must never leave the device without rules (FR-14).
return None
existing = self.current
if existing is not None and candidate.generation_key() < existing.generation_key():
return None
self.save(candidate)
return candidate
# ---------------------------------------------------------------------
def _read(self) -> DeviceConfig | None:
try:
with open(self.path, "r", encoding="utf-8") as handle:
return DeviceConfig.from_json(handle.read())
except FileNotFoundError:
return None
except (OSError, ValueError, json.JSONDecodeError):
return None

View File

@@ -0,0 +1,241 @@
"""The MQTT link to the 8883 device plane (AGENTS.md §10 rule 6, FR-6).
The client is one device account on the TLS listener. Its ACL lets it read only
``restrict/<token>/config`` and write only its own status and telemetry, so the
broker itself stops a child publishing a fake "unlocked" (TRANSPORT_DESIGN.md
"Security").
Presence is the broker's Last Will and Testament: the client publishes a
retained ``{"online": true}`` when it connects and registers the will
``{"online": false}`` on the same topic, so the Control Service learns the
device is gone the moment the connection drops without a clean goodbye (FR-6).
A broker that is down is not fatal: the cached config stays in force (FR-14),
paho retries on a backoff, and the retained config is delivered again on
reconnect.
"""
from __future__ import annotations
import json
import logging
import ssl
from datetime import datetime, timezone
from typing import Callable, Mapping
from .settings import Settings
log = logging.getLogger("webrestrict.mqtt")
QOS = 1
KEEPALIVE_SECONDS = 30
def iso_now(moment: datetime | None = None) -> str:
"""RFC 3339 in UTC, as the Control Service writes timestamps."""
current = moment or datetime.now(timezone.utc)
if current.tzinfo is None:
current = current.replace(tzinfo=timezone.utc)
return current.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def status_payload(locked: bool, app: str = "", tamper: dict[str, str] | None = None) -> str:
"""The status report the dashboard reads (FR-6, FR-9, FR-17)."""
body: dict[str, object] = {"online": True, "locked": locked, "app": app, "at": iso_now()}
if tamper:
body["tamper"] = {**tamper, "at": iso_now()}
return json.dumps(body, sort_keys=True)
def telemetry_payload(day: str, apps: Mapping[str, int]) -> str:
"""A day's per-app totals. Not retained: it is a report, not a rule."""
return json.dumps(
{
"day": day,
"apps": [
{"name": name, "seconds": int(seconds)}
for name, seconds in sorted(apps.items())
if name and int(seconds) > 0
],
"domains": [],
},
sort_keys=True,
)
class MqttLink:
"""A reconnecting paho client wrapper with a small, testable surface."""
def __init__(
self,
settings: Settings,
on_config: Callable[[str], None],
on_connected: Callable[[], None] | None = None,
on_lost: Callable[[str], None] | None = None,
):
self.settings = settings
self.on_config = on_config
self.on_connected = on_connected or (lambda: None)
self.on_lost = on_lost or (lambda reason: None)
self.connected = False
self.last_error = ""
self._client = None
self._stopped = False
self._unreachable_logged = False
# -- lifecycle ---------------------------------------------------------
def start(self) -> None:
"""Create the client and begin connecting in the background."""
import paho.mqtt.client as mqtt # noqa: PLC0415 - keep the import local
client = self._new_client(mqtt)
self._client = client
client.on_connect = self._handle_connect
client.on_connect_fail = self._handle_connect_fail
client.on_disconnect = self._handle_disconnect
client.on_message = self._handle_message
client.username_pw_set(self.settings.mqtt_username, self.settings.mqtt_password or None)
if self.settings.ca_file:
client.tls_set(
ca_certs=self.settings.ca_file,
cert_reqs=ssl.CERT_REQUIRED,
tls_version=ssl.PROTOCOL_TLS_CLIENT,
)
else: # pragma: no cover - the lab always pins the CA
client.tls_set(cert_reqs=ssl.CERT_REQUIRED, tls_version=ssl.PROTOCOL_TLS_CLIENT)
# Presence (FR-6): the broker publishes this the moment the link drops
# without a clean DISCONNECT.
client.will_set(
self.settings.status_topic,
json.dumps({"online": False}),
qos=QOS,
retain=True,
)
client.reconnect_delay_set(min_delay=1, max_delay=30)
log.info(
"mqtt connecting to %s:%s as %s (topic %s)",
self.settings.broker_host,
self.settings.broker_port,
self.settings.mqtt_username,
self.settings.device_topic,
)
client.connect_async(
self.settings.broker_host,
self.settings.broker_port,
keepalive=KEEPALIVE_SECONDS,
)
client.loop_start()
def stop(self) -> None:
"""Disconnect cleanly, which suppresses the will."""
self._stopped = True
client = self._client
if client is None:
return
try:
client.disconnect()
client.loop_stop()
except Exception as exc: # noqa: BLE001 - shutting down anyway
log.debug("mqtt stop: %s", exc)
# -- publishing --------------------------------------------------------
def publish_status(self, locked: bool, app: str = "", tamper: dict[str, str] | None = None) -> bool:
return self._publish(self.settings.status_topic, status_payload(locked, app, tamper), retain=True)
def publish_telemetry(self, day: str, apps: Mapping[str, int]) -> bool:
if not apps:
return False
return self._publish(self.settings.telemetry_topic, telemetry_payload(day, apps), retain=False)
def publish_offline(self) -> bool:
"""Retained offline status, published on a clean shutdown (FR-6)."""
return self._publish(self.settings.status_topic, json.dumps({"online": False}), retain=True)
def _publish(self, topic: str, payload: str, *, retain: bool) -> bool:
client = self._client
if client is None or not self.connected:
return False
try:
info = client.publish(topic, payload, qos=QOS, retain=retain)
if info.rc != 0:
log.warning("publish to %s rejected (rc=%s)", topic, info.rc)
return False
return True
except Exception as exc: # noqa: BLE001 - a lost link retries
log.warning("publish to %s failed: %s", topic, exc)
return False
# -- paho plumbing -----------------------------------------------------
def _new_client(self, mqtt):
"""paho 2.x takes an explicit callback API version; 1.x does not."""
try:
return mqtt.Client(
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
client_id=self.settings.effective_client_id,
clean_session=True,
protocol=mqtt.MQTTv311,
)
except TypeError: # pragma: no cover - paho 1.x
return mqtt.Client(
client_id=self.settings.effective_client_id,
clean_session=True,
protocol=mqtt.MQTTv311,
)
def _handle_connect(self, client, _userdata, _flags, reason_code, _properties=None):
code = getattr(reason_code, "value", reason_code)
try:
code = int(code)
except (TypeError, ValueError):
code = 1
if code != 0:
self.connected = False
self.last_error = f"connect refused: {reason_code}"
log.warning("mqtt connect refused: %s", reason_code)
return
self.connected = True
self.last_error = ""
self._unreachable_logged = False
log.info("mqtt connected, subscribing to %s", self.settings.device_topic)
client.subscribe(self.settings.device_topic, qos=QOS)
# on_connected publishes the retained online status with the real
# locked state, and flushes any telemetry held while offline.
self.on_connected()
def _handle_connect_fail(self, *_args):
"""The broker could not be reached at all (FR-14).
Logged once per outage so the record shows the client is running on its
cached config rather than sitting idle, and not flooded by paho's retry.
"""
self.connected = False
self.last_error = "broker unreachable"
if not self._unreachable_logged:
self._unreachable_logged = True
log.warning(
"broker %s:%s is unreachable; enforcing the cached config only (FR-14)",
self.settings.broker_host,
self.settings.broker_port,
)
self.on_lost("broker unreachable")
def _handle_disconnect(self, _client, _userdata, _flags, reason_code, _properties=None):
self.connected = False
message = str(reason_code)
self.last_error = message
if self._stopped:
log.info("mqtt disconnected (client stopped): %s", message)
return
log.warning("mqtt connection lost: %s — keeping the last config (FR-14)", message)
self.on_lost(message)
def _handle_message(self, _client, _userdata, message):
if message.topic != self.settings.device_topic:
return
payload = message.payload.decode("utf-8", "replace")
try:
self.on_config(payload)
except Exception as exc: # noqa: BLE001 - a bad config must not kill the link
log.warning("config rejected, keeping the last one: %s", exc)

View File

@@ -0,0 +1,147 @@
"""The child's warning window (AGENTS.md FR-9).
A small always-on-top Tkinter window: what is blocked, why, and until when. It
is an *alert*, not a defence in depth — the child can close it, and on Windows
the enforcement is the process block itself (FR-3) plus the presence and
offline reports that reach Sam (FR-6, FR-13).
Tkinter wants one thread that owns the interpreter, so the window runs on its
own daemon thread and the agent talks to it through a queue. If Tk is missing,
or there is no interactive desktop (a service session, a headless test), the
overlay is simply unavailable and blocking continues.
"""
from __future__ import annotations
import logging
import queue
import threading
from dataclasses import dataclass
from typing import Sequence
log = logging.getLogger("webrestrict.overlay")
TITLE = "Screen time"
@dataclass(frozen=True)
class Notice:
"""What the window shows while a block is active."""
reason: str
until: str
names: Sequence[str]
@property
def blocked_text(self) -> str:
if not self.names:
return "This computer is blocked while the window is active."
shown = ", ".join(self.names[:8])
if len(self.names) > 8:
shown += f", and {len(self.names) - 8} more"
return f"Blocked now: {shown}"
class Overlay:
"""A queue-fed Tkinter window with a tiny, testable interface."""
def __init__(self, enabled: bool = True):
self.enabled = enabled
self.available = False
self._queue: "queue.Queue[tuple[str, object]]" = queue.Queue()
self._thread: threading.Thread | None = None
self._shown: Notice | None = None
def start(self) -> None:
if not self.enabled:
log.info("overlay disabled by configuration")
return
self._thread = threading.Thread(target=self._run, name="webrestrict-overlay", daemon=True)
self._thread.start()
def update(self, notice: Notice | None) -> None:
"""Show the notice, hide on ``None``. Repeat calls with the same content are dropped."""
if notice == self._shown:
return
self._shown = notice
if notice is None:
self._queue.put(("hide", None))
else:
self._queue.put(("show", notice))
def stop(self) -> None:
self._queue.put(("stop", None))
# ---------------------------------------------------------------------
def _run(self) -> None: # pragma: no cover - needs a desktop
try:
import tkinter as tk
except ImportError as exc:
log.warning("tkinter is unavailable, no warning window: %s", exc)
return
try:
root = tk.Tk()
except Exception as exc: # noqa: BLE001 - no interactive desktop
log.warning("the warning window is unavailable: %s", exc)
return
root.withdraw()
root.title(TITLE)
root.attributes("-topmost", True)
root.resizable(False, False)
frame = tk.Frame(root, padx=18, pady=16)
frame.pack(fill="both", expand=True)
heading = tk.Label(frame, text="Screen time is over", font=("Segoe UI", 14, "bold"), anchor="w")
heading.pack(fill="x")
reason_label = tk.Label(frame, text="", wraplength=380, justify="left", anchor="w")
reason_label.pack(fill="x", pady=(8, 0))
until_label = tk.Label(frame, text="", anchor="w", font=("Segoe UI", 9))
until_label.pack(fill="x", pady=(6, 0))
blocked_label = tk.Label(frame, text="", wraplength=380, justify="left", anchor="w")
blocked_label.pack(fill="x", pady=(8, 0))
tk.Button(frame, text="Close warning", command=root.withdraw).pack(anchor="e", pady=(12, 0))
root.geometry("420x260")
self.available = True
log.info("warning window is available")
def drain() -> None: # pragma: no cover - needs a desktop
stop = False
while True:
try:
action, payload = self._queue.get_nowait()
except queue.Empty:
break
if action == "show" and isinstance(payload, Notice):
reason_label.config(text=f"The rules say: {payload.reason}.")
until_label.config(text=f"Until {payload.until}." if payload.until else "No end time is set.")
blocked_label.config(text=payload.blocked_text)
root.deiconify()
root.lift()
root.attributes("-topmost", True)
place_bottom_right(root)
elif action == "hide":
root.withdraw()
elif action == "stop":
stop = True
if stop:
root.destroy()
return
root.after(200, drain)
root.after(200, drain)
try:
root.mainloop()
except Exception as exc: # noqa: BLE001 - nothing left to do
log.debug("warning window loop ended: %s", exc)
def place_bottom_right(root) -> None: # pragma: no cover - needs a desktop
"""Park the window bottom-right, out of the way of what it is covering."""
root.update_idletasks()
width = root.winfo_width() or 420
height = root.winfo_height() or 260
screen = root.winfo_screenwidth(), root.winfo_screenheight()
root.geometry(f"{width}x{height}+{max(screen[0] - width - 24, 0)}+{max(screen[1] - height - 72, 0)}")

View File

@@ -0,0 +1,263 @@
"""The Windows side of enforcement: process list, window list, foregound app.
Everything here touches the real machine, so it is kept apart from the pure
decision in :mod:`webrestrict.blocker`. ``psutil`` is the only third-party
dependency; the window queries go through ``ctypes`` and ``user32`` so the
client needs no ``pywin32`` install.
Off Windows (unit tests, a lab box) every function degrades to an empty result
instead of raising, so the client stays importable and testable.
"""
from __future__ import annotations
import ctypes
import logging
import os
import sys
from dataclasses import dataclass
from datetime import datetime
from typing import Iterable
from .blocker import Victim, select_victims, self_protection
from .config import DeviceConfig, bare
log = logging.getLogger("webrestrict.process")
IS_WINDOWS = os.name == "nt"
# How long to give a polite terminate() before the hard kill(), in seconds.
TERMINATE_GRACE_SECONDS = 3.0
def _psutil():
"""Import psutil on demand, with a readable error when it is missing."""
try:
import psutil # noqa: PLC0415 (deliberate: keeps the import cost local)
except ImportError as exc: # pragma: no cover - depends on the machine
raise RuntimeError(
"psutil is not installed; run: python -m pip install -r windows/requirements.txt"
) from exc
return psutil
def snapshot_processes() -> list[tuple[int, str]]:
"""Every running process as ``(pid, name)``.
A process that exits while the list is built, or that denies access, is
skipped: it is not a reason to fail an enforcement tick.
"""
psutil = _psutil()
out: list[tuple[int, str]] = []
for proc in psutil.process_iter(["pid", "name"]):
try:
info = proc.info
name = info.get("name") or ""
pid = int(info.get("pid") or 0)
except Exception: # noqa: BLE001 - a dead process is normal
continue
if pid and name:
out.append((pid, name))
return out
def kill_process(pid: int, name: str) -> str:
"""Terminate one process. Returns ``"terminated"`` or ``"killed"``.
``terminate()`` asks the app to close, which is exactly what a scheduled
window does to a game. If it is still alive after a short grace, ``kill()``
ends it. Access denied is reported, not raised: the machine keeps running.
"""
psutil = _psutil()
try:
proc = psutil.Process(pid)
except Exception as exc: # noqa: BLE001 - the process is already gone
log.debug("process %s (%s) vanished: %s", pid, name, exc)
return "gone"
try:
proc.terminate()
except Exception as exc: # noqa: BLE001 - e.g. AccessDenied for another user
log.warning("terminate %s (%s) failed: %s", pid, name, exc)
return "failed"
try:
proc.wait(timeout=TERMINATE_GRACE_SECONDS)
return "terminated"
except Exception: # noqa: BLE001 - still running, escalate
pass
try:
proc.kill()
proc.wait(timeout=TERMINATE_GRACE_SECONDS)
return "killed"
except Exception as exc: # noqa: BLE001
log.warning("kill %s (%s) failed: %s", pid, name, exc)
return "failed"
# --- Windows window probes (ctypes; no pywin32) --------------------------
_WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p) if IS_WINDOWS else None
def _user32():
return ctypes.windll.user32 # type: ignore[attr-defined]
def window_pids() -> set[int]:
"""Pids that own a visible, titled top-level window.
That is the closest cheap proxy Windows offers for "the child can see this
app". A background app with a hidden window does not count.
"""
if not IS_WINDOWS: # pragma: no cover - platform dependent
return set()
user32 = _user32()
pids: set[int] = set()
def callback(hwnd, _lparam): # pragma: no cover - platform dependent
try:
if not user32.IsWindowVisible(hwnd):
return True
if user32.GetWindowTextLengthW(hwnd) == 0:
return True
pid = ctypes.c_ulong(0)
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
if pid.value:
pids.add(int(pid.value))
except Exception: # noqa: BLE001 - never let a callback break the enum
return True
return True
try:
user32.EnumWindows(_WNDENUMPROC(callback), 0)
except Exception as exc: # noqa: BLE001 - pragma: no cover
log.debug("EnumWindows failed: %s", exc)
return pids
def foreground_pid() -> int | None:
"""The pid of the window the child is using, or ``None`` off Windows."""
if not IS_WINDOWS: # pragma: no cover - platform dependent
return None
try:
hwnd = _user32().GetForegroundWindow()
if not hwnd:
return None
pid = ctypes.c_ulong(0)
_user32().GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
return int(pid.value) or None
except Exception: # noqa: BLE001 - pragma: no cover
return None
def process_name(pid: int) -> str:
"""The image name for one pid, or an empty string."""
psutil = _psutil()
try:
return psutil.Process(pid).name() or ""
except Exception: # noqa: BLE001 - the process ended
return ""
def foreground_app() -> str:
"""The foreground app's image name, for the status report (FR-9)."""
pid = foreground_pid()
return process_name(pid) if pid else ""
def visible_apps(exclude_pids: Iterable[int] | None = None) -> list[str]:
"""Names of processes with a visible, titled window right now.
The client's own process is excluded by default: its warning window has a
title and would otherwise be counted as the child's usage.
"""
pids = window_pids()
if not pids:
return []
skip = {int(pid) for pid in (exclude_pids if exclude_pids is not None else (os.getpid(),))}
pids -= skip
return sorted({name for name in (process_name(pid) for pid in pids) if name})
@dataclass
class EnforcementReport:
"""What one tick did, for the log and the status report."""
blocking: bool
blocked_names: tuple[str, ...]
killed: tuple[Victim, ...]
failed: tuple[Victim, ...]
@property
def killed_names(self) -> list[str]:
return sorted({victim.matched for victim in self.killed})
class Enforcer:
"""Terminates blocked processes while the device is effectively locked."""
def __init__(self, protected: Iterable[str], log_extra: logging.Logger | None = None):
self.log = log_extra or log
self.protected_names = {bare(name) for name in protected if name and name.strip()}
self.protected_pids: set[int] = set()
# The client's own process tree is never a victim, even if a blocklist
# names python.exe: killing itself would end enforcement silently.
parents = _parent_pids(os.getpid())
pids, names = self_protection(os.getpid(), {os.path.basename(sys.executable), "python", "pythonw"}, parents)
self.protected_pids |= pids
self.protected_names |= {bare(name) for name in names}
def enforce(self, config: DeviceConfig, now: datetime | None = None) -> EnforcementReport:
"""One enforcement tick: find the victims, kill them, report the result."""
blocking = config.is_blocking(now)
victims = select_victims(
snapshot_processes(),
config,
now=now,
protected_names=self.protected_names,
protected_pids=self.protected_pids,
)
killed: list[Victim] = []
failed: list[Victim] = []
for victim in victims:
outcome = kill_process(victim.pid, victim.name)
if outcome in ("terminated", "killed"):
self.log.info(
"blocked %s (pid=%s, rule=%s): %s", victim.name, victim.pid, victim.matched, outcome
)
killed.append(victim)
elif outcome == "failed":
failed.append(victim)
if killed:
self.log.info("enforcement killed %d process(es): %s", len(killed), ", ".join(
f"{v.name}({v.pid})" for v in killed
))
return EnforcementReport(
blocking=blocking,
blocked_names=tuple(sorted(config.blocked_names())),
killed=tuple(killed),
failed=tuple(failed),
)
def _parent_pids(pid: int) -> set[int]:
"""The pid's ancestor chain, skipping failures."""
psutil = _psutil()
out: set[int] = set()
try:
proc = psutil.Process(pid)
except Exception: # noqa: BLE001 - pragma: no cover
return out
for _ in range(10):
try:
parent = proc.parent()
except Exception: # noqa: BLE001
break
if parent is None or parent.pid in out:
break
out.add(parent.pid)
proc = parent
return out

View File

@@ -0,0 +1,178 @@
"""Client settings, read from one JSON file (AGENTS.md §11 Phase 4).
Everything the client needs is local configuration: the broker address, the
device's own topic and MQTT account, and where to keep its state. Nothing here
is a second source of truth — the Control Service still owns the rules.
The file is provisioned on the machine and is **never** committed. It holds the
device's MQTT password, which must stay off the repository (AGENTS.md §10 rule
8). The path comes from ``--config``, then ``WEBRESTRICT_CONFIG``, then
``%PROGRAMDATA%\\WebRestrict\\client.json``.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
# Default location. ProgramData is machine-wide and readable by the New Comp
# account, which is what the logon Scheduled Task runs as.
DEFAULT_CONFIG_NAME = "client.json"
DEFAULT_STATE_DIR_NAME = "WebRestrict"
# Never kill these, whatever a blocklist says. The client must not be able to
# take the machine down, and it must not kill itself (it runs as python.exe).
# The client's own interpreter and its parent chain are added at runtime.
DEFAULT_PROTECTED: tuple[str, ...] = (
"system",
"system idle process",
"registry",
"smss",
"csrss",
"wininit",
"winlogon",
"services",
"lsass",
"svchost",
"dwm",
"explorer",
"taskhostw",
"sihost",
"ctfmon",
"conhost",
"fontdrvhost",
"audiodg",
"msmpeng",
"securityhealthservice",
"searchindexer",
"startmenuexperiencehost",
"shellexperiencehost",
"textinputhost",
"applicationframehost",
"python",
"pythonw",
)
def default_program_data() -> Path:
"""The machine-wide directory for client configuration and state."""
base = os.environ.get("PROGRAMDATA") or os.environ.get("ALLUSERSPROFILE")
if base:
return Path(base) / DEFAULT_STATE_DIR_NAME
# Not Windows (unit tests, a lab box): keep it under the user's home.
return Path.home() / ".webrestrict"
def default_config_path() -> Path:
"""The config path: ``WEBRESTRICT_CONFIG`` wins, else ``%PROGRAMDATA%``."""
override = os.environ.get("WEBRESTRICT_CONFIG")
if override:
return Path(override)
return default_program_data() / DEFAULT_CONFIG_NAME
@dataclass
class Settings:
"""Everything the client needs to run, with safe lab defaults.
A missing field falls back to the default, so a hand-written config file
only needs the values that differ. Unknown fields are ignored, so the file
may grow without a client update.
"""
# --- the link ---------------------------------------------------------
broker_host: str = "192.168.20.13"
broker_port: int = 8883
ca_file: str = "" # empty = the platform trust store
device_topic: str = ""
mqtt_username: str = ""
mqtt_password: str = ""
client_id: str = "" # empty = derive from the topic token
# --- local state ------------------------------------------------------
state_dir: str = ""
# --- timings (seconds) ------------------------------------------------
poll_seconds: float = 5.0
telemetry_seconds: float = 60.0
status_seconds: float = 60.0
connect_timeout_seconds: float = 10.0
# --- behaviour --------------------------------------------------------
overlay: bool = True
protected: list[str] = field(default_factory=lambda: list(DEFAULT_PROTECTED))
# ---------------------------------------------------------------------
@property
def state_path(self) -> Path:
"""The directory holding the cached config, the usage roll-up and the log."""
if self.state_dir:
return Path(self.state_dir)
if self.device_token:
return default_program_data() / self.device_token
return default_program_data()
@property
def device_token(self) -> str:
"""The middle topic level, which is also the broker account name."""
base = self.device_topic.removesuffix("/config")
return base.rsplit("/", 1)[-1] if "/" in base else base
@property
def status_topic(self) -> str:
return self.device_topic.removesuffix("/config") + "/status"
@property
def telemetry_topic(self) -> str:
return self.device_topic.removesuffix("/config") + "/telemetry"
@property
def effective_client_id(self) -> str:
return self.client_id or f"kid-{self.device_token or 'windows'}"
def config_path(self) -> Path:
return self.state_path / "config-cache.json"
def telemetry_path(self) -> Path:
return self.state_path / "telemetry.json"
def log_path(self) -> Path:
return self.state_path / "client.log"
def validate(self) -> None:
"""Raise when the client cannot possibly work, instead of failing later."""
if not self.device_topic:
raise ValueError("device_topic is not set (e.g. restrict/finn_s_computer_2/config)")
if not self.device_topic.endswith("/config"):
raise ValueError(f"device_topic must end with /config: {self.device_topic!r}")
if not self.broker_host:
raise ValueError("broker_host is not set")
if not 1 <= self.broker_port <= 65535:
raise ValueError(f"broker_port is out of range: {self.broker_port}")
if not self.mqtt_username:
raise ValueError("mqtt_username is not set (it is the topic token)")
# ---------------------------------------------------------------------
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "Settings":
"""Build settings from a parsed JSON object, ignoring unknown keys."""
known = {f for f in cls.__dataclass_fields__} # type: ignore[attr-defined]
data = {k: v for k, v in raw.items() if k in known}
settings = cls(**data)
settings.broker_port = int(settings.broker_port)
return settings
@classmethod
def load(cls, path: str | os.PathLike[str] | None = None) -> "Settings":
"""Read the config file and validate it."""
target = Path(path) if path else default_config_path()
with open(target, "r", encoding="utf-8") as handle:
raw = json.load(handle)
if not isinstance(raw, dict):
raise ValueError(f"{target}: expected a JSON object")
settings = cls.from_dict(raw)
settings.validate()
return settings

View File

@@ -0,0 +1,155 @@
"""Daily usage roll-up and its local store (AGENTS.md FR-7).
The client publishes ``{"day":...,"apps":[{"name":...,"seconds":N}]}`` where the
seconds are **totals for the day**, not deltas: the Control Service replaces the
row for (device, day, app) on every report (``internal/store/migrations/002``).
Windows has no per-app foreground clock like Android's Usage Access, so the
client builds its own total: every sample, the elapsed seconds are added to each
app that currently owns a visible, titled top-level window. That total is kept
on disk so a restart never loses the day, and it is republished when the link
returns (FR-14).
**Domains are not attributed.** Windows gives no cheap per-process DNS answer —
it needs a packet capture, an ETW consumer or the DNS Client event log, all of
which are either a second dependency or an administrator-only channel. The
client therefore publishes ``"domains": []`` and the gap is recorded in
``windows/README.md`` rather than filled with a guess.
"""
from __future__ import annotations
import json
import logging
import os
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable, Mapping
log = logging.getLogger("webrestrict.telemetry")
# Keep two weeks of daily totals on the device, matching the Android client.
MAX_DAYS = 14
# A sample longer than this is treated as this long, so a laptop that was asleep
# does not credit one app with hours of "use".
MAX_SAMPLE_SECONDS = 120.0
def day_key(moment: datetime | None = None) -> str:
"""The local ``YYYY-MM-DD`` the usage belongs to.
An aware timestamp is converted to local time first, so a day boundary is the
child's midnight rather than UTC's.
"""
current = moment or datetime.now()
if current.tzinfo is not None:
current = current.astimezone()
return current.strftime("%Y-%m-%d")
def to_entries(apps: Mapping[str, int]) -> list[dict[str, object]]:
"""The list form the telemetry topic accepts, sorted and positive-only."""
return [
{"name": name, "seconds": int(seconds)}
for name, seconds in sorted(apps.items())
if name and int(seconds) > 0
]
def build_payload(day: str, apps: Mapping[str, int]) -> dict[str, object]:
"""The Phase 3 telemetry payload (``internal/mqtt/payload.go``)."""
return {"day": day, "apps": to_entries(apps), "domains": []}
@dataclass
class TelemetryStore:
"""day -> app -> seconds, kept on disk so a restart or outage loses nothing."""
path: Path
days: dict[str, dict[str, int]] = field(default_factory=dict)
loaded: bool = False
def load(self) -> "TelemetryStore":
if self.loaded:
return self
self.loaded = True
try:
if not self.path.exists():
return self
with open(self.path, "r", encoding="utf-8") as handle:
raw = json.load(handle)
days = raw.get("days") if isinstance(raw, dict) else None
if isinstance(days, dict):
self.days = {
str(day): {str(name): int(seconds) for name, seconds in row.items()}
for day, row in days.items()
if isinstance(row, dict)
}
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
log.warning("telemetry store is unreadable, starting empty: %s", exc)
self.days = {}
return self
def save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
temp = self.path.with_suffix(self.path.suffix + ".tmp")
with open(temp, "w", encoding="utf-8") as handle:
json.dump({"days": self.days}, handle, sort_keys=True)
os.replace(temp, self.path)
def day(self, day: str) -> dict[str, int]:
self.load()
return dict(self.days.get(day, {}))
def add_seconds(self, day: str, apps: Mapping[str, int]) -> dict[str, int]:
"""Add a sample to a day and return that day's new totals."""
self.load()
row = self.days.setdefault(day, {})
for name, seconds in apps.items():
if not name or seconds <= 0:
continue
row[name] = row.get(name, 0) + int(seconds)
self.prune()
self.save()
return dict(row)
def prune(self) -> None:
"""Keep only the [MAX_DAYS] most recent days."""
if len(self.days) <= MAX_DAYS:
return
for old in sorted(self.days)[:-MAX_DAYS]:
self.days.pop(old, None)
class UsageSampler:
"""Turns "these apps had a window" ticks into per-app daily seconds."""
def __init__(self, store: TelemetryStore):
self.store = store
self.last_at: datetime | None = None
def sample(
self,
apps: Iterable[str],
now: datetime | None = None,
day: str | None = None,
) -> dict[str, int]:
"""Credit the elapsed time to each app in the sample.
Returns the day's totals. The first call only starts the clock: there is
no way to know what ran before the client started.
"""
moment = now or datetime.now(timezone.utc)
self.last_at = self.last_at or moment
elapsed = (moment - self.last_at).total_seconds()
self.last_at = moment
if elapsed <= 0:
return self.store.day(day or day_key(moment))
elapsed = min(elapsed, MAX_SAMPLE_SECONDS)
wanted = sorted({name for name in apps if name})
if not wanted:
return self.store.day(day or day_key(moment))
return self.store.add_seconds(day or day_key(moment), {name: int(elapsed) for name in wanted})