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.
318 lines
12 KiB
Python
318 lines
12 KiB
Python
"""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],
|
|
}
|