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:
317
windows/webrestrict/agent.py
Normal file
317
windows/webrestrict/agent.py
Normal 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],
|
||||
}
|
||||
Reference in New Issue
Block a user