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

231
windows/README.md Normal file
View File

@@ -0,0 +1,231 @@
# Kids' Windows computer client — Phase 4
The kids' **Windows** client (AGENTS.md §11 Phase 4, FR-3, FR-4, FR-6, FR-7,
FR-9, FR-14). Python, one process, no service install and no admin requirement
beyond what the account already has. It follows the Control Service's desired
state over MQTT, blocks listed processes while the device is locked, warns the
child, reports usage, and **keeps its last state when the broker is
unreachable**.
It is a device, not a control plane: it never changes its own rules (AGENTS.md
§10 rule 7). Everything it enforces arrives on `restrict/<device>/config`.
## What it does
| Piece | File | Job |
|---|---|---|
| Config transport | `webrestrict/mqtt_link.py` | Subscribes, retained, to `restrict/<device>/config` on the 8883 TLS plane with the device's own credentials; publishes presence and usage on `restrict/<device>/status` and `restrict/<device>/telemetry`. |
| Desired state | `webrestrict/config.py` | Parses the Phase 3 payload and computes the effective lock (`locked` and the clock). |
| Last state (FR-14) | `webrestrict/config.py` | The last valid config is cached on disk; a broker outage changes nothing, and only a newer valid config replaces it. |
| Block rule | `webrestrict/blocker.py` | Blocked = effectively locked **and** on the blocklist **and** not allowlisted (FR-4). |
| Process blocking (FR-3) | `webrestrict/process_watch.py` | Polls `psutil`, `terminate()`s a blocked process, escalates to `kill()` after a short grace. |
| Warning (FR-9) | `webrestrict/overlay.py` | An always-on-top Tkinter window: what is blocked, why, until when. |
| Usage (FR-7) | `webrestrict/telemetry.py` | Daily per-app seconds, rolled up on disk and published as day totals. |
| The loop | `webrestrict/agent.py` | Wires the pieces together with bounded, isolated ticks. |
| Install | `install-scheduled-task.ps1` | The logon Scheduled Task. |
**No service, no driver, no filter at the network edge.** The child can end the
process; that is accepted (see "Honest limits").
## How the block works
Every `poll_seconds` (default 5) the enforcer takes a process snapshot and
selects victims:
1. the cached config is **not** effectively locked → nothing is blocked;
2. a process is a victim when its normalised name is on the blocklist;
3. the **allowlist always wins** (FR-4) — `spotify.exe` stays up while everything
else is blocked;
4. the client never kills itself, its parent chain, or a protected system
process (`explorer.exe`, `lsass.exe`, `svchost.exe`, …), whatever a blocklist
says. The full default list is `DEFAULT_PROTECTED` in
`webrestrict/settings.py`.
Name matching is case-insensitive and ignores `.exe`, so `Notepad.EXE`,
`notepad.exe` and `notepad` are the same app.
"Effectively locked" mirrors `internal/store/schedules.go` `EffectiveState`: the
payload already carries the resolved `locked` flag, so the client only applies
the clock. An expired `until` lifts the block at the same instant the Control
Service would clear it, a live `grant_until` never blocks (FR-10 grant), and an
indefinite lock (`until` absent) never lifts.
## Keep last state (FR-14)
The config is cached at `<state_dir>/config-cache.json`, written atomically. The
client loads it **before** it tries to connect, so a device that never reaches
the broker still applies the rules it was last given. A malformed payload, a
newer schema version, or an older `generated_at` is rejected and the cache is
left alone.
## Presence (FR-6)
The client publishes a retained `{"online": true}` status when it connects and
registers the will `{"online": false}` on the same topic. If the machine is
switched off, the client is force-stopped, or the link dies, the broker
publishes the will and the Control Service marks the device offline — that is
the Windows tamper/offline signal (FR-17 here is "the client is gone", which
`CONTROL_ALERT_OFFLINE_AFTER` turns into an Apprise alert, FR-13). A clean stop
publishes the offline status explicitly.
The status payload also carries `locked` and the foreground `app`, so the
dashboard shows real state:
```json
{"app":"notepad.exe","at":"2026-10-14T04:05:06Z","locked":true,"online":true}
```
## Usage telemetry (FR-7), and the domain gap
Windows has no `UsageStatsManager`. The client builds its own daily total: every
`telemetry_seconds` (default 60) it lists the processes that own a **visible,
titled top-level window** and adds the elapsed seconds to each. The roll-up is
persisted per day (14 days kept) and published as **day totals**, so a repeated
report replaces the Control Service row instead of double counting
(`internal/store/migrations/002_telemetry_unique.sql`):
```json
{"day":"2026-10-14","apps":[{"name":"notepad.exe","seconds":420}],"domains":[]}
```
* It counts "an app had a window on screen", sampled every minute, not Android's
exact foreground clock. It is an approximation, and it is the honest one
available without a second dependency. The client's own process is excluded,
so its warning window is never counted as the child's usage.
* **Domains are always `[]`.** Windows gives no cheap per-process DNS
attribution: it needs a packet capture, an ETW consumer, or the DNS Client
event log — a second dependency or an administrator-only channel. The gap is
recorded here rather than filled with a guess. Site filtering for Windows is
therefore **not** implemented in this phase; FR-18 is satisfied on the phone
only (`android/`). Say so in any status report.
## Safety limits
* Never kills a protected system process, the current process tree, or anything
on the allowlist.
* A missing or corrupt config never invents a lock: first run seeds an
**unlocked** config, so a fresh install cannot lock a child out of their
machine while the broker is unreachable.
* Every tick is wrapped: a psutil or Tk failure is logged and the loop
continues.
## Honest limits
* The child can end the process (`taskkill /im pythonw.exe`). The enforcement is
the tamper/offline signal and the parent's knowledge, not a fortress
(AGENTS.md §4 FR-17 rationale, TRANSPORT_DESIGN.md "Anti-bypass").
* A process started **after** a tick is killed on the next tick; the window is
`poll_seconds` (5 s by default).
* The overlay is a warning only. Closing it does not lift the block.
* Running the task as a limited (non-elevated) user blocks the child's own
processes, which is the normal case. Blocking a process owned by **another**
Windows account needs the task to run at highest privilege.
## Provisioning a machine
Nothing secret is committed. The config file holds the device's MQTT password.
1. **Register the device** in the Control Service (dashboard or admin API) with
platform `windows`, then read its topic token:
```bash
docker exec webrestrict-control /app/control mqtt-clients
```
2. **Create its broker account** on the 8883 plane and read the password from
the generated file (both gitignored):
```bash
deploy/mosquitto/provision-clients.sh
grep -P '^finn_s_computer_2\t' deploy/mosquitto/clients.env
```
3. **Copy the CA and the client** to the machine. `C:\ProgramData\WebRestrict`
is machine-wide and readable by the kid's account:
```powershell
New-Item -ItemType Directory -Force C:\ProgramData\WebRestrict\client
# copy windows\webrestrict\* to C:\ProgramData\WebRestrict\client\webrestrict\
# copy deploy/mosquitto/tls/ca.crt to C:\ProgramData\WebRestrict\ca.crt
python -m pip install --user -r requirements.txt
```
4. **Write `C:\ProgramData\WebRestrict\client.json`** (mode: readable by the kid's
account only) — `{ ` `}` replaced by the real values:
```json
{
"broker_host": "192.168.20.13",
"broker_port": 8883,
"ca_file": "C:\\ProgramData\\WebRestrict\\ca.crt",
"device_topic": "restrict/finn_s_computer_2/config",
"mqtt_username": "finn_s_computer_2",
"mqtt_password": "<from clients.env>",
"state_dir": "C:\\ProgramData\\WebRestrict\\state"
}
```
5. **Install the Scheduled Task** so the client survives a reboot. The script
also grants the account Modify on the state directory — a file created from
an elevated shell inherits only `Administrators`, and the task's limited
interactive token would otherwise get `Access denied`:
```powershell
powershell -ExecutionPolicy Bypass -File C:\ProgramData\WebRestrict\client\install-scheduled-task.ps1 `
-ClientDir "C:\ProgramData\WebRestrict\client" `
-Config "C:\ProgramData\WebRestrict\client.json"
```
Or, the one-line form:
```
schtasks /Create /TN "WebRestrict Kids Client" /SC ONLOGON /F ^
/TR "\"C:\Users\New Comp\AppData\Local\Programs\Python\Python314\pythonw.exe\" -m webrestrict --config \"C:\ProgramData\WebRestrict\client.json\""
```
Both register the task for the account that runs them, with an interactive
logon token and no stored password, and both start the client at logon.
6. **Check it without waiting**:
```
python -m webrestrict check
```
That prints the cached config, whether it is effectively locked, and the
processes it would terminate right now. It changes nothing.
## Running and reading the log
```
python -m webrestrict --config C:\ProgramData\WebRestrict\client.json # the client
python -m webrestrict check # one-shot state
python -m webrestrict version
```
The client logs to the console and to `<state_dir>/client.log` (2 MB, 3
rotations). It records every config it applied and every process it blocked,
which is the evidence trail for a parent and for a phase check.
## Tests
Pure logic only — no broker, no Windows, no psutil:
```bash
cd windows && python -m unittest discover -s tests -t .
# or, from the repository root:
make windows-test
```
53 tests cover the payload shape, the effective lock, name matching, the
allowlist, the keep-last-state cache, victim selection (including the protected
names), the usage roll-up, and the status and telemetry payloads.
## Evidence from the live run
See `windows/EVIDENCE.md` for the transcript of the Definition of Done on the
real machine (`DESKTOP-TE2RHV2`): a listed process killed inside a scheduled
window, the dashboard showing the device online, a telemetry row landing in the
Control Service, enforcement continuing with the broker unreachable, the warning
window on screen, and the Scheduled Task registered at logon. It also records
the honest gaps (no domain attribution, no elevated blocking, tamper = offline).