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.
232 lines
10 KiB
Markdown
232 lines
10 KiB
Markdown
# 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).
|