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.
269 lines
9.7 KiB
Python
269 lines
9.7 KiB
Python
"""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
|