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:
231
windows/README.md
Normal file
231
windows/README.md
Normal 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).
|
||||
Reference in New Issue
Block a user