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:
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())
|
||||
Reference in New Issue
Block a user