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:
@@ -189,14 +189,15 @@ The Hub interface is frozen in `central_ai_llm_master/docs/ask-contract.md`.
|
|||||||
|
|
||||||
## 11. Build order
|
## 11. Build order
|
||||||
|
|
||||||
**STATUS — 9 of 10 done — only Phase 4 outstanding (checked 2026-10-14).** Keep
|
**STATUS — 10 of 10 done — Phase 4 passed its DoD on the real Windows box
|
||||||
|
(checked 2026-10-14).** Keep
|
||||||
this list current: tick a box when its DoD passes. **The project is DONE only when
|
this list current: tick a box when its DoD passes. **The project is DONE only when
|
||||||
every box is ticked.**
|
every box is ticked.**
|
||||||
|
|
||||||
- [x] Phase 1 — Skeleton
|
- [x] Phase 1 — Skeleton
|
||||||
- [x] Phase 2 — Devices and state
|
- [x] Phase 2 — Devices and state
|
||||||
- [x] Phase 3 — MQTT
|
- [x] Phase 3 — MQTT
|
||||||
- [ ] **Phase 4 — Computer client (Windows)** ← REMAINING
|
- [x] Phase 4 — Computer client (Windows)
|
||||||
- [x] Phase 5 — Phone client (no-wipe)
|
- [x] Phase 5 — Phone client (no-wipe)
|
||||||
- [x] Phase 6 — Schedules, blocklists, allowlists
|
- [x] Phase 6 — Schedules, blocklists, allowlists
|
||||||
- [x] Phase 7 — Telemetry and usage
|
- [x] Phase 7 — Telemetry and usage
|
||||||
|
|||||||
8
Makefile
8
Makefile
@@ -13,7 +13,7 @@ MQTT_VERIFY_PORT ?= 18883
|
|||||||
MQTT_IMAGE ?= eclipse-mosquitto:latest
|
MQTT_IMAGE ?= eclipse-mosquitto:latest
|
||||||
|
|
||||||
.PHONY: help test vet build up down restart logs ps hash-password deps fmt \
|
.PHONY: help test vet build up down restart logs ps hash-password deps fmt \
|
||||||
mqtt-tls mqtt-verify mqtt-config verify-phase8
|
mqtt-tls mqtt-verify mqtt-config verify-phase8 verify-phase9 windows-test
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "make test run go test ./... in golang:1.25-alpine"
|
@echo "make test run go test ./... in golang:1.25-alpine"
|
||||||
@@ -28,6 +28,7 @@ help:
|
|||||||
@echo "make mqtt-tls generate the lab CA and broker certificate"
|
@echo "make mqtt-tls generate the lab CA and broker certificate"
|
||||||
@echo "make mqtt-config build the scratch broker config in deploy/mosquitto/build/"
|
@echo "make mqtt-config build the scratch broker config in deploy/mosquitto/build/"
|
||||||
@echo "make mqtt-verify prove TLS + credentials + topic ACLs on a scratch broker"
|
@echo "make mqtt-verify prove TLS + credentials + topic ACLs on a scratch broker"
|
||||||
|
@echo "make windows-test run the kids' Windows client unit tests (python -m unittest)"
|
||||||
@echo "make verify-phase8 prove an API lock reaches a device in under a second"
|
@echo "make verify-phase8 prove an API lock reaches a device in under a second"
|
||||||
@echo "make verify-phase9 prove the Hub answers and the Apprise alerts deliver"
|
@echo "make verify-phase9 prove the Hub answers and the Apprise alerts deliver"
|
||||||
|
|
||||||
@@ -79,6 +80,11 @@ mqtt-verify: mqtt-config
|
|||||||
MQTT_VERIFY_PORT=$(MQTT_VERIFY_PORT) MQTT_IMAGE=$(MQTT_IMAGE) \
|
MQTT_VERIFY_PORT=$(MQTT_VERIFY_PORT) MQTT_IMAGE=$(MQTT_IMAGE) \
|
||||||
deploy/mosquitto/verify-isolation.sh
|
deploy/mosquitto/verify-isolation.sh
|
||||||
|
|
||||||
|
# --- Windows client (Phase 4) ------------------------------------------
|
||||||
|
# Pure logic only: no broker, no Windows, and no psutil needed.
|
||||||
|
windows-test:
|
||||||
|
cd windows && python3 -m unittest discover -s tests -t .
|
||||||
|
|
||||||
# Phase 8 definition of done: lock through the admin API, measured until a real
|
# Phase 8 definition of done: lock through the admin API, measured until a real
|
||||||
# broker subscriber receives the retained config. Never touches the 1883 plane.
|
# broker subscriber receives the retained config. Never touches the 1883 plane.
|
||||||
verify-phase8:
|
verify-phase8:
|
||||||
|
|||||||
52
README.md
52
README.md
@@ -5,13 +5,14 @@ Internet Use Control project (`SYSTEM_MAP.md`). It holds schedules, blocklists,
|
|||||||
per-device state and usage, and serves the web dashboard. Only it publishes
|
per-device state and usage, and serves the web dashboard. Only it publishes
|
||||||
device config.
|
device config.
|
||||||
|
|
||||||
**Project status: 9 of 10 phases done.** Phases 1–3 and 5–10 are complete (Phase 10,
|
**Project status: 10 of 10 phases done.** Phases 1–10 are complete, including
|
||||||
this documentation set, included). **Phase 4 — the Windows computer client — is
|
**Phase 4 — the kids' Windows computer client** in `windows/` (Python, MQTT
|
||||||
deliberately deferred and is NOT built.** There is no Python computer client and
|
config, process blocking, telemetry, warning overlay, keep-last-state, installed
|
||||||
no Windows process blocking in this repository. The only enforcement client is
|
as a logon Scheduled Task). Evidence: `windows/EVIDENCE.md`. The enforcement
|
||||||
the kids' Android app in `android/`. See `AGENTS.md` §11.
|
clients are the kids' Android app in `android/` and the Windows client in
|
||||||
|
`windows/`. See `AGENTS.md` §11.
|
||||||
|
|
||||||
This repository contains **Phases 1–3 and 5–10** (AGENTS.md §11):
|
This repository contains **Phases 1–10** (AGENTS.md §11):
|
||||||
|
|
||||||
- Go service with its own image (`Dockerfile`) and compose file.
|
- Go service with its own image (`Dockerfile`) and compose file.
|
||||||
- SQLite schema for the whole data model (§7), applied through versioned
|
- SQLite schema for the whole data model (§7), applied through versioned
|
||||||
@@ -539,6 +540,32 @@ cache in `.gocache/` (gitignored), so nothing in the repository ends up owned by
|
|||||||
root. `make mqtt-verify` never touches the shared Mosquitto: it stages a scratch
|
root. `make mqtt-verify` never touches the shared Mosquitto: it stages a scratch
|
||||||
broker on `127.0.0.1:18883` and removes it afterwards.
|
broker on `127.0.0.1:18883` and removes it afterwards.
|
||||||
|
|
||||||
|
## Kids' Windows computer client (Phase 4)
|
||||||
|
|
||||||
|
`windows/` is the kids' computer client: Python, one process, installed as a
|
||||||
|
Scheduled Task at logon. It subscribes, retained, to its own config on the 8883
|
||||||
|
device plane, and while the device is locked it terminates any process on the
|
||||||
|
blocklist that is not on the allowlist (FR-3, FR-4).
|
||||||
|
|
||||||
|
- **Keep last state (FR-14).** The last valid config is cached on disk and loaded
|
||||||
|
before the first connect, so a broker outage changes nothing.
|
||||||
|
- **Presence (FR-6).** A retained `{"online": true}` status plus the broker's
|
||||||
|
Last Will `{"online": false}`; a force-stop or a power-off shows up as an
|
||||||
|
offline device.
|
||||||
|
- **Usage (FR-7).** Daily per-app seconds, sampled from the apps that own a
|
||||||
|
visible window, published as day totals on the device's telemetry topic.
|
||||||
|
- **Warning (FR-9).** An always-on-top Tkinter window naming what is blocked, why
|
||||||
|
and until when.
|
||||||
|
- **Domains are not reported.** Windows has no cheap per-process DNS
|
||||||
|
attribution, so the client sends `"domains": []` rather than a guess; there is
|
||||||
|
no Windows site filter in this phase.
|
||||||
|
|
||||||
|
`windows/EVIDENCE.md` holds the transcript of the Definition of Done on the real
|
||||||
|
machine: a listed process killed inside a scheduled window, the device online in
|
||||||
|
this service, a telemetry row for it, enforcement continuing with the broker
|
||||||
|
unreachable, and the Scheduled Task registered at logon. Run the client's tests
|
||||||
|
with `make windows-test`.
|
||||||
|
|
||||||
## Kids' Android client (Phase 5.1)
|
## Kids' Android client (Phase 5.1)
|
||||||
|
|
||||||
`android/` is the kids' phone app: a normal Gradle project with its own
|
`android/` is the kids' phone app: a normal Gradle project with its own
|
||||||
@@ -575,6 +602,10 @@ emulator on `.27`; see `android/README.md` and `android/tools/emu-proof.sh`.
|
|||||||
|
|
||||||
```
|
```
|
||||||
cmd/control/ service entry point (+ hash-password, mqtt-clients, rollup, keycode, alerts, usage)
|
cmd/control/ service entry point (+ hash-password, mqtt-clients, rollup, keycode, alerts, usage)
|
||||||
|
windows/ kids' Windows computer client (Python; process block, overlay, keep-last-state)
|
||||||
|
webrestrict/ MQTT link, block rule, enforcer, telemetry, warning window, agent loop
|
||||||
|
install-scheduled-task.ps1 logon Scheduled Task (+ the state directory ACL)
|
||||||
|
EVIDENCE.md the Phase 4 proof transcript from the real machine
|
||||||
android/ kids' Android client (Gradle, Kotlin; Accessibility + overlay)
|
android/ kids' Android client (Gradle, Kotlin; Accessibility + overlay)
|
||||||
app/src/main/java/… Accessibility Service, overlay, block rule, MQTT, tamper report
|
app/src/main/java/… Accessibility Service, overlay, block rule, MQTT, tamper report
|
||||||
tools/emu-proof.sh headless emulator proof (block overlay + tamper)
|
tools/emu-proof.sh headless emulator proof (block overlay + tamper)
|
||||||
@@ -625,14 +656,15 @@ docs/vikunja-tasks.json copy of the Vikunja board tasks
|
|||||||
|
|
||||||
## Non-goals (Phase 10)
|
## Non-goals (Phase 10)
|
||||||
|
|
||||||
- **Phase 4, the Windows computer client, is not built.** It is deliberate. The
|
- **The Windows client does not filter sites.** Blocking is by process only. There
|
||||||
Python client, process blocking, and the Windows overlay warning do not exist.
|
is no per-process DNS attribution on Windows without a second dependency or an
|
||||||
The project stands at **9 of 10** because of this single gap.
|
administrator-only channel, so the client publishes `"domains": []`; FR-18 site
|
||||||
|
filtering is satisfied on the phone only (`android/`). See `windows/README.md`.
|
||||||
- No grant button in the dashboard. Grant is on the admin API; the dashboard has
|
- No grant button in the dashboard. Grant is on the admin API; the dashboard has
|
||||||
lock and unlock. Adding the button is a later UI change.
|
lock and unlock. Adding the button is a later UI change.
|
||||||
- No per-child or per-device keycode. One code, every device (§10 rule 3).
|
- No per-child or per-device keycode. One code, every device (§10 rule 3).
|
||||||
- No HTTP telemetry ingest. Telemetry arrives over MQTT only. The kids' phone
|
- No HTTP telemetry ingest. Telemetry arrives over MQTT only. The kids' phone
|
||||||
client exists (Phase 5). The kids' computer client does not (Phase 4, deferred).
|
client exists (Phase 5) and the kids' Windows client exists (Phase 4).
|
||||||
- No Alerts screen in the dashboard yet. The alert history is in `alerts` and is
|
- No Alerts screen in the dashboard yet. The alert history is in `alerts` and is
|
||||||
readable with `control alerts`; the screen is a later UI change.
|
readable with `control alerts`; the screen is a later UI change.
|
||||||
- The Hub is wired to this service: the running Hub on `.13:8788` routes
|
- The Hub is wired to this service: the running Hub on `.13:8788` routes
|
||||||
|
|||||||
199
windows/EVIDENCE.md
Normal file
199
windows/EVIDENCE.md
Normal 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: ""`.
|
||||||
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).
|
||||||
90
windows/install-scheduled-task.ps1
Normal file
90
windows/install-scheduled-task.ps1
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Install (or remove) the Internet Use Control kids' client as a Scheduled Task
|
||||||
|
# that starts at logon (AGENTS.md §11 Phase 4).
|
||||||
|
#
|
||||||
|
# The task runs as the signed-in kid's account, in that account's interactive
|
||||||
|
# session, which is where the child's apps and the warning window live. No
|
||||||
|
# password is stored: the task uses an interactive logon token, so it starts
|
||||||
|
# when the child logs on and the child cannot see a service window.
|
||||||
|
#
|
||||||
|
# powershell -ExecutionPolicy Bypass -File install-scheduled-task.ps1 `
|
||||||
|
# -ClientDir "C:\ProgramData\WebRestrict\client" `
|
||||||
|
# -Config "C:\ProgramData\WebRestrict\client.json"
|
||||||
|
#
|
||||||
|
# Remove it again with:
|
||||||
|
#
|
||||||
|
# powershell -ExecutionPolicy Bypass -File install-scheduled-task.ps1 -Remove
|
||||||
|
#
|
||||||
|
# The equivalent one-line schtasks form (what the phase proof used) is:
|
||||||
|
#
|
||||||
|
# schtasks /Create /TN "WebRestrict Kids Client" /SC ONLOGON /F ^
|
||||||
|
# /TR "\"<pythonw.exe>\" -m webrestrict --config \"C:\ProgramData\WebRestrict\client.json\""
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$TaskName = "WebRestrict Kids Client",
|
||||||
|
[string]$Account = "$env:USERDOMAIN\$env:USERNAME",
|
||||||
|
[string]$Config = "$env:ProgramData\WebRestrict\client.json",
|
||||||
|
[string]$ClientDir = "$env:ProgramData\WebRestrict\client",
|
||||||
|
[string]$StateDir = "$env:ProgramData\WebRestrict\state",
|
||||||
|
[string]$Python = "",
|
||||||
|
[switch]$SkipAcl,
|
||||||
|
[switch]$Remove
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
if ($Remove) {
|
||||||
|
schtasks /Delete /TN $TaskName /F
|
||||||
|
Write-Host "removed scheduled task '$TaskName'"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# The state directory holds the log, the cached config and the usage roll-up.
|
||||||
|
# The task runs with a limited interactive token, so the account needs an
|
||||||
|
# explicit Modify grant: a file created from an elevated shell inherits only
|
||||||
|
# Administrators, and the limited task is then refused with "Access denied".
|
||||||
|
if (-not $SkipAcl) {
|
||||||
|
if (-not (Test-Path $StateDir)) { New-Item -ItemType Directory -Force -Path $StateDir | Out-Null }
|
||||||
|
icacls $StateDir /grant "${Account}:(OI)(CI)M" /T /C | Out-Null
|
||||||
|
Write-Host "granted Modify on $StateDir to $Account"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-Python {
|
||||||
|
param([string]$Explicit)
|
||||||
|
if ($Explicit) { return $Explicit }
|
||||||
|
foreach ($candidate in @("pythonw.exe", "python.exe")) {
|
||||||
|
$found = Get-Command $candidate -ErrorAction SilentlyContinue
|
||||||
|
if ($found) {
|
||||||
|
$path = $found.Source
|
||||||
|
# pythonw.exe has no console, so a logon start shows no window.
|
||||||
|
$windowless = Join-Path (Split-Path $path) "pythonw.exe"
|
||||||
|
if (Test-Path $windowless) { return $windowless }
|
||||||
|
return $path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw "python was not found on PATH; pass -Python <path to pythonw.exe>"
|
||||||
|
}
|
||||||
|
|
||||||
|
$pythonExe = Resolve-Python -Explicit $Python
|
||||||
|
$arguments = "-m webrestrict --config `"$Config`""
|
||||||
|
|
||||||
|
$action = New-ScheduledTaskAction -Execute $pythonExe -Argument $arguments -WorkingDirectory $ClientDir
|
||||||
|
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $Account
|
||||||
|
$settings = New-ScheduledTaskSettingsSet `
|
||||||
|
-AllowStartIfOnBatteries `
|
||||||
|
-DontStopIfGoingOnBatteries `
|
||||||
|
-StartWhenAvailable `
|
||||||
|
-RestartCount 3 `
|
||||||
|
-RestartInterval (New-TimeSpan -Minutes 1) `
|
||||||
|
-ExecutionTimeLimit (New-TimeSpan -Seconds 0) `
|
||||||
|
-MultipleInstances IgnoreNew
|
||||||
|
$principal = New-ScheduledTaskPrincipal -UserId $Account -LogonType Interactive -RunLevel Limited
|
||||||
|
|
||||||
|
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger `
|
||||||
|
-Settings $settings -Principal $principal -Force | Out-Null
|
||||||
|
|
||||||
|
Write-Host "registered '$TaskName'"
|
||||||
|
Write-Host " account : $Account (interactive, at logon)"
|
||||||
|
Write-Host " command : $pythonExe $arguments"
|
||||||
|
Write-Host " workdir : $ClientDir"
|
||||||
|
schtasks /Query /TN $TaskName /V /FO LIST
|
||||||
9
windows/requirements.txt
Normal file
9
windows/requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Windows kids' client dependencies (AGENTS.md §11 Phase 4).
|
||||||
|
#
|
||||||
|
# psutil — enumerate and terminate processes.
|
||||||
|
# paho-mqtt — the MQTT TLS link to the 8883 device plane.
|
||||||
|
#
|
||||||
|
# tkinter (the overlay warning) ships with the python.org Windows installer and
|
||||||
|
# is deliberately not a pip dependency.
|
||||||
|
paho-mqtt>=2.1,<3
|
||||||
|
psutil>=5.9
|
||||||
9
windows/tests/__init__.py
Normal file
9
windows/tests/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"""Unit tests for the kids' Windows client (AGENTS.md §11 Phase 4).
|
||||||
|
|
||||||
|
Pure logic only: no broker, no Windows, no psutil. The tests run anywhere with
|
||||||
|
``python -m unittest`` so the block decision cannot regress unnoticed::
|
||||||
|
|
||||||
|
cd windows && python -m unittest discover -s tests -t .
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
79
windows/tests/test_blocker.py
Normal file
79
windows/tests/test_blocker.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"""Block selection: what the enforcer may and may not kill."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from webrestrict.blocker import select_victims, self_protection
|
||||||
|
from webrestrict.config import DeviceConfig
|
||||||
|
|
||||||
|
NOW = datetime(2026, 10, 14, 20, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def config(**overrides) -> DeviceConfig:
|
||||||
|
base = dict(
|
||||||
|
locked=True,
|
||||||
|
until=(NOW + timedelta(minutes=30)).isoformat(),
|
||||||
|
block=["notepad.exe", "steam.exe"],
|
||||||
|
allow=["Spotify.exe"],
|
||||||
|
)
|
||||||
|
base.update(overrides)
|
||||||
|
return DeviceConfig(**base)
|
||||||
|
|
||||||
|
|
||||||
|
PROCESSES = [
|
||||||
|
(100, "notepad.exe"),
|
||||||
|
(101, "Notepad.EXE"),
|
||||||
|
(200, "steam.exe"),
|
||||||
|
(300, "Spotify.exe"),
|
||||||
|
(400, "explorer.exe"),
|
||||||
|
(500, "python.exe"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SelectVictimsTests(unittest.TestCase):
|
||||||
|
def test_blocks_every_matching_process(self):
|
||||||
|
victims = select_victims(PROCESSES, config())
|
||||||
|
self.assertEqual([(v.pid, v.matched) for v in victims], [(100, "notepad"), (101, "notepad"), (200, "steam")])
|
||||||
|
|
||||||
|
def test_allowlist_wins(self):
|
||||||
|
victims = select_victims(PROCESSES, config(allow=["spotify"]))
|
||||||
|
self.assertNotIn(300, [v.pid for v in victims])
|
||||||
|
|
||||||
|
def test_nothing_is_blocked_when_unlocked(self):
|
||||||
|
self.assertEqual(select_victims(PROCESSES, config(locked=False)), [])
|
||||||
|
|
||||||
|
def test_nothing_is_blocked_when_the_blocklist_is_empty(self):
|
||||||
|
self.assertEqual(select_victims(PROCESSES, config(block=[])), [])
|
||||||
|
|
||||||
|
def test_nothing_is_blocked_after_until(self):
|
||||||
|
expired = config(until=(NOW - timedelta(minutes=1)).isoformat())
|
||||||
|
self.assertEqual(select_victims(PROCESSES, expired, now=NOW), [])
|
||||||
|
|
||||||
|
def test_protected_names_are_never_victims(self):
|
||||||
|
victims = select_victims(PROCESSES, config(block=["explorer.exe"]), protected_names=["explorer.exe"])
|
||||||
|
self.assertEqual(victims, [])
|
||||||
|
|
||||||
|
def test_protected_pids_are_never_victims(self):
|
||||||
|
victims = select_victims(PROCESSES, config(block=["python.exe"]), protected_pids=[500])
|
||||||
|
self.assertEqual(victims, [])
|
||||||
|
|
||||||
|
def test_duplicate_pids_are_reported_once(self):
|
||||||
|
victims = select_victims([(100, "notepad.exe"), (100, "notepad.exe")], config())
|
||||||
|
self.assertEqual(len(victims), 1)
|
||||||
|
|
||||||
|
def test_bad_rows_are_ignored(self):
|
||||||
|
victims = select_victims([("x", "notepad.exe"), (0, ""), (None, None)], config())
|
||||||
|
self.assertEqual(victims, [])
|
||||||
|
|
||||||
|
|
||||||
|
class SelfProtectionTests(unittest.TestCase):
|
||||||
|
def test_includes_the_process_and_its_parents(self):
|
||||||
|
pids, names = self_protection(10, ["python.exe", "pythonw.exe"], [5, 3, 1])
|
||||||
|
self.assertEqual(pids, {10, 5, 3, 1})
|
||||||
|
self.assertEqual(names, {"python.exe", "pythonw.exe"})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
149
windows/tests/test_config.py
Normal file
149
windows/tests/test_config.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"""Config parsing, effective lock state and the keep-last-state cache."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from webrestrict.config import ConfigCache, DeviceConfig, bare, parse_time
|
||||||
|
|
||||||
|
NOW = datetime(2026, 10, 14, 20, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def payload(**overrides) -> str:
|
||||||
|
body = {
|
||||||
|
"v": 1,
|
||||||
|
"device_id": 2,
|
||||||
|
"name": "Finn's computer",
|
||||||
|
"platform": "windows",
|
||||||
|
"locked": True,
|
||||||
|
"until": (NOW + timedelta(minutes=30)).isoformat().replace("+00:00", "Z"),
|
||||||
|
"allow": ["Spotify.exe"],
|
||||||
|
"block": ["notepad.exe", "steam"],
|
||||||
|
"reason": "schedule",
|
||||||
|
"generated_at": "2026-10-14T19:00:00Z",
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return json.dumps(body)
|
||||||
|
|
||||||
|
|
||||||
|
class ParseTests(unittest.TestCase):
|
||||||
|
def test_parses_the_phase3_shape(self):
|
||||||
|
config = DeviceConfig.from_json(payload(extra_field="ignored"))
|
||||||
|
self.assertEqual(config.v, 1)
|
||||||
|
self.assertEqual(config.device_id, 2)
|
||||||
|
self.assertTrue(config.locked)
|
||||||
|
self.assertEqual(config.allow, ["Spotify.exe"])
|
||||||
|
self.assertEqual(config.block, ["notepad.exe", "steam"])
|
||||||
|
self.assertEqual(config.reason, "schedule")
|
||||||
|
self.assertIsNotNone(config.until_at())
|
||||||
|
|
||||||
|
def test_missing_lists_become_empty_arrays(self):
|
||||||
|
config = DeviceConfig.from_json('{"v":1,"locked":true}')
|
||||||
|
self.assertEqual(config.allow, [])
|
||||||
|
self.assertEqual(config.block, [])
|
||||||
|
|
||||||
|
def test_rejects_a_newer_schema(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
DeviceConfig.from_json(payload(v=2))
|
||||||
|
|
||||||
|
def test_rejects_malformed_json(self):
|
||||||
|
with self.assertRaises(json.JSONDecodeError):
|
||||||
|
DeviceConfig.from_json("{not json")
|
||||||
|
|
||||||
|
def test_parse_time_accepts_z_and_offsets(self):
|
||||||
|
self.assertEqual(parse_time("2026-10-14T20:00:00Z").tzinfo, timezone.utc)
|
||||||
|
self.assertEqual(parse_time("2026-10-14T20:00:00+10:00").utcoffset(), timedelta(hours=10))
|
||||||
|
self.assertIsNone(parse_time("nonsense"))
|
||||||
|
self.assertIsNone(parse_time(None))
|
||||||
|
|
||||||
|
|
||||||
|
class EffectiveStateTests(unittest.TestCase):
|
||||||
|
def test_not_locked_never_blocks(self):
|
||||||
|
config = DeviceConfig.from_json(payload(locked=False))
|
||||||
|
self.assertFalse(config.is_blocking(NOW))
|
||||||
|
|
||||||
|
def test_locked_without_until_blocks_indefinitely(self):
|
||||||
|
config = DeviceConfig.from_json(payload(until=None))
|
||||||
|
self.assertTrue(config.is_blocking(NOW))
|
||||||
|
self.assertTrue(config.is_blocking(NOW + timedelta(days=30)))
|
||||||
|
|
||||||
|
def test_locked_until_a_future_time_blocks(self):
|
||||||
|
config = DeviceConfig.from_json(payload())
|
||||||
|
self.assertTrue(config.is_blocking(NOW))
|
||||||
|
|
||||||
|
def test_locked_until_a_past_time_does_not_block(self):
|
||||||
|
config = DeviceConfig.from_json(payload(until=(NOW - timedelta(minutes=1)).isoformat()))
|
||||||
|
self.assertFalse(config.is_blocking(NOW))
|
||||||
|
|
||||||
|
def test_a_live_grant_never_blocks(self):
|
||||||
|
config = DeviceConfig.from_json(
|
||||||
|
payload(locked=False, until=None, grant_until=(NOW + timedelta(minutes=10)).isoformat())
|
||||||
|
)
|
||||||
|
self.assertFalse(config.is_blocking(NOW))
|
||||||
|
|
||||||
|
def test_an_expired_grant_does_not_block_either(self):
|
||||||
|
config = DeviceConfig.from_json(
|
||||||
|
payload(locked=False, until=None, grant_until=(NOW - timedelta(minutes=10)).isoformat())
|
||||||
|
)
|
||||||
|
self.assertFalse(config.is_blocking(NOW))
|
||||||
|
|
||||||
|
|
||||||
|
class NameTests(unittest.TestCase):
|
||||||
|
def test_bare_normalises_case_and_exe(self):
|
||||||
|
self.assertEqual(bare("Notepad.EXE"), "notepad")
|
||||||
|
self.assertEqual(bare(" STEAM.exe "), "steam")
|
||||||
|
self.assertEqual(bare(None), "")
|
||||||
|
|
||||||
|
def test_blocked_names_subtract_the_allowlist(self):
|
||||||
|
config = DeviceConfig.from_json(payload(block=["notepad.exe", "Spotify"], allow=["spotify.EXE"]))
|
||||||
|
self.assertEqual(config.blocked_names(), {"notepad"})
|
||||||
|
|
||||||
|
def test_allowlist_wins_per_name(self):
|
||||||
|
config = DeviceConfig.from_json(payload(block=["notepad.exe", "steam.exe"], allow=["Notepad"]))
|
||||||
|
self.assertFalse(config.is_blocked_name("NOTEPAD.EXE"))
|
||||||
|
self.assertTrue(config.is_blocked_name("steam.exe"))
|
||||||
|
|
||||||
|
|
||||||
|
class CacheTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.path = Path(self.tmp.name) / "config-cache.json"
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
|
||||||
|
def test_round_trips_through_disk(self):
|
||||||
|
cache = ConfigCache(self.path)
|
||||||
|
stored = cache.accept(payload())
|
||||||
|
self.assertIsNotNone(stored)
|
||||||
|
again = ConfigCache(self.path).load()
|
||||||
|
self.assertEqual(again.block, stored.block)
|
||||||
|
self.assertTrue(again.locked)
|
||||||
|
|
||||||
|
def test_a_malformed_payload_keeps_the_last_config(self):
|
||||||
|
cache = ConfigCache(self.path)
|
||||||
|
cache.accept(payload())
|
||||||
|
self.assertIsNone(cache.accept("{not json"))
|
||||||
|
self.assertEqual(cache.current.block, ["notepad.exe", "steam"])
|
||||||
|
|
||||||
|
def test_an_older_config_is_ignored(self):
|
||||||
|
cache = ConfigCache(self.path)
|
||||||
|
cache.accept(payload(generated_at="2026-10-14T19:30:00Z", block=["steam.exe"]))
|
||||||
|
self.assertIsNone(cache.accept(payload(generated_at="2026-10-14T18:00:00Z", block=["notepad.exe"])))
|
||||||
|
self.assertEqual(cache.current.block, ["steam.exe"])
|
||||||
|
|
||||||
|
def test_a_newer_config_replaces_it(self):
|
||||||
|
cache = ConfigCache(self.path)
|
||||||
|
cache.accept(payload(generated_at="2026-10-14T19:30:00Z", block=["steam.exe"]))
|
||||||
|
stored = cache.accept(payload(generated_at="2026-10-14T19:45:00Z", block=["notepad.exe"]))
|
||||||
|
self.assertIsNotNone(stored)
|
||||||
|
self.assertEqual(cache.current.block, ["notepad.exe"])
|
||||||
|
|
||||||
|
def test_a_missing_file_is_not_an_error(self):
|
||||||
|
self.assertIsNone(ConfigCache(self.path).load())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
102
windows/tests/test_settings_and_link.py
Normal file
102
windows/tests/test_settings_and_link.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""Settings loading and the MQTT payload builders (no broker, no paho)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from webrestrict.mqtt_link import iso_now, status_payload, telemetry_payload
|
||||||
|
from webrestrict.settings import DEFAULT_PROTECTED, Settings
|
||||||
|
|
||||||
|
RAW = {
|
||||||
|
"broker_host": "192.168.20.13",
|
||||||
|
"broker_port": 8883,
|
||||||
|
"device_topic": "restrict/finn_s_computer_2/config",
|
||||||
|
"mqtt_username": "finn_s_computer_2",
|
||||||
|
"mqtt_password": "secret",
|
||||||
|
"ca_file": "C:/ProgramData/WebRestrict/ca.crt",
|
||||||
|
"state_dir": "C:/ProgramData/WebRestrict",
|
||||||
|
"unknown_future_field": "ignored",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsTests(unittest.TestCase):
|
||||||
|
def test_derives_topics_and_token(self):
|
||||||
|
settings = Settings.from_dict(RAW)
|
||||||
|
self.assertEqual(settings.device_token, "finn_s_computer_2")
|
||||||
|
self.assertEqual(settings.status_topic, "restrict/finn_s_computer_2/status")
|
||||||
|
self.assertEqual(settings.telemetry_topic, "restrict/finn_s_computer_2/telemetry")
|
||||||
|
self.assertEqual(settings.effective_client_id, "kid-finn_s_computer_2")
|
||||||
|
|
||||||
|
def test_unknown_fields_are_ignored(self):
|
||||||
|
settings = Settings.from_dict(RAW)
|
||||||
|
self.assertFalse(hasattr(settings, "unknown_future_field"))
|
||||||
|
|
||||||
|
def test_loads_from_a_file(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "client.json"
|
||||||
|
path.write_text(json.dumps({**RAW, "state_dir": tmp}), encoding="utf-8")
|
||||||
|
settings = Settings.load(path)
|
||||||
|
self.assertEqual(settings.broker_port, 8883)
|
||||||
|
self.assertEqual(settings.log_path(), Path(tmp) / "client.log")
|
||||||
|
|
||||||
|
def test_validate_rejects_a_bad_topic(self):
|
||||||
|
settings = Settings.from_dict({**RAW, "device_topic": "restrict/finn"})
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
settings.validate()
|
||||||
|
|
||||||
|
def test_validate_rejects_a_missing_account(self):
|
||||||
|
bad = {k: v for k, v in RAW.items() if k != "mqtt_username"}
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
Settings.from_dict(bad).validate()
|
||||||
|
|
||||||
|
def test_validate_rejects_a_bad_port(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
Settings.from_dict({**RAW, "broker_port": 70000}).validate()
|
||||||
|
|
||||||
|
def test_protected_list_covers_the_system_and_the_interpreter(self):
|
||||||
|
for name in ("explorer", "svchost", "lsass", "python"):
|
||||||
|
self.assertIn(name, DEFAULT_PROTECTED)
|
||||||
|
|
||||||
|
def test_the_default_config_path_is_overridable(self):
|
||||||
|
from webrestrict.settings import default_config_path
|
||||||
|
|
||||||
|
previous = os.environ.get("WEBRESTRICT_CONFIG")
|
||||||
|
os.environ["WEBRESTRICT_CONFIG"] = "/tmp/webrestrict-test.json"
|
||||||
|
try:
|
||||||
|
self.assertEqual(str(default_config_path()), "/tmp/webrestrict-test.json")
|
||||||
|
finally:
|
||||||
|
if previous is None:
|
||||||
|
os.environ.pop("WEBRESTRICT_CONFIG", None)
|
||||||
|
else:
|
||||||
|
os.environ["WEBRESTRICT_CONFIG"] = previous
|
||||||
|
|
||||||
|
|
||||||
|
class PayloadTests(unittest.TestCase):
|
||||||
|
def test_status_reports_online_and_the_lock(self):
|
||||||
|
body = json.loads(status_payload(True, "notepad.exe"))
|
||||||
|
self.assertTrue(body["online"])
|
||||||
|
self.assertTrue(body["locked"])
|
||||||
|
self.assertEqual(body["app"], "notepad.exe")
|
||||||
|
self.assertTrue(body["at"].endswith("Z"))
|
||||||
|
|
||||||
|
def test_status_can_carry_a_tamper_report(self):
|
||||||
|
body = json.loads(status_payload(True, "", tamper={"kind": "client_stopped", "detail": "watchdog"}))
|
||||||
|
self.assertEqual(body["tamper"]["kind"], "client_stopped")
|
||||||
|
self.assertTrue(body["tamper"]["at"].endswith("Z"))
|
||||||
|
|
||||||
|
def test_telemetry_shape_is_the_list_form(self):
|
||||||
|
body = json.loads(telemetry_payload("2026-10-14", {"notepad.exe": 60, "steam.exe": 0}))
|
||||||
|
self.assertEqual(body["day"], "2026-10-14")
|
||||||
|
self.assertEqual(body["apps"], [{"name": "notepad.exe", "seconds": 60}])
|
||||||
|
self.assertEqual(body["domains"], [])
|
||||||
|
|
||||||
|
def test_iso_now_is_utc(self):
|
||||||
|
self.assertRegex(iso_now(), r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
115
windows/tests/test_telemetry.py
Normal file
115
windows/tests/test_telemetry.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""The usage roll-up: payload shape, accumulation, pruning and sampling."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from webrestrict.telemetry import (
|
||||||
|
MAX_DAYS,
|
||||||
|
MAX_SAMPLE_SECONDS,
|
||||||
|
TelemetryStore,
|
||||||
|
UsageSampler,
|
||||||
|
build_payload,
|
||||||
|
day_key,
|
||||||
|
to_entries,
|
||||||
|
)
|
||||||
|
|
||||||
|
NOW = datetime(2026, 10, 14, 20, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class PayloadTests(unittest.TestCase):
|
||||||
|
def test_shape_matches_the_control_service(self):
|
||||||
|
body = build_payload("2026-10-14", {"notepad.exe": 120, "steam.exe": 60})
|
||||||
|
self.assertEqual(body["day"], "2026-10-14")
|
||||||
|
self.assertEqual(body["apps"], [{"name": "notepad.exe", "seconds": 120}, {"name": "steam.exe", "seconds": 60}])
|
||||||
|
# Windows cannot attribute domains cheaply; the gap is explicit, not guessed.
|
||||||
|
self.assertEqual(body["domains"], [])
|
||||||
|
|
||||||
|
def test_zero_and_blank_entries_are_dropped(self):
|
||||||
|
self.assertEqual(to_entries({"": 10, "steam.exe": 0, "notepad.exe": 5}), [{"name": "notepad.exe", "seconds": 5}])
|
||||||
|
|
||||||
|
def test_day_key_is_local(self):
|
||||||
|
self.assertEqual(day_key(datetime(2026, 1, 2, 23, 59)), "2026-01-02")
|
||||||
|
|
||||||
|
def test_an_aware_timestamp_is_converted_to_local_time(self):
|
||||||
|
# The day boundary must be the child's midnight, not UTC's.
|
||||||
|
offset = datetime.now().astimezone().utcoffset()
|
||||||
|
utc = datetime(2026, 1, 2, 23, 30, tzinfo=timezone.utc)
|
||||||
|
self.assertEqual(day_key(utc), (utc + offset).strftime("%Y-%m-%d"))
|
||||||
|
|
||||||
|
|
||||||
|
class StoreTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.path = Path(self.tmp.name) / "telemetry.json"
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
|
||||||
|
def test_accumulates_and_persists(self):
|
||||||
|
store = TelemetryStore(self.path)
|
||||||
|
store.add_seconds("2026-10-14", {"notepad.exe": 60})
|
||||||
|
store.add_seconds("2026-10-14", {"notepad.exe": 30, "steam.exe": 10})
|
||||||
|
self.assertEqual(store.day("2026-10-14"), {"notepad.exe": 90, "steam.exe": 10})
|
||||||
|
|
||||||
|
reloaded = TelemetryStore(self.path)
|
||||||
|
self.assertEqual(reloaded.day("2026-10-14"), {"notepad.exe": 90, "steam.exe": 10})
|
||||||
|
|
||||||
|
def test_ignores_non_positive_samples(self):
|
||||||
|
store = TelemetryStore(self.path)
|
||||||
|
store.add_seconds("2026-10-14", {"notepad.exe": 0, "steam.exe": -5})
|
||||||
|
self.assertEqual(store.day("2026-10-14"), {})
|
||||||
|
|
||||||
|
def test_prunes_to_the_most_recent_days(self):
|
||||||
|
store = TelemetryStore(self.path)
|
||||||
|
for day in range(1, MAX_DAYS + 5):
|
||||||
|
store.add_seconds(f"2026-01-{day:02d}", {"notepad.exe": 1})
|
||||||
|
self.assertEqual(len(store.days), MAX_DAYS)
|
||||||
|
self.assertIn("2026-01-18", store.days)
|
||||||
|
self.assertNotIn("2026-01-01", store.days)
|
||||||
|
|
||||||
|
def test_an_unreadable_file_starts_empty(self):
|
||||||
|
self.path.write_text("{not json", encoding="utf-8")
|
||||||
|
store = TelemetryStore(self.path).load()
|
||||||
|
self.assertEqual(store.days, {})
|
||||||
|
|
||||||
|
def test_written_json_is_readable(self):
|
||||||
|
store = TelemetryStore(self.path)
|
||||||
|
store.add_seconds("2026-10-14", {"notepad.exe": 5})
|
||||||
|
self.assertIn("days", json.loads(self.path.read_text(encoding="utf-8")))
|
||||||
|
|
||||||
|
|
||||||
|
class SamplerTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.store = TelemetryStore(Path(self.tmp.name) / "telemetry.json")
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
|
||||||
|
def test_the_first_sample_only_starts_the_clock(self):
|
||||||
|
sampler = UsageSampler(self.store)
|
||||||
|
self.assertEqual(sampler.sample(["notepad.exe"], now=NOW, day="2026-10-14"), {})
|
||||||
|
|
||||||
|
def test_credits_elapsed_seconds_to_each_visible_app(self):
|
||||||
|
sampler = UsageSampler(self.store)
|
||||||
|
sampler.sample(["notepad.exe"], now=NOW, day="2026-10-14")
|
||||||
|
totals = sampler.sample(["notepad.exe", "steam.exe"], now=NOW + timedelta(seconds=60), day="2026-10-14")
|
||||||
|
self.assertEqual(totals, {"notepad.exe": 60, "steam.exe": 60})
|
||||||
|
|
||||||
|
def test_a_long_gap_is_capped(self):
|
||||||
|
sampler = UsageSampler(self.store)
|
||||||
|
sampler.sample(["notepad.exe"], now=NOW, day="2026-10-14")
|
||||||
|
totals = sampler.sample(
|
||||||
|
["notepad.exe"], now=NOW + timedelta(hours=5), day="2026-10-14"
|
||||||
|
)
|
||||||
|
self.assertEqual(totals["notepad.exe"], int(MAX_SAMPLE_SECONDS))
|
||||||
|
|
||||||
|
def test_no_visible_apps_adds_nothing(self):
|
||||||
|
sampler = UsageSampler(self.store)
|
||||||
|
sampler.sample([], now=NOW, day="2026-10-14")
|
||||||
|
self.assertEqual(sampler.sample([], now=NOW + timedelta(seconds=60), day="2026-10-14"), {})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
12
windows/webrestrict/__init__.py
Normal file
12
windows/webrestrict/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""Kids' Windows computer client for Internet Use Control (AGENTS.md §11 Phase 4).
|
||||||
|
|
||||||
|
The client is a device, not a control plane. It subscribes to its own retained
|
||||||
|
config on the MQTT 8883 plane, blocks listed processes while the device is
|
||||||
|
locked, reports presence and daily usage, warns the child, and keeps enforcing
|
||||||
|
its last config when the broker is unreachable (FR-14). It never changes its own
|
||||||
|
rules (AGENTS.md §10 rule 7).
|
||||||
|
|
||||||
|
Run it as ``python -m webrestrict`` (see ``windows/README.md``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "1.0.0-phase4"
|
||||||
96
windows/webrestrict/__main__.py
Normal file
96
windows/webrestrict/__main__.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""Command line entry point: ``python -m webrestrict`` (AGENTS.md §11 Phase 4).
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python -m webrestrict run # the client (the Scheduled Task runs this)
|
||||||
|
python -m webrestrict check # one-shot: cached config, effective state, victims
|
||||||
|
python -m webrestrict version
|
||||||
|
|
||||||
|
``--config PATH`` overrides the config file (default
|
||||||
|
``%PROGRAMDATA%\\WebRestrict\\client.json``, or ``WEBRESTRICT_CONFIG``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import logging.handlers
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from . import __version__
|
||||||
|
from .agent import Agent, check_once, install_signal_handlers
|
||||||
|
from .settings import Settings, default_config_path
|
||||||
|
|
||||||
|
LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s %(message)s"
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(prog="webrestrict", description="Kids' Windows computer client")
|
||||||
|
parser.add_argument("--config", help="path to client.json (default: %(default)s)")
|
||||||
|
parser.add_argument("--verbose", action="store_true", help="debug logging")
|
||||||
|
sub = parser.add_subparsers(dest="command")
|
||||||
|
sub.add_parser("run", help="run the client (default)")
|
||||||
|
sub.add_parser("check", help="print the cached config and what would be blocked, then exit")
|
||||||
|
sub.add_parser("version", help="print the client version")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(settings: Settings, verbose: bool) -> None:
|
||||||
|
"""Log to the console and to a rotating file next to the state.
|
||||||
|
|
||||||
|
A Scheduled Task runs with no visible console, so the file is the record
|
||||||
|
that survives: it holds the config that arrived and every process blocked.
|
||||||
|
"""
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.setLevel(logging.DEBUG if verbose else logging.INFO)
|
||||||
|
root.handlers.clear()
|
||||||
|
|
||||||
|
console = logging.StreamHandler(sys.stdout)
|
||||||
|
console.setFormatter(logging.Formatter(LOG_FORMAT))
|
||||||
|
root.addHandler(console)
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings.state_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
rotating = logging.handlers.RotatingFileHandler(
|
||||||
|
settings.log_path(), maxBytes=2 * 1024 * 1024, backupCount=3, encoding="utf-8"
|
||||||
|
)
|
||||||
|
rotating.setFormatter(logging.Formatter(LOG_FORMAT))
|
||||||
|
root.addHandler(rotating)
|
||||||
|
except OSError as exc: # pragma: no cover - depends on the machine
|
||||||
|
root.warning("cannot write the log file %s: %s", settings.log_path(), exc)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
command = args.command or "run"
|
||||||
|
|
||||||
|
if command == "version":
|
||||||
|
print(f"webrestrict-windows {__version__}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
settings_path = args.config or str(default_config_path())
|
||||||
|
try:
|
||||||
|
settings = Settings.load(settings_path)
|
||||||
|
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
print(f"webrestrict: cannot load {settings_path}: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
configure_logging(settings, args.verbose)
|
||||||
|
|
||||||
|
if command == "check":
|
||||||
|
print(json.dumps(check_once(settings), indent=2, sort_keys=True))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
agent = Agent(settings)
|
||||||
|
install_signal_handlers(agent)
|
||||||
|
try:
|
||||||
|
return agent.run()
|
||||||
|
except KeyboardInterrupt: # pragma: no cover - Ctrl-C
|
||||||
|
agent.shutdown()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
317
windows/webrestrict/agent.py
Normal file
317
windows/webrestrict/agent.py
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
"""The client agent: one loop that enforces, warns, reports and never gives up.
|
||||||
|
|
||||||
|
Wiring, in one place:
|
||||||
|
|
||||||
|
* the cached config is loaded **before** the broker link is attempted, so the
|
||||||
|
device is enforcing the moment the process starts, broker or no broker
|
||||||
|
(FR-14);
|
||||||
|
* the MQTT link subscribes to the retained config and publishes presence;
|
||||||
|
* every few seconds the enforcer terminates blocked processes while the device
|
||||||
|
is effectively locked (FR-3, FR-4);
|
||||||
|
* the overlay shows the child what is blocked and until when (FR-9);
|
||||||
|
* every minute the usage sample is rolled up and published (FR-7), and the
|
||||||
|
status report keeps the dashboard's presence fresh (FR-6).
|
||||||
|
|
||||||
|
Nothing here changes the rules. The client obeys and reports (AGENTS.md §10
|
||||||
|
rule 7).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from .config import ConfigCache, DeviceConfig
|
||||||
|
from .mqtt_link import MqttLink, iso_now
|
||||||
|
from .overlay import Notice, Overlay
|
||||||
|
from .process_watch import EnforcementReport, Enforcer, foreground_app, visible_apps
|
||||||
|
from .settings import Settings
|
||||||
|
from .telemetry import TelemetryStore, UsageSampler, build_payload, day_key
|
||||||
|
|
||||||
|
log = logging.getLogger("webrestrict")
|
||||||
|
|
||||||
|
|
||||||
|
def format_until(config: DeviceConfig) -> str:
|
||||||
|
"""A short local rendering of the config's end time, for the log and overlay."""
|
||||||
|
for value in (config.grant_until, config.until):
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
from .config import parse_time # noqa: PLC0415 - small helper, local use
|
||||||
|
|
||||||
|
moment = parse_time(value)
|
||||||
|
return moment.astimezone().strftime("%a %H:%M") if moment else value
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AgentState:
|
||||||
|
"""The moving parts, so tests can drive them without a broker or a desktop."""
|
||||||
|
|
||||||
|
settings: Settings
|
||||||
|
cache: ConfigCache
|
||||||
|
config: DeviceConfig
|
||||||
|
telemetry: TelemetryStore
|
||||||
|
sampler: UsageSampler
|
||||||
|
overlay: Overlay
|
||||||
|
enforcer: Enforcer
|
||||||
|
link: MqttLink | None = None
|
||||||
|
last_report: EnforcementReport | None = None
|
||||||
|
started_at: float = field(default_factory=time.monotonic)
|
||||||
|
|
||||||
|
|
||||||
|
class Agent:
|
||||||
|
"""The long-running kids' client."""
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings, logger: logging.Logger | None = None):
|
||||||
|
self.settings = settings
|
||||||
|
self.log = logger or log
|
||||||
|
self.stop_event = threading.Event()
|
||||||
|
telemetry = TelemetryStore(settings.telemetry_path())
|
||||||
|
self.state = AgentState(
|
||||||
|
settings=settings,
|
||||||
|
cache=ConfigCache(settings.config_path()),
|
||||||
|
config=DeviceConfig(),
|
||||||
|
telemetry=telemetry,
|
||||||
|
sampler=UsageSampler(telemetry),
|
||||||
|
overlay=Overlay(enabled=settings.overlay),
|
||||||
|
enforcer=Enforcer(settings.protected, self.log),
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- setup -------------------------------------------------------------
|
||||||
|
def load_config(self) -> DeviceConfig:
|
||||||
|
"""Load the cached config, seeding a permissive first-run config.
|
||||||
|
|
||||||
|
The seed is **unlocked with no lists**: a client that has never heard
|
||||||
|
from the broker must never invent a lock. The first retained config
|
||||||
|
replaces it.
|
||||||
|
"""
|
||||||
|
cached = self.state.cache.load()
|
||||||
|
if cached is None:
|
||||||
|
cached = DeviceConfig(platform="windows", locked=False)
|
||||||
|
self.state.cache.save(cached)
|
||||||
|
self.log.info("no cached config yet; seeded an unlocked config (FR-14)")
|
||||||
|
self.state.config = cached
|
||||||
|
self.log.info(
|
||||||
|
"startup config locked=%s until=%s block=%d allow=%d cached_at=%s",
|
||||||
|
cached.locked,
|
||||||
|
cached.until,
|
||||||
|
len(cached.block),
|
||||||
|
len(cached.allow),
|
||||||
|
cached.generated_at,
|
||||||
|
)
|
||||||
|
return cached
|
||||||
|
|
||||||
|
# -- callbacks ---------------------------------------------------------
|
||||||
|
def apply_config(self, payload: str) -> None:
|
||||||
|
"""Handle a config message: store it if valid, then apply it at once."""
|
||||||
|
accepted = self.state.cache.accept(payload)
|
||||||
|
if accepted is None:
|
||||||
|
self.log.warning("config ignored (older or unreadable); keeping the cached one")
|
||||||
|
return
|
||||||
|
self.state.config = accepted
|
||||||
|
self.log.info(
|
||||||
|
"config applied locked=%s until=%s reason=%s block=%s allow=%s generated_at=%s",
|
||||||
|
accepted.locked,
|
||||||
|
accepted.until,
|
||||||
|
accepted.reason or "-",
|
||||||
|
accepted.block,
|
||||||
|
accepted.allow,
|
||||||
|
accepted.generated_at,
|
||||||
|
)
|
||||||
|
# Act on it now instead of waiting for the next tick: a lock must land
|
||||||
|
# within the poll interval, and the Android client behaves the same way.
|
||||||
|
self.tick()
|
||||||
|
|
||||||
|
def on_connected(self) -> None:
|
||||||
|
config = self.state.config
|
||||||
|
self.log.info("link up: publishing presence and today's usage")
|
||||||
|
self.publish_status(config)
|
||||||
|
self.flush_telemetry()
|
||||||
|
|
||||||
|
def on_lost(self, reason: str) -> None:
|
||||||
|
self.log.warning(
|
||||||
|
"broker link lost (%s): enforcing the cached config (FR-14) block=%s until=%s",
|
||||||
|
reason,
|
||||||
|
self.state.config.block,
|
||||||
|
self.state.config.until,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- one tick ----------------------------------------------------------
|
||||||
|
def tick(self) -> EnforcementReport:
|
||||||
|
config = self.state.config
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
blocking = config.is_blocking(now)
|
||||||
|
report = self.state.enforcer.enforce(config, now)
|
||||||
|
self.state.last_report = report
|
||||||
|
self.state.overlay.update(self.notice(config, report, now) if blocking else None)
|
||||||
|
return report
|
||||||
|
|
||||||
|
def notice(self, config: DeviceConfig, report: EnforcementReport, now: datetime) -> Notice | None:
|
||||||
|
if not report.blocked_names:
|
||||||
|
return None
|
||||||
|
return Notice(
|
||||||
|
reason=config.reason_text or "screen-time rules are active",
|
||||||
|
until=format_until(config),
|
||||||
|
names=report.blocked_names,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- reporting ---------------------------------------------------------
|
||||||
|
def publish_status(self, config: DeviceConfig | None = None, app: str | None = None) -> None:
|
||||||
|
link = self.state.link
|
||||||
|
if link is None:
|
||||||
|
return
|
||||||
|
current = config or self.state.config
|
||||||
|
link.publish_status(current.is_blocking(), app if app is not None else foreground_app())
|
||||||
|
|
||||||
|
def flush_telemetry(self) -> None:
|
||||||
|
link = self.state.link
|
||||||
|
if link is None:
|
||||||
|
return
|
||||||
|
day = day_key()
|
||||||
|
apps = self.state.telemetry.day(day)
|
||||||
|
if apps and link.publish_telemetry(day, apps):
|
||||||
|
self.log.info("telemetry republished day=%s apps=%d", day, len(apps))
|
||||||
|
|
||||||
|
def sample_usage(self) -> None:
|
||||||
|
"""One usage sample: visible-window apps get the elapsed seconds."""
|
||||||
|
apps = visible_apps()
|
||||||
|
totals = self.state.sampler.sample(apps)
|
||||||
|
if not totals:
|
||||||
|
return
|
||||||
|
day = day_key()
|
||||||
|
self.log.debug("usage sample day=%s apps=%d", day, len(totals))
|
||||||
|
link = self.state.link
|
||||||
|
if link is not None and link.publish_telemetry(day, totals):
|
||||||
|
self.log.info(
|
||||||
|
"telemetry day=%s apps=%d top=%s",
|
||||||
|
day,
|
||||||
|
len(totals),
|
||||||
|
json.dumps(build_payload(day, totals)["apps"][:3]),
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- the loop ----------------------------------------------------------
|
||||||
|
def run(self) -> int:
|
||||||
|
self.load_config()
|
||||||
|
self.state.overlay.start()
|
||||||
|
|
||||||
|
link = MqttLink(
|
||||||
|
self.settings,
|
||||||
|
on_config=self.apply_config,
|
||||||
|
on_connected=self.on_connected,
|
||||||
|
on_lost=self.on_lost,
|
||||||
|
)
|
||||||
|
self.state.link = link
|
||||||
|
try:
|
||||||
|
link.start()
|
||||||
|
except Exception as exc: # noqa: BLE001 - run without a link, keep the rules
|
||||||
|
self.log.error("mqtt could not start (%s); enforcing the cached config only", exc)
|
||||||
|
|
||||||
|
self.state.started_at = time.monotonic()
|
||||||
|
next_telemetry = time.monotonic() + self.settings.telemetry_seconds
|
||||||
|
next_status = time.monotonic() + self.settings.status_seconds
|
||||||
|
|
||||||
|
self.log.info(
|
||||||
|
"client running: poll=%ss telemetry=%ss status=%ss device=%s state_dir=%s",
|
||||||
|
self.settings.poll_seconds,
|
||||||
|
self.settings.telemetry_seconds,
|
||||||
|
self.settings.status_seconds,
|
||||||
|
self.settings.device_token,
|
||||||
|
self.settings.state_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
while not self.stop_event.is_set():
|
||||||
|
try:
|
||||||
|
report = self.tick()
|
||||||
|
if report.killed:
|
||||||
|
self.publish_status(self.state.config)
|
||||||
|
except Exception as exc: # noqa: BLE001 - one bad tick must not stop the client
|
||||||
|
self.log.exception("enforcement tick failed: %s", exc)
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
if now >= next_telemetry:
|
||||||
|
next_telemetry = now + self.settings.telemetry_seconds
|
||||||
|
try:
|
||||||
|
self.sample_usage()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self.log.warning("usage sample failed: %s", exc)
|
||||||
|
if now >= next_status:
|
||||||
|
next_status = now + self.settings.status_seconds
|
||||||
|
try:
|
||||||
|
self.publish_status(self.state.config)
|
||||||
|
self.log.info(
|
||||||
|
"status published locked=%s blocking=%s broker=%s cached_config=%s",
|
||||||
|
self.state.config.locked,
|
||||||
|
self.state.config.is_blocking(),
|
||||||
|
"connected" if (self.state.link and self.state.link.connected) else "unreachable",
|
||||||
|
bool(self.state.config.generated_at),
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self.log.warning("status publish failed: %s", exc)
|
||||||
|
|
||||||
|
self.stop_event.wait(self.settings.poll_seconds)
|
||||||
|
|
||||||
|
self.shutdown()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
self.log.info("client stopping: publishing offline status")
|
||||||
|
link = self.state.link
|
||||||
|
if link is not None:
|
||||||
|
try:
|
||||||
|
link.publish_offline()
|
||||||
|
time.sleep(0.2) # let the retained offline message leave
|
||||||
|
link.stop()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self.log.debug("shutdown: %s", exc)
|
||||||
|
self.state.overlay.stop()
|
||||||
|
|
||||||
|
def request_stop(self, *_args) -> None:
|
||||||
|
self.stop_event.set()
|
||||||
|
|
||||||
|
|
||||||
|
def install_signal_handlers(agent: Agent) -> None:
|
||||||
|
"""Stop cleanly on Ctrl-C and on a Scheduled Task 'End task'."""
|
||||||
|
for name in ("SIGINT", "SIGTERM"):
|
||||||
|
handler = getattr(signal, name, None)
|
||||||
|
if handler is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
signal.signal(handler, agent.request_stop)
|
||||||
|
except (ValueError, OSError): # pragma: no cover - not the main thread
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def check_once(settings: Settings) -> dict[str, object]:
|
||||||
|
"""A one-shot report for the operator and for the phase proof.
|
||||||
|
|
||||||
|
Prints the cached config, the effective block decision, and the processes it
|
||||||
|
would terminate right now. It changes nothing.
|
||||||
|
"""
|
||||||
|
cache = ConfigCache(settings.config_path())
|
||||||
|
config = cache.load() or DeviceConfig(platform="windows", locked=False)
|
||||||
|
enforcer = Enforcer(settings.protected)
|
||||||
|
from .blocker import select_victims # noqa: PLC0415 - only needed here
|
||||||
|
from .process_watch import snapshot_processes
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
victims = select_victims(
|
||||||
|
snapshot_processes(),
|
||||||
|
config,
|
||||||
|
now=now,
|
||||||
|
protected_names=enforcer.protected_names,
|
||||||
|
protected_pids=enforcer.protected_pids,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"device_topic": settings.device_topic,
|
||||||
|
"state_dir": str(settings.state_path),
|
||||||
|
"checked_at": iso_now(),
|
||||||
|
"config": config.to_dict(),
|
||||||
|
"blocking": config.is_blocking(now),
|
||||||
|
"blocked_names": sorted(config.blocked_names()),
|
||||||
|
"victims": [{"pid": v.pid, "name": v.name, "rule": v.matched} for v in victims],
|
||||||
|
}
|
||||||
87
windows/webrestrict/blocker.py
Normal file
87
windows/webrestrict/blocker.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"""Pure block-selection logic (AGENTS.md FR-3, FR-4, FR-16).
|
||||||
|
|
||||||
|
No psutil and no Windows here, so the decision can be unit tested anywhere.
|
||||||
|
:mod:`webrestrict.process_watch` supplies the running process list and does the
|
||||||
|
killing; this module only decides **which** processes are blocked.
|
||||||
|
|
||||||
|
The rules are deliberately small, because the client must never widen or narrow
|
||||||
|
them on its own (AGENTS.md §10 rule 7):
|
||||||
|
|
||||||
|
* nothing is blocked while the config is not effectively locked;
|
||||||
|
* a process is blocked when its normalised name is on the blocklist;
|
||||||
|
* the allowlist always wins (FR-4);
|
||||||
|
* the client never kills itself, its parent chain, or a protected system
|
||||||
|
process, whatever the blocklist says.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from .config import DeviceConfig, bare
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Victim:
|
||||||
|
"""One process that must be terminated."""
|
||||||
|
|
||||||
|
pid: int
|
||||||
|
name: str # as reported by the OS, e.g. "notepad.exe"
|
||||||
|
matched: str # the normalised blocklist entry that matched
|
||||||
|
|
||||||
|
|
||||||
|
def select_victims(
|
||||||
|
processes: Iterable[tuple[int, str]],
|
||||||
|
config: DeviceConfig,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
protected_names: Iterable[str] = (),
|
||||||
|
protected_pids: Iterable[int] = (),
|
||||||
|
) -> list[Victim]:
|
||||||
|
"""Choose the processes to terminate for one enforcement tick.
|
||||||
|
|
||||||
|
``processes`` is an iterable of ``(pid, name)`` pairs, so the caller can pass
|
||||||
|
a psutil snapshot, a fake list in a test, or a single process. The result is
|
||||||
|
ordered by pid and contains no duplicates, so the caller can log it.
|
||||||
|
"""
|
||||||
|
if not config.is_blocking(now):
|
||||||
|
return []
|
||||||
|
|
||||||
|
blocked = config.blocked_names()
|
||||||
|
if not blocked:
|
||||||
|
return []
|
||||||
|
|
||||||
|
allowed = config.allowed_names()
|
||||||
|
protected = {bare(item) for item in protected_names if item and str(item).strip()}
|
||||||
|
do_not_touch = {int(pid) for pid in protected_pids}
|
||||||
|
|
||||||
|
victims: list[Victim] = []
|
||||||
|
seen: set[int] = set()
|
||||||
|
for pid, name in processes:
|
||||||
|
try:
|
||||||
|
pid = int(pid)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
value = bare(name)
|
||||||
|
if not value or pid in seen:
|
||||||
|
continue
|
||||||
|
seen.add(pid)
|
||||||
|
if pid in do_not_touch or value in protected or value in allowed:
|
||||||
|
continue
|
||||||
|
if value in blocked:
|
||||||
|
victims.append(Victim(pid=pid, name=name, matched=value))
|
||||||
|
victims.sort(key=lambda victim: (victim.matched, victim.pid))
|
||||||
|
return victims
|
||||||
|
|
||||||
|
|
||||||
|
def self_protection(pid: int, image_names: Iterable[str], parents: Iterable[int]) -> tuple[set[int], set[str]]:
|
||||||
|
"""The pids and names the enforcer must never touch for its own process tree.
|
||||||
|
|
||||||
|
The client runs as ``python.exe`` on the kid's machine, so a blocklist that
|
||||||
|
named Python would otherwise make the client kill itself and stop reporting.
|
||||||
|
"""
|
||||||
|
pids = {int(pid)} | {int(parent) for parent in parents}
|
||||||
|
names = {str(name) for name in image_names if str(name).strip()}
|
||||||
|
return pids, names
|
||||||
268
windows/webrestrict/config.py
Normal file
268
windows/webrestrict/config.py
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
"""The device config and its on-disk cache (AGENTS.md FR-14).
|
||||||
|
|
||||||
|
``restrict/<device>/config`` carries the desired state, exactly as
|
||||||
|
``internal/mqtt/payload.go`` publishes it. This module parses that payload and
|
||||||
|
keeps the last valid copy on disk, so a device that cannot reach the broker
|
||||||
|
still knows its rules. Only a valid, not-older config replaces the cache:
|
||||||
|
a malformed or stale message never leaves the device without rules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
# The schema version this client understands. A payload with a higher version is
|
||||||
|
# rejected rather than guessed at; the cached config stays in force.
|
||||||
|
CONFIG_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
def parse_time(value: str | None) -> datetime | None:
|
||||||
|
"""Parse an RFC 3339 timestamp as the Control Service writes it.
|
||||||
|
|
||||||
|
Accepts a trailing ``Z`` and an optional fractional part. Returns ``None``
|
||||||
|
for an empty or unparseable value, so a bad timestamp downgrades to "no end
|
||||||
|
time" instead of raising.
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
text = value.strip()
|
||||||
|
if text.endswith("Z") or text.endswith("z"):
|
||||||
|
text = text[:-1] + "+00:00"
|
||||||
|
try:
|
||||||
|
moment = datetime.fromisoformat(text)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if moment.tzinfo is None:
|
||||||
|
moment = moment.replace(tzinfo=timezone.utc)
|
||||||
|
return moment
|
||||||
|
|
||||||
|
|
||||||
|
def bare(name: str) -> str:
|
||||||
|
"""Normalise a process or list entry for comparison.
|
||||||
|
|
||||||
|
``notepad.exe``, ``Notepad.EXE`` and ``notepad`` are the same app. The
|
||||||
|
Windows process list reports ``.exe``; a blocklist entry may or may not.
|
||||||
|
"""
|
||||||
|
value = (name or "").strip().casefold()
|
||||||
|
if value.endswith(".exe"):
|
||||||
|
value = value[:-4]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DeviceConfig:
|
||||||
|
"""The desired state for one device (TRANSPORT_DESIGN.md, Phase 3)."""
|
||||||
|
|
||||||
|
v: int = CONFIG_VERSION
|
||||||
|
device_id: int = 0
|
||||||
|
name: str = ""
|
||||||
|
platform: str = "windows"
|
||||||
|
locked: bool = False
|
||||||
|
until: str | None = None
|
||||||
|
allow: list[str] = field(default_factory=list)
|
||||||
|
block: list[str] = field(default_factory=list)
|
||||||
|
reason: str = ""
|
||||||
|
grant_until: str | None = None
|
||||||
|
generated_at: str | None = None
|
||||||
|
|
||||||
|
# -- parsing -----------------------------------------------------------
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, raw: dict[str, Any]) -> "DeviceConfig":
|
||||||
|
"""Parse a payload, ignoring unknown fields (forward compatible).
|
||||||
|
|
||||||
|
Raises ``ValueError`` on a payload this client cannot honour, so the
|
||||||
|
caller keeps the last good config.
|
||||||
|
"""
|
||||||
|
version = raw.get("v", CONFIG_VERSION)
|
||||||
|
try:
|
||||||
|
version = int(version)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError(f"config v is not an integer: {version!r}") from exc
|
||||||
|
if version > CONFIG_VERSION:
|
||||||
|
raise ValueError(f"config v={version} is newer than this client ({CONFIG_VERSION})")
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
v=version,
|
||||||
|
device_id=int(raw.get("device_id") or 0),
|
||||||
|
name=str(raw.get("name") or ""),
|
||||||
|
platform=str(raw.get("platform") or "windows"),
|
||||||
|
locked=bool(raw.get("locked")),
|
||||||
|
until=_str_or_none(raw.get("until")),
|
||||||
|
allow=_string_list(raw.get("allow")),
|
||||||
|
block=_string_list(raw.get("block")),
|
||||||
|
reason=str(raw.get("reason") or ""),
|
||||||
|
grant_until=_str_or_none(raw.get("grant_until")),
|
||||||
|
generated_at=_str_or_none(raw.get("generated_at")),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_json(cls, payload: str | bytes) -> "DeviceConfig":
|
||||||
|
if isinstance(payload, (bytes, bytearray)):
|
||||||
|
payload = payload.decode("utf-8", "replace")
|
||||||
|
data = json.loads(payload)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("config payload is not a JSON object")
|
||||||
|
return cls.from_dict(data)
|
||||||
|
|
||||||
|
def to_json(self) -> str:
|
||||||
|
return json.dumps(self.to_dict(), sort_keys=True)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"v": self.v,
|
||||||
|
"device_id": self.device_id,
|
||||||
|
"name": self.name,
|
||||||
|
"platform": self.platform,
|
||||||
|
"locked": self.locked,
|
||||||
|
"until": self.until,
|
||||||
|
"allow": list(self.allow),
|
||||||
|
"block": list(self.block),
|
||||||
|
"reason": self.reason,
|
||||||
|
"grant_until": self.grant_until,
|
||||||
|
"generated_at": self.generated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- effective state --------------------------------------------------
|
||||||
|
def until_at(self) -> datetime | None:
|
||||||
|
return parse_time(self.until)
|
||||||
|
|
||||||
|
def grant_at(self) -> datetime | None:
|
||||||
|
return parse_time(self.grant_until)
|
||||||
|
|
||||||
|
def is_blocking(self, now: datetime | None = None) -> bool:
|
||||||
|
"""Whether the device must block right now.
|
||||||
|
|
||||||
|
This mirrors the Control Service's *effective* state
|
||||||
|
(``internal/store/schedules.go`` ``EffectiveState``): the payload already
|
||||||
|
carries the resolved ``locked`` flag, so the client only has to apply
|
||||||
|
the clock. An expired ``until`` lifts the block locally — the same
|
||||||
|
instant the Control Service would clear it — a live ``grant_until``
|
||||||
|
never blocks, and an indefinite lock (``until`` absent) never lifts.
|
||||||
|
"""
|
||||||
|
if not self.locked:
|
||||||
|
return False
|
||||||
|
moment = now or datetime.now(timezone.utc)
|
||||||
|
grant = self.grant_at()
|
||||||
|
if grant is not None and grant > moment:
|
||||||
|
return False
|
||||||
|
until = self.until_at()
|
||||||
|
if until is not None and moment >= until:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def blocked_names(self) -> set[str]:
|
||||||
|
"""The blocklist as normalised names, minus the allowlist (FR-4)."""
|
||||||
|
allowed = {bare(item) for item in self.allow}
|
||||||
|
return {bare(item) for item in self.block if bare(item)} - allowed
|
||||||
|
|
||||||
|
def allowed_names(self) -> set[str]:
|
||||||
|
return {bare(item) for item in self.allow if bare(item)}
|
||||||
|
|
||||||
|
def is_blocked_name(self, process_name: str) -> bool:
|
||||||
|
"""Whether one process name is blocked, with the allowlist winning."""
|
||||||
|
value = bare(process_name)
|
||||||
|
if not value:
|
||||||
|
return False
|
||||||
|
if value in self.allowed_names():
|
||||||
|
return False
|
||||||
|
return value in self.blocked_names()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reason_text(self) -> str:
|
||||||
|
"""A short human reason for the overlay (FR-9)."""
|
||||||
|
return {
|
||||||
|
"schedule": "it is inside a scheduled screen-time window",
|
||||||
|
"manual": "a parent locked this computer",
|
||||||
|
"grant": "",
|
||||||
|
}.get(self.reason, self.reason or "screen-time rules are active")
|
||||||
|
|
||||||
|
def generation_key(self) -> tuple[str, int]:
|
||||||
|
"""A sortable key for "is this config newer?".
|
||||||
|
|
||||||
|
The Control Service republishes a config whenever the state changes, so
|
||||||
|
``generated_at`` is normally enough. The fallback keeps a payload with a
|
||||||
|
missing timestamp acceptable rather than dropping a real update.
|
||||||
|
"""
|
||||||
|
return (self.generated_at or "", self.device_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _str_or_none(value: Any) -> str | None:
|
||||||
|
return None if value is None else str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _string_list(value: Any) -> list[str]:
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [value]
|
||||||
|
if isinstance(value, Iterable):
|
||||||
|
return [str(item) for item in value if str(item).strip()]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigCache:
|
||||||
|
"""The last valid config on disk (FR-14 "keep last state").
|
||||||
|
|
||||||
|
A device that cannot reach the broker keeps enforcing what it already has.
|
||||||
|
The cache is written atomically, so a power cut cannot leave a half-written
|
||||||
|
file, and a cache that cannot be read is treated as empty rather than fatal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path: str | os.PathLike[str]):
|
||||||
|
self.path = Path(path)
|
||||||
|
self._current: DeviceConfig | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current(self) -> DeviceConfig | None:
|
||||||
|
"""The in-memory config, loading from disk on first use."""
|
||||||
|
if self._current is None:
|
||||||
|
self._current = self._read()
|
||||||
|
return self._current
|
||||||
|
|
||||||
|
def load(self) -> DeviceConfig | None:
|
||||||
|
return self.current
|
||||||
|
|
||||||
|
def save(self, config: DeviceConfig) -> None:
|
||||||
|
"""Persist a config and make it current."""
|
||||||
|
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:
|
||||||
|
handle.write(config.to_json())
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.replace(temp, self.path)
|
||||||
|
self._current = config
|
||||||
|
|
||||||
|
def accept(self, payload: str | bytes) -> DeviceConfig | None:
|
||||||
|
"""Validate and store a payload.
|
||||||
|
|
||||||
|
Returns the stored config, or ``None`` when the payload is rejected (a
|
||||||
|
malformed message, a newer schema, or an older ``generated_at`` than the
|
||||||
|
cache). A rejection leaves the current config untouched.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
candidate = DeviceConfig.from_json(payload)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
# json.JSONDecodeError is a ValueError. A broker bug or a hostile
|
||||||
|
# payload must never leave the device without rules (FR-14).
|
||||||
|
return None
|
||||||
|
existing = self.current
|
||||||
|
if existing is not None and candidate.generation_key() < existing.generation_key():
|
||||||
|
return None
|
||||||
|
self.save(candidate)
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
def _read(self) -> DeviceConfig | None:
|
||||||
|
try:
|
||||||
|
with open(self.path, "r", encoding="utf-8") as handle:
|
||||||
|
return DeviceConfig.from_json(handle.read())
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
except (OSError, ValueError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
241
windows/webrestrict/mqtt_link.py
Normal file
241
windows/webrestrict/mqtt_link.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"""The MQTT link to the 8883 device plane (AGENTS.md §10 rule 6, FR-6).
|
||||||
|
|
||||||
|
The client is one device account on the TLS listener. Its ACL lets it read only
|
||||||
|
``restrict/<token>/config`` and write only its own status and telemetry, so the
|
||||||
|
broker itself stops a child publishing a fake "unlocked" (TRANSPORT_DESIGN.md
|
||||||
|
"Security").
|
||||||
|
|
||||||
|
Presence is the broker's Last Will and Testament: the client publishes a
|
||||||
|
retained ``{"online": true}`` when it connects and registers the will
|
||||||
|
``{"online": false}`` on the same topic, so the Control Service learns the
|
||||||
|
device is gone the moment the connection drops without a clean goodbye (FR-6).
|
||||||
|
|
||||||
|
A broker that is down is not fatal: the cached config stays in force (FR-14),
|
||||||
|
paho retries on a backoff, and the retained config is delivered again on
|
||||||
|
reconnect.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import ssl
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Callable, Mapping
|
||||||
|
|
||||||
|
from .settings import Settings
|
||||||
|
|
||||||
|
log = logging.getLogger("webrestrict.mqtt")
|
||||||
|
|
||||||
|
QOS = 1
|
||||||
|
KEEPALIVE_SECONDS = 30
|
||||||
|
|
||||||
|
|
||||||
|
def iso_now(moment: datetime | None = None) -> str:
|
||||||
|
"""RFC 3339 in UTC, as the Control Service writes timestamps."""
|
||||||
|
current = moment or datetime.now(timezone.utc)
|
||||||
|
if current.tzinfo is None:
|
||||||
|
current = current.replace(tzinfo=timezone.utc)
|
||||||
|
return current.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def status_payload(locked: bool, app: str = "", tamper: dict[str, str] | None = None) -> str:
|
||||||
|
"""The status report the dashboard reads (FR-6, FR-9, FR-17)."""
|
||||||
|
body: dict[str, object] = {"online": True, "locked": locked, "app": app, "at": iso_now()}
|
||||||
|
if tamper:
|
||||||
|
body["tamper"] = {**tamper, "at": iso_now()}
|
||||||
|
return json.dumps(body, sort_keys=True)
|
||||||
|
|
||||||
|
|
||||||
|
def telemetry_payload(day: str, apps: Mapping[str, int]) -> str:
|
||||||
|
"""A day's per-app totals. Not retained: it is a report, not a rule."""
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"day": day,
|
||||||
|
"apps": [
|
||||||
|
{"name": name, "seconds": int(seconds)}
|
||||||
|
for name, seconds in sorted(apps.items())
|
||||||
|
if name and int(seconds) > 0
|
||||||
|
],
|
||||||
|
"domains": [],
|
||||||
|
},
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MqttLink:
|
||||||
|
"""A reconnecting paho client wrapper with a small, testable surface."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
settings: Settings,
|
||||||
|
on_config: Callable[[str], None],
|
||||||
|
on_connected: Callable[[], None] | None = None,
|
||||||
|
on_lost: Callable[[str], None] | None = None,
|
||||||
|
):
|
||||||
|
self.settings = settings
|
||||||
|
self.on_config = on_config
|
||||||
|
self.on_connected = on_connected or (lambda: None)
|
||||||
|
self.on_lost = on_lost or (lambda reason: None)
|
||||||
|
self.connected = False
|
||||||
|
self.last_error = ""
|
||||||
|
self._client = None
|
||||||
|
self._stopped = False
|
||||||
|
self._unreachable_logged = False
|
||||||
|
|
||||||
|
# -- lifecycle ---------------------------------------------------------
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Create the client and begin connecting in the background."""
|
||||||
|
import paho.mqtt.client as mqtt # noqa: PLC0415 - keep the import local
|
||||||
|
|
||||||
|
client = self._new_client(mqtt)
|
||||||
|
self._client = client
|
||||||
|
client.on_connect = self._handle_connect
|
||||||
|
client.on_connect_fail = self._handle_connect_fail
|
||||||
|
client.on_disconnect = self._handle_disconnect
|
||||||
|
client.on_message = self._handle_message
|
||||||
|
|
||||||
|
client.username_pw_set(self.settings.mqtt_username, self.settings.mqtt_password or None)
|
||||||
|
if self.settings.ca_file:
|
||||||
|
client.tls_set(
|
||||||
|
ca_certs=self.settings.ca_file,
|
||||||
|
cert_reqs=ssl.CERT_REQUIRED,
|
||||||
|
tls_version=ssl.PROTOCOL_TLS_CLIENT,
|
||||||
|
)
|
||||||
|
else: # pragma: no cover - the lab always pins the CA
|
||||||
|
client.tls_set(cert_reqs=ssl.CERT_REQUIRED, tls_version=ssl.PROTOCOL_TLS_CLIENT)
|
||||||
|
|
||||||
|
# Presence (FR-6): the broker publishes this the moment the link drops
|
||||||
|
# without a clean DISCONNECT.
|
||||||
|
client.will_set(
|
||||||
|
self.settings.status_topic,
|
||||||
|
json.dumps({"online": False}),
|
||||||
|
qos=QOS,
|
||||||
|
retain=True,
|
||||||
|
)
|
||||||
|
client.reconnect_delay_set(min_delay=1, max_delay=30)
|
||||||
|
log.info(
|
||||||
|
"mqtt connecting to %s:%s as %s (topic %s)",
|
||||||
|
self.settings.broker_host,
|
||||||
|
self.settings.broker_port,
|
||||||
|
self.settings.mqtt_username,
|
||||||
|
self.settings.device_topic,
|
||||||
|
)
|
||||||
|
client.connect_async(
|
||||||
|
self.settings.broker_host,
|
||||||
|
self.settings.broker_port,
|
||||||
|
keepalive=KEEPALIVE_SECONDS,
|
||||||
|
)
|
||||||
|
client.loop_start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Disconnect cleanly, which suppresses the will."""
|
||||||
|
self._stopped = True
|
||||||
|
client = self._client
|
||||||
|
if client is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
client.disconnect()
|
||||||
|
client.loop_stop()
|
||||||
|
except Exception as exc: # noqa: BLE001 - shutting down anyway
|
||||||
|
log.debug("mqtt stop: %s", exc)
|
||||||
|
|
||||||
|
# -- publishing --------------------------------------------------------
|
||||||
|
def publish_status(self, locked: bool, app: str = "", tamper: dict[str, str] | None = None) -> bool:
|
||||||
|
return self._publish(self.settings.status_topic, status_payload(locked, app, tamper), retain=True)
|
||||||
|
|
||||||
|
def publish_telemetry(self, day: str, apps: Mapping[str, int]) -> bool:
|
||||||
|
if not apps:
|
||||||
|
return False
|
||||||
|
return self._publish(self.settings.telemetry_topic, telemetry_payload(day, apps), retain=False)
|
||||||
|
|
||||||
|
def publish_offline(self) -> bool:
|
||||||
|
"""Retained offline status, published on a clean shutdown (FR-6)."""
|
||||||
|
return self._publish(self.settings.status_topic, json.dumps({"online": False}), retain=True)
|
||||||
|
|
||||||
|
def _publish(self, topic: str, payload: str, *, retain: bool) -> bool:
|
||||||
|
client = self._client
|
||||||
|
if client is None or not self.connected:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
info = client.publish(topic, payload, qos=QOS, retain=retain)
|
||||||
|
if info.rc != 0:
|
||||||
|
log.warning("publish to %s rejected (rc=%s)", topic, info.rc)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except Exception as exc: # noqa: BLE001 - a lost link retries
|
||||||
|
log.warning("publish to %s failed: %s", topic, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# -- paho plumbing -----------------------------------------------------
|
||||||
|
def _new_client(self, mqtt):
|
||||||
|
"""paho 2.x takes an explicit callback API version; 1.x does not."""
|
||||||
|
try:
|
||||||
|
return mqtt.Client(
|
||||||
|
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||||
|
client_id=self.settings.effective_client_id,
|
||||||
|
clean_session=True,
|
||||||
|
protocol=mqtt.MQTTv311,
|
||||||
|
)
|
||||||
|
except TypeError: # pragma: no cover - paho 1.x
|
||||||
|
return mqtt.Client(
|
||||||
|
client_id=self.settings.effective_client_id,
|
||||||
|
clean_session=True,
|
||||||
|
protocol=mqtt.MQTTv311,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _handle_connect(self, client, _userdata, _flags, reason_code, _properties=None):
|
||||||
|
code = getattr(reason_code, "value", reason_code)
|
||||||
|
try:
|
||||||
|
code = int(code)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
code = 1
|
||||||
|
if code != 0:
|
||||||
|
self.connected = False
|
||||||
|
self.last_error = f"connect refused: {reason_code}"
|
||||||
|
log.warning("mqtt connect refused: %s", reason_code)
|
||||||
|
return
|
||||||
|
self.connected = True
|
||||||
|
self.last_error = ""
|
||||||
|
self._unreachable_logged = False
|
||||||
|
log.info("mqtt connected, subscribing to %s", self.settings.device_topic)
|
||||||
|
client.subscribe(self.settings.device_topic, qos=QOS)
|
||||||
|
# on_connected publishes the retained online status with the real
|
||||||
|
# locked state, and flushes any telemetry held while offline.
|
||||||
|
self.on_connected()
|
||||||
|
|
||||||
|
def _handle_connect_fail(self, *_args):
|
||||||
|
"""The broker could not be reached at all (FR-14).
|
||||||
|
|
||||||
|
Logged once per outage so the record shows the client is running on its
|
||||||
|
cached config rather than sitting idle, and not flooded by paho's retry.
|
||||||
|
"""
|
||||||
|
self.connected = False
|
||||||
|
self.last_error = "broker unreachable"
|
||||||
|
if not self._unreachable_logged:
|
||||||
|
self._unreachable_logged = True
|
||||||
|
log.warning(
|
||||||
|
"broker %s:%s is unreachable; enforcing the cached config only (FR-14)",
|
||||||
|
self.settings.broker_host,
|
||||||
|
self.settings.broker_port,
|
||||||
|
)
|
||||||
|
self.on_lost("broker unreachable")
|
||||||
|
|
||||||
|
def _handle_disconnect(self, _client, _userdata, _flags, reason_code, _properties=None):
|
||||||
|
self.connected = False
|
||||||
|
message = str(reason_code)
|
||||||
|
self.last_error = message
|
||||||
|
if self._stopped:
|
||||||
|
log.info("mqtt disconnected (client stopped): %s", message)
|
||||||
|
return
|
||||||
|
log.warning("mqtt connection lost: %s — keeping the last config (FR-14)", message)
|
||||||
|
self.on_lost(message)
|
||||||
|
|
||||||
|
def _handle_message(self, _client, _userdata, message):
|
||||||
|
if message.topic != self.settings.device_topic:
|
||||||
|
return
|
||||||
|
payload = message.payload.decode("utf-8", "replace")
|
||||||
|
try:
|
||||||
|
self.on_config(payload)
|
||||||
|
except Exception as exc: # noqa: BLE001 - a bad config must not kill the link
|
||||||
|
log.warning("config rejected, keeping the last one: %s", exc)
|
||||||
147
windows/webrestrict/overlay.py
Normal file
147
windows/webrestrict/overlay.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"""The child's warning window (AGENTS.md FR-9).
|
||||||
|
|
||||||
|
A small always-on-top Tkinter window: what is blocked, why, and until when. It
|
||||||
|
is an *alert*, not a defence in depth — the child can close it, and on Windows
|
||||||
|
the enforcement is the process block itself (FR-3) plus the presence and
|
||||||
|
offline reports that reach Sam (FR-6, FR-13).
|
||||||
|
|
||||||
|
Tkinter wants one thread that owns the interpreter, so the window runs on its
|
||||||
|
own daemon thread and the agent talks to it through a queue. If Tk is missing,
|
||||||
|
or there is no interactive desktop (a service session, a headless test), the
|
||||||
|
overlay is simply unavailable and blocking continues.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
log = logging.getLogger("webrestrict.overlay")
|
||||||
|
|
||||||
|
TITLE = "Screen time"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Notice:
|
||||||
|
"""What the window shows while a block is active."""
|
||||||
|
|
||||||
|
reason: str
|
||||||
|
until: str
|
||||||
|
names: Sequence[str]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def blocked_text(self) -> str:
|
||||||
|
if not self.names:
|
||||||
|
return "This computer is blocked while the window is active."
|
||||||
|
shown = ", ".join(self.names[:8])
|
||||||
|
if len(self.names) > 8:
|
||||||
|
shown += f", and {len(self.names) - 8} more"
|
||||||
|
return f"Blocked now: {shown}"
|
||||||
|
|
||||||
|
|
||||||
|
class Overlay:
|
||||||
|
"""A queue-fed Tkinter window with a tiny, testable interface."""
|
||||||
|
|
||||||
|
def __init__(self, enabled: bool = True):
|
||||||
|
self.enabled = enabled
|
||||||
|
self.available = False
|
||||||
|
self._queue: "queue.Queue[tuple[str, object]]" = queue.Queue()
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._shown: Notice | None = None
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if not self.enabled:
|
||||||
|
log.info("overlay disabled by configuration")
|
||||||
|
return
|
||||||
|
self._thread = threading.Thread(target=self._run, name="webrestrict-overlay", daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def update(self, notice: Notice | None) -> None:
|
||||||
|
"""Show the notice, hide on ``None``. Repeat calls with the same content are dropped."""
|
||||||
|
if notice == self._shown:
|
||||||
|
return
|
||||||
|
self._shown = notice
|
||||||
|
if notice is None:
|
||||||
|
self._queue.put(("hide", None))
|
||||||
|
else:
|
||||||
|
self._queue.put(("show", notice))
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._queue.put(("stop", None))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
def _run(self) -> None: # pragma: no cover - needs a desktop
|
||||||
|
try:
|
||||||
|
import tkinter as tk
|
||||||
|
except ImportError as exc:
|
||||||
|
log.warning("tkinter is unavailable, no warning window: %s", exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
root = tk.Tk()
|
||||||
|
except Exception as exc: # noqa: BLE001 - no interactive desktop
|
||||||
|
log.warning("the warning window is unavailable: %s", exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
root.withdraw()
|
||||||
|
root.title(TITLE)
|
||||||
|
root.attributes("-topmost", True)
|
||||||
|
root.resizable(False, False)
|
||||||
|
|
||||||
|
frame = tk.Frame(root, padx=18, pady=16)
|
||||||
|
frame.pack(fill="both", expand=True)
|
||||||
|
heading = tk.Label(frame, text="Screen time is over", font=("Segoe UI", 14, "bold"), anchor="w")
|
||||||
|
heading.pack(fill="x")
|
||||||
|
reason_label = tk.Label(frame, text="", wraplength=380, justify="left", anchor="w")
|
||||||
|
reason_label.pack(fill="x", pady=(8, 0))
|
||||||
|
until_label = tk.Label(frame, text="", anchor="w", font=("Segoe UI", 9))
|
||||||
|
until_label.pack(fill="x", pady=(6, 0))
|
||||||
|
blocked_label = tk.Label(frame, text="", wraplength=380, justify="left", anchor="w")
|
||||||
|
blocked_label.pack(fill="x", pady=(8, 0))
|
||||||
|
tk.Button(frame, text="Close warning", command=root.withdraw).pack(anchor="e", pady=(12, 0))
|
||||||
|
root.geometry("420x260")
|
||||||
|
|
||||||
|
self.available = True
|
||||||
|
log.info("warning window is available")
|
||||||
|
|
||||||
|
def drain() -> None: # pragma: no cover - needs a desktop
|
||||||
|
stop = False
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
action, payload = self._queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
if action == "show" and isinstance(payload, Notice):
|
||||||
|
reason_label.config(text=f"The rules say: {payload.reason}.")
|
||||||
|
until_label.config(text=f"Until {payload.until}." if payload.until else "No end time is set.")
|
||||||
|
blocked_label.config(text=payload.blocked_text)
|
||||||
|
root.deiconify()
|
||||||
|
root.lift()
|
||||||
|
root.attributes("-topmost", True)
|
||||||
|
place_bottom_right(root)
|
||||||
|
elif action == "hide":
|
||||||
|
root.withdraw()
|
||||||
|
elif action == "stop":
|
||||||
|
stop = True
|
||||||
|
if stop:
|
||||||
|
root.destroy()
|
||||||
|
return
|
||||||
|
root.after(200, drain)
|
||||||
|
|
||||||
|
root.after(200, drain)
|
||||||
|
try:
|
||||||
|
root.mainloop()
|
||||||
|
except Exception as exc: # noqa: BLE001 - nothing left to do
|
||||||
|
log.debug("warning window loop ended: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def place_bottom_right(root) -> None: # pragma: no cover - needs a desktop
|
||||||
|
"""Park the window bottom-right, out of the way of what it is covering."""
|
||||||
|
root.update_idletasks()
|
||||||
|
width = root.winfo_width() or 420
|
||||||
|
height = root.winfo_height() or 260
|
||||||
|
screen = root.winfo_screenwidth(), root.winfo_screenheight()
|
||||||
|
root.geometry(f"{width}x{height}+{max(screen[0] - width - 24, 0)}+{max(screen[1] - height - 72, 0)}")
|
||||||
263
windows/webrestrict/process_watch.py
Normal file
263
windows/webrestrict/process_watch.py
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
"""The Windows side of enforcement: process list, window list, foregound app.
|
||||||
|
|
||||||
|
Everything here touches the real machine, so it is kept apart from the pure
|
||||||
|
decision in :mod:`webrestrict.blocker`. ``psutil`` is the only third-party
|
||||||
|
dependency; the window queries go through ``ctypes`` and ``user32`` so the
|
||||||
|
client needs no ``pywin32`` install.
|
||||||
|
|
||||||
|
Off Windows (unit tests, a lab box) every function degrades to an empty result
|
||||||
|
instead of raising, so the client stays importable and testable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from .blocker import Victim, select_victims, self_protection
|
||||||
|
from .config import DeviceConfig, bare
|
||||||
|
|
||||||
|
log = logging.getLogger("webrestrict.process")
|
||||||
|
|
||||||
|
IS_WINDOWS = os.name == "nt"
|
||||||
|
|
||||||
|
# How long to give a polite terminate() before the hard kill(), in seconds.
|
||||||
|
TERMINATE_GRACE_SECONDS = 3.0
|
||||||
|
|
||||||
|
|
||||||
|
def _psutil():
|
||||||
|
"""Import psutil on demand, with a readable error when it is missing."""
|
||||||
|
try:
|
||||||
|
import psutil # noqa: PLC0415 (deliberate: keeps the import cost local)
|
||||||
|
except ImportError as exc: # pragma: no cover - depends on the machine
|
||||||
|
raise RuntimeError(
|
||||||
|
"psutil is not installed; run: python -m pip install -r windows/requirements.txt"
|
||||||
|
) from exc
|
||||||
|
return psutil
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_processes() -> list[tuple[int, str]]:
|
||||||
|
"""Every running process as ``(pid, name)``.
|
||||||
|
|
||||||
|
A process that exits while the list is built, or that denies access, is
|
||||||
|
skipped: it is not a reason to fail an enforcement tick.
|
||||||
|
"""
|
||||||
|
psutil = _psutil()
|
||||||
|
out: list[tuple[int, str]] = []
|
||||||
|
for proc in psutil.process_iter(["pid", "name"]):
|
||||||
|
try:
|
||||||
|
info = proc.info
|
||||||
|
name = info.get("name") or ""
|
||||||
|
pid = int(info.get("pid") or 0)
|
||||||
|
except Exception: # noqa: BLE001 - a dead process is normal
|
||||||
|
continue
|
||||||
|
if pid and name:
|
||||||
|
out.append((pid, name))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def kill_process(pid: int, name: str) -> str:
|
||||||
|
"""Terminate one process. Returns ``"terminated"`` or ``"killed"``.
|
||||||
|
|
||||||
|
``terminate()`` asks the app to close, which is exactly what a scheduled
|
||||||
|
window does to a game. If it is still alive after a short grace, ``kill()``
|
||||||
|
ends it. Access denied is reported, not raised: the machine keeps running.
|
||||||
|
"""
|
||||||
|
psutil = _psutil()
|
||||||
|
try:
|
||||||
|
proc = psutil.Process(pid)
|
||||||
|
except Exception as exc: # noqa: BLE001 - the process is already gone
|
||||||
|
log.debug("process %s (%s) vanished: %s", pid, name, exc)
|
||||||
|
return "gone"
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
except Exception as exc: # noqa: BLE001 - e.g. AccessDenied for another user
|
||||||
|
log.warning("terminate %s (%s) failed: %s", pid, name, exc)
|
||||||
|
return "failed"
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=TERMINATE_GRACE_SECONDS)
|
||||||
|
return "terminated"
|
||||||
|
except Exception: # noqa: BLE001 - still running, escalate
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
proc.wait(timeout=TERMINATE_GRACE_SECONDS)
|
||||||
|
return "killed"
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("kill %s (%s) failed: %s", pid, name, exc)
|
||||||
|
return "failed"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Windows window probes (ctypes; no pywin32) --------------------------
|
||||||
|
|
||||||
|
_WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p) if IS_WINDOWS else None
|
||||||
|
|
||||||
|
|
||||||
|
def _user32():
|
||||||
|
return ctypes.windll.user32 # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
|
def window_pids() -> set[int]:
|
||||||
|
"""Pids that own a visible, titled top-level window.
|
||||||
|
|
||||||
|
That is the closest cheap proxy Windows offers for "the child can see this
|
||||||
|
app". A background app with a hidden window does not count.
|
||||||
|
"""
|
||||||
|
if not IS_WINDOWS: # pragma: no cover - platform dependent
|
||||||
|
return set()
|
||||||
|
|
||||||
|
user32 = _user32()
|
||||||
|
pids: set[int] = set()
|
||||||
|
|
||||||
|
def callback(hwnd, _lparam): # pragma: no cover - platform dependent
|
||||||
|
try:
|
||||||
|
if not user32.IsWindowVisible(hwnd):
|
||||||
|
return True
|
||||||
|
if user32.GetWindowTextLengthW(hwnd) == 0:
|
||||||
|
return True
|
||||||
|
pid = ctypes.c_ulong(0)
|
||||||
|
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||||
|
if pid.value:
|
||||||
|
pids.add(int(pid.value))
|
||||||
|
except Exception: # noqa: BLE001 - never let a callback break the enum
|
||||||
|
return True
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
user32.EnumWindows(_WNDENUMPROC(callback), 0)
|
||||||
|
except Exception as exc: # noqa: BLE001 - pragma: no cover
|
||||||
|
log.debug("EnumWindows failed: %s", exc)
|
||||||
|
return pids
|
||||||
|
|
||||||
|
|
||||||
|
def foreground_pid() -> int | None:
|
||||||
|
"""The pid of the window the child is using, or ``None`` off Windows."""
|
||||||
|
if not IS_WINDOWS: # pragma: no cover - platform dependent
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
hwnd = _user32().GetForegroundWindow()
|
||||||
|
if not hwnd:
|
||||||
|
return None
|
||||||
|
pid = ctypes.c_ulong(0)
|
||||||
|
_user32().GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||||
|
return int(pid.value) or None
|
||||||
|
except Exception: # noqa: BLE001 - pragma: no cover
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def process_name(pid: int) -> str:
|
||||||
|
"""The image name for one pid, or an empty string."""
|
||||||
|
psutil = _psutil()
|
||||||
|
try:
|
||||||
|
return psutil.Process(pid).name() or ""
|
||||||
|
except Exception: # noqa: BLE001 - the process ended
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def foreground_app() -> str:
|
||||||
|
"""The foreground app's image name, for the status report (FR-9)."""
|
||||||
|
pid = foreground_pid()
|
||||||
|
return process_name(pid) if pid else ""
|
||||||
|
|
||||||
|
|
||||||
|
def visible_apps(exclude_pids: Iterable[int] | None = None) -> list[str]:
|
||||||
|
"""Names of processes with a visible, titled window right now.
|
||||||
|
|
||||||
|
The client's own process is excluded by default: its warning window has a
|
||||||
|
title and would otherwise be counted as the child's usage.
|
||||||
|
"""
|
||||||
|
pids = window_pids()
|
||||||
|
if not pids:
|
||||||
|
return []
|
||||||
|
skip = {int(pid) for pid in (exclude_pids if exclude_pids is not None else (os.getpid(),))}
|
||||||
|
pids -= skip
|
||||||
|
return sorted({name for name in (process_name(pid) for pid in pids) if name})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EnforcementReport:
|
||||||
|
"""What one tick did, for the log and the status report."""
|
||||||
|
|
||||||
|
blocking: bool
|
||||||
|
blocked_names: tuple[str, ...]
|
||||||
|
killed: tuple[Victim, ...]
|
||||||
|
failed: tuple[Victim, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def killed_names(self) -> list[str]:
|
||||||
|
return sorted({victim.matched for victim in self.killed})
|
||||||
|
|
||||||
|
|
||||||
|
class Enforcer:
|
||||||
|
"""Terminates blocked processes while the device is effectively locked."""
|
||||||
|
|
||||||
|
def __init__(self, protected: Iterable[str], log_extra: logging.Logger | None = None):
|
||||||
|
self.log = log_extra or log
|
||||||
|
self.protected_names = {bare(name) for name in protected if name and name.strip()}
|
||||||
|
self.protected_pids: set[int] = set()
|
||||||
|
# The client's own process tree is never a victim, even if a blocklist
|
||||||
|
# names python.exe: killing itself would end enforcement silently.
|
||||||
|
parents = _parent_pids(os.getpid())
|
||||||
|
pids, names = self_protection(os.getpid(), {os.path.basename(sys.executable), "python", "pythonw"}, parents)
|
||||||
|
self.protected_pids |= pids
|
||||||
|
self.protected_names |= {bare(name) for name in names}
|
||||||
|
|
||||||
|
def enforce(self, config: DeviceConfig, now: datetime | None = None) -> EnforcementReport:
|
||||||
|
"""One enforcement tick: find the victims, kill them, report the result."""
|
||||||
|
blocking = config.is_blocking(now)
|
||||||
|
victims = select_victims(
|
||||||
|
snapshot_processes(),
|
||||||
|
config,
|
||||||
|
now=now,
|
||||||
|
protected_names=self.protected_names,
|
||||||
|
protected_pids=self.protected_pids,
|
||||||
|
)
|
||||||
|
killed: list[Victim] = []
|
||||||
|
failed: list[Victim] = []
|
||||||
|
for victim in victims:
|
||||||
|
outcome = kill_process(victim.pid, victim.name)
|
||||||
|
if outcome in ("terminated", "killed"):
|
||||||
|
self.log.info(
|
||||||
|
"blocked %s (pid=%s, rule=%s): %s", victim.name, victim.pid, victim.matched, outcome
|
||||||
|
)
|
||||||
|
killed.append(victim)
|
||||||
|
elif outcome == "failed":
|
||||||
|
failed.append(victim)
|
||||||
|
if killed:
|
||||||
|
self.log.info("enforcement killed %d process(es): %s", len(killed), ", ".join(
|
||||||
|
f"{v.name}({v.pid})" for v in killed
|
||||||
|
))
|
||||||
|
return EnforcementReport(
|
||||||
|
blocking=blocking,
|
||||||
|
blocked_names=tuple(sorted(config.blocked_names())),
|
||||||
|
killed=tuple(killed),
|
||||||
|
failed=tuple(failed),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parent_pids(pid: int) -> set[int]:
|
||||||
|
"""The pid's ancestor chain, skipping failures."""
|
||||||
|
psutil = _psutil()
|
||||||
|
out: set[int] = set()
|
||||||
|
try:
|
||||||
|
proc = psutil.Process(pid)
|
||||||
|
except Exception: # noqa: BLE001 - pragma: no cover
|
||||||
|
return out
|
||||||
|
for _ in range(10):
|
||||||
|
try:
|
||||||
|
parent = proc.parent()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
break
|
||||||
|
if parent is None or parent.pid in out:
|
||||||
|
break
|
||||||
|
out.add(parent.pid)
|
||||||
|
proc = parent
|
||||||
|
return out
|
||||||
178
windows/webrestrict/settings.py
Normal file
178
windows/webrestrict/settings.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"""Client settings, read from one JSON file (AGENTS.md §11 Phase 4).
|
||||||
|
|
||||||
|
Everything the client needs is local configuration: the broker address, the
|
||||||
|
device's own topic and MQTT account, and where to keep its state. Nothing here
|
||||||
|
is a second source of truth — the Control Service still owns the rules.
|
||||||
|
|
||||||
|
The file is provisioned on the machine and is **never** committed. It holds the
|
||||||
|
device's MQTT password, which must stay off the repository (AGENTS.md §10 rule
|
||||||
|
8). The path comes from ``--config``, then ``WEBRESTRICT_CONFIG``, then
|
||||||
|
``%PROGRAMDATA%\\WebRestrict\\client.json``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Default location. ProgramData is machine-wide and readable by the New Comp
|
||||||
|
# account, which is what the logon Scheduled Task runs as.
|
||||||
|
DEFAULT_CONFIG_NAME = "client.json"
|
||||||
|
DEFAULT_STATE_DIR_NAME = "WebRestrict"
|
||||||
|
|
||||||
|
# Never kill these, whatever a blocklist says. The client must not be able to
|
||||||
|
# take the machine down, and it must not kill itself (it runs as python.exe).
|
||||||
|
# The client's own interpreter and its parent chain are added at runtime.
|
||||||
|
DEFAULT_PROTECTED: tuple[str, ...] = (
|
||||||
|
"system",
|
||||||
|
"system idle process",
|
||||||
|
"registry",
|
||||||
|
"smss",
|
||||||
|
"csrss",
|
||||||
|
"wininit",
|
||||||
|
"winlogon",
|
||||||
|
"services",
|
||||||
|
"lsass",
|
||||||
|
"svchost",
|
||||||
|
"dwm",
|
||||||
|
"explorer",
|
||||||
|
"taskhostw",
|
||||||
|
"sihost",
|
||||||
|
"ctfmon",
|
||||||
|
"conhost",
|
||||||
|
"fontdrvhost",
|
||||||
|
"audiodg",
|
||||||
|
"msmpeng",
|
||||||
|
"securityhealthservice",
|
||||||
|
"searchindexer",
|
||||||
|
"startmenuexperiencehost",
|
||||||
|
"shellexperiencehost",
|
||||||
|
"textinputhost",
|
||||||
|
"applicationframehost",
|
||||||
|
"python",
|
||||||
|
"pythonw",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def default_program_data() -> Path:
|
||||||
|
"""The machine-wide directory for client configuration and state."""
|
||||||
|
base = os.environ.get("PROGRAMDATA") or os.environ.get("ALLUSERSPROFILE")
|
||||||
|
if base:
|
||||||
|
return Path(base) / DEFAULT_STATE_DIR_NAME
|
||||||
|
# Not Windows (unit tests, a lab box): keep it under the user's home.
|
||||||
|
return Path.home() / ".webrestrict"
|
||||||
|
|
||||||
|
|
||||||
|
def default_config_path() -> Path:
|
||||||
|
"""The config path: ``WEBRESTRICT_CONFIG`` wins, else ``%PROGRAMDATA%``."""
|
||||||
|
override = os.environ.get("WEBRESTRICT_CONFIG")
|
||||||
|
if override:
|
||||||
|
return Path(override)
|
||||||
|
return default_program_data() / DEFAULT_CONFIG_NAME
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Settings:
|
||||||
|
"""Everything the client needs to run, with safe lab defaults.
|
||||||
|
|
||||||
|
A missing field falls back to the default, so a hand-written config file
|
||||||
|
only needs the values that differ. Unknown fields are ignored, so the file
|
||||||
|
may grow without a client update.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# --- the link ---------------------------------------------------------
|
||||||
|
broker_host: str = "192.168.20.13"
|
||||||
|
broker_port: int = 8883
|
||||||
|
ca_file: str = "" # empty = the platform trust store
|
||||||
|
device_topic: str = ""
|
||||||
|
mqtt_username: str = ""
|
||||||
|
mqtt_password: str = ""
|
||||||
|
client_id: str = "" # empty = derive from the topic token
|
||||||
|
|
||||||
|
# --- local state ------------------------------------------------------
|
||||||
|
state_dir: str = ""
|
||||||
|
|
||||||
|
# --- timings (seconds) ------------------------------------------------
|
||||||
|
poll_seconds: float = 5.0
|
||||||
|
telemetry_seconds: float = 60.0
|
||||||
|
status_seconds: float = 60.0
|
||||||
|
connect_timeout_seconds: float = 10.0
|
||||||
|
|
||||||
|
# --- behaviour --------------------------------------------------------
|
||||||
|
overlay: bool = True
|
||||||
|
protected: list[str] = field(default_factory=lambda: list(DEFAULT_PROTECTED))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
@property
|
||||||
|
def state_path(self) -> Path:
|
||||||
|
"""The directory holding the cached config, the usage roll-up and the log."""
|
||||||
|
if self.state_dir:
|
||||||
|
return Path(self.state_dir)
|
||||||
|
if self.device_token:
|
||||||
|
return default_program_data() / self.device_token
|
||||||
|
return default_program_data()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_token(self) -> str:
|
||||||
|
"""The middle topic level, which is also the broker account name."""
|
||||||
|
base = self.device_topic.removesuffix("/config")
|
||||||
|
return base.rsplit("/", 1)[-1] if "/" in base else base
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status_topic(self) -> str:
|
||||||
|
return self.device_topic.removesuffix("/config") + "/status"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def telemetry_topic(self) -> str:
|
||||||
|
return self.device_topic.removesuffix("/config") + "/telemetry"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_client_id(self) -> str:
|
||||||
|
return self.client_id or f"kid-{self.device_token or 'windows'}"
|
||||||
|
|
||||||
|
def config_path(self) -> Path:
|
||||||
|
return self.state_path / "config-cache.json"
|
||||||
|
|
||||||
|
def telemetry_path(self) -> Path:
|
||||||
|
return self.state_path / "telemetry.json"
|
||||||
|
|
||||||
|
def log_path(self) -> Path:
|
||||||
|
return self.state_path / "client.log"
|
||||||
|
|
||||||
|
def validate(self) -> None:
|
||||||
|
"""Raise when the client cannot possibly work, instead of failing later."""
|
||||||
|
if not self.device_topic:
|
||||||
|
raise ValueError("device_topic is not set (e.g. restrict/finn_s_computer_2/config)")
|
||||||
|
if not self.device_topic.endswith("/config"):
|
||||||
|
raise ValueError(f"device_topic must end with /config: {self.device_topic!r}")
|
||||||
|
if not self.broker_host:
|
||||||
|
raise ValueError("broker_host is not set")
|
||||||
|
if not 1 <= self.broker_port <= 65535:
|
||||||
|
raise ValueError(f"broker_port is out of range: {self.broker_port}")
|
||||||
|
if not self.mqtt_username:
|
||||||
|
raise ValueError("mqtt_username is not set (it is the topic token)")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, raw: dict[str, Any]) -> "Settings":
|
||||||
|
"""Build settings from a parsed JSON object, ignoring unknown keys."""
|
||||||
|
known = {f for f in cls.__dataclass_fields__} # type: ignore[attr-defined]
|
||||||
|
data = {k: v for k, v in raw.items() if k in known}
|
||||||
|
settings = cls(**data)
|
||||||
|
settings.broker_port = int(settings.broker_port)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, path: str | os.PathLike[str] | None = None) -> "Settings":
|
||||||
|
"""Read the config file and validate it."""
|
||||||
|
target = Path(path) if path else default_config_path()
|
||||||
|
with open(target, "r", encoding="utf-8") as handle:
|
||||||
|
raw = json.load(handle)
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError(f"{target}: expected a JSON object")
|
||||||
|
settings = cls.from_dict(raw)
|
||||||
|
settings.validate()
|
||||||
|
return settings
|
||||||
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