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:
268
windows/webrestrict/config.py
Normal file
268
windows/webrestrict/config.py
Normal 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
|
||||
Reference in New Issue
Block a user