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:
2026-10-14 15:00:22 +11:00
parent eb300b6701
commit 1275b1baaa
22 changed files with 2799 additions and 13 deletions

199
windows/EVIDENCE.md Normal file
View File

@@ -0,0 +1,199 @@
# Phase 4 evidence — Windows client on the real machine
**Date:** 2026-10-14 (Australia/Sydney, UTC+11)
**Machine:** `DESKTOP-TE2RHV2` (`192.168.20.31`), account `New Comp` (local administrator, task
runs with a limited interactive token)
**Client version:** `webrestrict-windows 1.0.0-phase4` (`windows/`)
**Device:** `Finn's computer`, Control Service device id 2, topic `restrict/finn_s_computer_2/config`
**Broker:** Mosquitto on `.13:8883`, TLS, account `finn_s_computer_2` (topic ACL, `%u`)
**Control Service:** `.13:8791`, dashboard `https://webrestrict.lab.audasmedia.com.au`
The Definition of Done (AGENTS.md §11 Phase 4):
1. a real Windows machine blocks a listed process during a scheduled window;
2. the client keeps its last state when the broker is unavailable.
Both are shown below from the machine's own log. The client was installed as a Scheduled Task
and started through that task, so the evidence is produced by the deployment, not by a
hand-run interpreter.
---
## 1. The configuration that caused the block
Set through the dashboard (device 2): blocklist entry `notepad.exe`, and a temporary weekly
window for Wednesday 14:00–23:59. The Control Service published the retained config, read back
from the broker with the device's own account:
```json
{"v":1,"device_id":2,"name":"Finn's computer","platform":"windows","locked":true,
"until":"2026-10-14T23:59:00+11:00","allow":[],"block":["notepad.exe"],
"reason":"schedule","generated_at":"2026-10-14T03:49:31.401827999Z"}
```
## 2. A listed process is killed inside the scheduled window
Before the client starts, the process is running on the interactive desktop:
```
Image Name PID Session Name Session# Mem Usage
========================= ======== ================ =========== ============
notepad.exe 16600 Console 2 13,396 K
INFO: No tasks are running which match the specified criteria. <- pythonw.exe: client not running
```
The Scheduled Task is started (`schtasks /Run /TN "WebRestrict Kids Client"`). Machine log
`C:\ProgramData\WebRestrict\state\client.log`:
```
2026-10-14 14:59:46,301 INFO webrestrict no cached config yet; seeded an unlocked config (FR-14)
2026-10-14 14:59:46,301 INFO webrestrict startup config locked=False until=None block=0 allow=0 cached_at=None
2026-10-14 14:59:46,414 INFO webrestrict.overlay warning window is available
2026-10-14 14:59:46,498 INFO webrestrict.mqtt mqtt connecting to 192.168.20.13:8883 as finn_s_computer_2 (topic restrict/finn_s_computer_2/config)
2026-10-14 14:59:46,500 INFO webrestrict client running: poll=5s telemetry=30s status=30s device=finn_s_computer_2 state_dir=C:\ProgramData\WebRestrict\state
2026-10-14 14:59:47,131 INFO webrestrict.mqtt mqtt connected, subscribing to restrict/finn_s_computer_2/config
2026-10-14 14:59:47,131 INFO webrestrict link up: publishing presence and today's usage
2026-10-14 14:59:47,140 INFO webrestrict config applied locked=True until=2026-10-14T23:59:00+11:00 reason=schedule block=['notepad.exe'] allow=[] generated_at=2026-10-14T03:49:31.401827999Z
2026-10-14 14:59:47,169 INFO webrestrict blocked notepad.exe (pid=16600, rule=notepad): terminated
2026-10-14 14:59:47,169 INFO webrestrict enforcement killed 1 process(es): notepad.exe(16600)
```
After, on the same machine:
```
INFO: No tasks are running which match the specified criteria. <- notepad.exe, gone
Image Name PID Session Name Session# Mem Usage
pythonw.exe 12608 Console 2 47,904 K
```
The cached config on disk, which is what a broker-less restart uses:
```json
{"allow": [], "block": ["notepad.exe"], "device_id": 2,
"generated_at": "2026-10-14T03:49:31.401827999Z", "grant_until": null, "locked": true,
"name": "Finn's computer", "platform": "windows", "reason": "schedule",
"until": "2026-10-14T23:59:00+11:00", "v": 1}
```
## 3. The dashboard shows the device online with the right state
`GET /api/v1/admin/devices/2` on `.13:8791` while the client was running:
```json
{"ok":true,"device":{"id":2,"name":"Finn's computer","child":"Finn","platform":"windows",
"topic":"restrict/finn_s_computer_2/config","online":true,"last_seen":"2026-10-14T04:01:17Z",
"locked":true,"until":"2026-10-14T23:59:00+11:00","grant_until":null,"reason":"schedule"}}
```
`online: true` comes from the client's retained status report; `locked`/`until`/`reason` are the
schedule that is in force. The machine log shows the presence heartbeat:
```
2026-10-14 15:00:17,228 INFO webrestrict status published locked=True blocking=True broker=connected cached_config=True
2026-10-14 15:00:47,323 INFO webrestrict telemetry day=2026-10-14 apps=10 top=[{"name": "ApplicationFrameHost.exe", "seconds": 30}, ...]
2026-10-14 15:01:17,417 INFO webrestrict telemetry day=2026-10-14 apps=10 top=[{"name": "ApplicationFrameHost.exe", "seconds": 60}, ...]
```
## 4. A telemetry row lands in the Control Service
Control Service, same day (`docker exec webrestrict-control /app/control usage`):
```
day=2026-10-14 source=rollup
Harry's phone Harry app=5466 domain=4920 screen=5466
Finn's computer Finn app=690 domain=0 screen=690
total_screen=6156
```
`app=690` is `Finn's computer`'s daily total in seconds, from the client's
`restrict/finn_s_computer_2/telemetry` reports. `domain=0` is the documented gap: Windows has no
cheap per-process DNS attribution, so the client sends `"domains": []` instead of a guess (see
`README.md`).
The on-device roll-up shows the attribution, and confirms the client no longer credits itself
(the overlay's own window is excluded):
```json
{"days": {"2026-10-14": {"ApplicationFrameHost.exe": 60, "Discord.exe": 60, "EADesktop.exe": 60,
"Overwolf.exe": 60, "Spotify.exe": 60, "TextInputHost.exe": 60, "chrome.exe": 60, "cmd.exe": 60,
"explorer.exe": 60, "powershell.exe": 60}}}
```
## 5. Keep last state when the broker is unavailable (FR-14)
The client was stopped, then started again with a copy of the config whose broker port points at
a dead listener (`192.168.20.13:8884`) and the **same** state directory, so it loads the cached
config. A new `notepad.exe` was started afterwards. Machine log:
```
2026-10-14 15:01:41,347 INFO webrestrict startup config locked=True until=2026-10-14T23:59:00+11:00 block=1 allow=0 cached_at=2026-10-14T03:49:31.401827999Z
2026-10-14 15:01:41,539 INFO webrestrict.mqtt mqtt connecting to 192.168.20.13:8884 as finn_s_computer_2 (topic restrict/finn_s_computer_2/config)
2026-10-14 15:01:47,045 WARNING webrestrict.mqtt broker 192.168.20.13:8884 is unreachable; enforcing the cached config only (FR-14)
2026-10-14 15:01:47,045 WARNING webrestrict broker link lost (broker unreachable): enforcing the cached config (FR-14) block=['notepad.exe'] until=2026-10-14T23:59:00+11:00
2026-10-14 15:01:52,207 INFO webrestrict blocked notepad.exe (pid=9724, rule=notepad): terminated
```
`notepad.exe` (PID 9724) was running in session 2 immediately before that line, and
`tasklist /FI "IMAGENAME eq notepad.exe"` reported no matching task afterwards. The 8884 listener
does not exist, so nothing was received from the broker during that run.
The same resilience covers usage: on the next connected start the client republished the day's
totals held on disk (`telemetry republished day=2026-10-14 apps=10`).
## 6. The warning window (FR-9)
The client runs in the child's session (`pythonw.exe`, session 2) and shows an always-on-top
Tkinter window. A window enumeration from the same session, while a block was in force:
```json
{"hwnd": 3278938, "visible": true, "titlelen": 11, "title": "Screen time", "pid": 9908,
"class": "TkTopLevel", "proc": "pythonw.exe"}
```
Screenshot of the interactive desktop taken by a helper task in the same session:
`screen-offline-run.png` (attached to AUD-97). The bottom-right region of the image is a large
light-grey panel over a dark desktop — the overlay, placed bottom-right of the primary screen.
## 7. The Scheduled Task
```
Folder: \
HostName: DESKTOP-TE2RHV2
TaskName: \WebRestrict Kids Client
Status: Ready
Logon Mode: Interactive only
Run As User: New Comp
Task To Run: C:\Users\New Comp\AppData\Local\Programs\Python\Python314\pythonw.exe -m webrestrict --config "C:\ProgramData\WebRestrict\client.json"
Start In: C:\ProgramData\WebRestrict\client
Schedule Type: At logon time
```
Installed by `install-scheduled-task.ps1` (which also grants the account Modify on the state
directory), or by the one-line `schtasks /Create /TN "WebRestrict Kids Client" /SC ONLOGON /F ...`
form in `README.md`.
Every step above was produced by starting this task (`schtasks /Run`), not by a shell.
## 8. What was not proven, and the honest gaps
* **Domains in telemetry.** Not implemented on Windows, by design and by decision. The client
publishes `"domains": []`; there is no FR-18 site filter for Windows in this phase.
* **Blocking another account's processes.** The task runs with a limited (non-elevated) token, so
it blocks the child's own processes. A process owned by a different Windows account would need
the task to run at highest privilege.
* **Tamper detection is presence, not a watchdog.** A force-stop or a logoff drives the broker's
Last Will to `{"online": false}`, which the Control Service turns into the offline alert
(FR-13). There is no separate on-device watchdog process; a determined child can end
`pythonw.exe` and is visible as an offline device seconds later.
* **A process started mid-window is killed on the next tick** (≤ `poll_seconds`, 5 s by default).
## 9. End state left on the machine
* `WebRestrict Kids Client` is registered (at logon, `New Comp`, interactive) and running.
* The temporary proof window (Wednesday 14:00–23:59) was **cleared**; no invented schedule is in
force. `notepad.exe` stays on the blocklist, so the rule is ready for the next window — it does
nothing while the device is unlocked.
* Proof scaffolding (helper tasks, helper scripts, the offline config copy, screenshots) was
removed from the machine. Only `C:\ProgramData\WebRestrict\{client,client.json,ca.crt,state}`
remains.
* Final device state: `online: true`, `locked: false`, `reason: ""`.