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:
155
windows/webrestrict/telemetry.py
Normal file
155
windows/webrestrict/telemetry.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""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})
|
||||
Reference in New Issue
Block a user