Files
internet_use_control/windows/webrestrict/telemetry.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

156 lines
5.6 KiB
Python

"""Daily usage roll-up and its local store (AGENTS.md FR-7).
The client publishes ``{"day":...,"apps":[{"name":...,"seconds":N}]}`` where the
seconds are **totals for the day**, not deltas: the Control Service replaces the
row for (device, day, app) on every report (``internal/store/migrations/002``).
Windows has no per-app foreground clock like Android's Usage Access, so the
client builds its own total: every sample, the elapsed seconds are added to each
app that currently owns a visible, titled top-level window. That total is kept
on disk so a restart never loses the day, and it is republished when the link
returns (FR-14).
**Domains are not attributed.** Windows gives no cheap per-process DNS answer —
it needs a packet capture, an ETW consumer or the DNS Client event log, all of
which are either a second dependency or an administrator-only channel. The
client therefore publishes ``"domains": []`` and the gap is recorded in
``windows/README.md`` rather than filled with a guess.
"""
from __future__ import annotations
import json
import logging
import os
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable, Mapping
log = logging.getLogger("webrestrict.telemetry")
# Keep two weeks of daily totals on the device, matching the Android client.
MAX_DAYS = 14
# A sample longer than this is treated as this long, so a laptop that was asleep
# does not credit one app with hours of "use".
MAX_SAMPLE_SECONDS = 120.0
def day_key(moment: datetime | None = None) -> str:
"""The local ``YYYY-MM-DD`` the usage belongs to.
An aware timestamp is converted to local time first, so a day boundary is the
child's midnight rather than UTC's.
"""
current = moment or datetime.now()
if current.tzinfo is not None:
current = current.astimezone()
return current.strftime("%Y-%m-%d")
def to_entries(apps: Mapping[str, int]) -> list[dict[str, object]]:
"""The list form the telemetry topic accepts, sorted and positive-only."""
return [
{"name": name, "seconds": int(seconds)}
for name, seconds in sorted(apps.items())
if name and int(seconds) > 0
]
def build_payload(day: str, apps: Mapping[str, int]) -> dict[str, object]:
"""The Phase 3 telemetry payload (``internal/mqtt/payload.go``)."""
return {"day": day, "apps": to_entries(apps), "domains": []}
@dataclass
class TelemetryStore:
"""day -> app -> seconds, kept on disk so a restart or outage loses nothing."""
path: Path
days: dict[str, dict[str, int]] = field(default_factory=dict)
loaded: bool = False
def load(self) -> "TelemetryStore":
if self.loaded:
return self
self.loaded = True
try:
if not self.path.exists():
return self
with open(self.path, "r", encoding="utf-8") as handle:
raw = json.load(handle)
days = raw.get("days") if isinstance(raw, dict) else None
if isinstance(days, dict):
self.days = {
str(day): {str(name): int(seconds) for name, seconds in row.items()}
for day, row in days.items()
if isinstance(row, dict)
}
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
log.warning("telemetry store is unreadable, starting empty: %s", exc)
self.days = {}
return self
def save(self) -> None:
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:
json.dump({"days": self.days}, handle, sort_keys=True)
os.replace(temp, self.path)
def day(self, day: str) -> dict[str, int]:
self.load()
return dict(self.days.get(day, {}))
def add_seconds(self, day: str, apps: Mapping[str, int]) -> dict[str, int]:
"""Add a sample to a day and return that day's new totals."""
self.load()
row = self.days.setdefault(day, {})
for name, seconds in apps.items():
if not name or seconds <= 0:
continue
row[name] = row.get(name, 0) + int(seconds)
self.prune()
self.save()
return dict(row)
def prune(self) -> None:
"""Keep only the [MAX_DAYS] most recent days."""
if len(self.days) <= MAX_DAYS:
return
for old in sorted(self.days)[:-MAX_DAYS]:
self.days.pop(old, None)
class UsageSampler:
"""Turns "these apps had a window" ticks into per-app daily seconds."""
def __init__(self, store: TelemetryStore):
self.store = store
self.last_at: datetime | None = None
def sample(
self,
apps: Iterable[str],
now: datetime | None = None,
day: str | None = None,
) -> dict[str, int]:
"""Credit the elapsed time to each app in the sample.
Returns the day's totals. The first call only starts the clock: there is
no way to know what ran before the client started.
"""
moment = now or datetime.now(timezone.utc)
self.last_at = self.last_at or moment
elapsed = (moment - self.last_at).total_seconds()
self.last_at = moment
if elapsed <= 0:
return self.store.day(day or day_key(moment))
elapsed = min(elapsed, MAX_SAMPLE_SECONDS)
wanted = sorted({name for name in apps if name})
if not wanted:
return self.store.day(day or day_key(moment))
return self.store.add_seconds(day or day_key(moment), {name: int(elapsed) for name in wanted})