Files
internet_use_control/windows/webrestrict/settings.py
Sam Rolfe 1275b1baaa 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.
2026-10-14 15:00:22 +11:00

179 lines
6.3 KiB
Python

"""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