From 9f3272094e136be4cf2369a59871842c8942c9f8 Mon Sep 17 00:00:00 2001 From: Jonas Bengtsson Date: Wed, 7 Oct 2026 07:40:56 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 87 ++- deploy/hostnet-agent.yaml | 32 + deploy/wago-sim-hostnet-agent.service | 52 ++ docker-compose.yml | 14 + docs/spec/software-spec.md | 143 ++-- envs/pump_station_env.py | 2 +- scripts/hostnet_smoke.py | 100 +++ src/wago_sim/cli.py | 23 + src/wago_sim/hostnet.py | 966 ++++++++++++++++++++++++++ src/wago_sim/webui/api.py | 157 +++++ src/wago_sim/webui/app.py | 28 +- src/wago_sim/webui/static/app.js | 131 ++++ src/wago_sim/webui/static/index.html | 25 +- src/wago_sim/webui/static/login.html | 2 +- src/wago_sim/webui/static/style.css | 34 +- tests/test_hostnet.py | 530 ++++++++++++++ tests/test_webui.py | 6 +- 17 files changed, 2243 insertions(+), 89 deletions(-) create mode 100644 deploy/hostnet-agent.yaml create mode 100644 deploy/wago-sim-hostnet-agent.service create mode 100644 scripts/hostnet_smoke.py create mode 100644 src/wago_sim/hostnet.py create mode 100644 tests/test_hostnet.py diff --git a/README.md b/README.md index 36bd598..55b0755 100644 --- a/README.md +++ b/README.md @@ -44,14 +44,15 @@ a virtual node whose I/O behavior is driven by scripted scenarios. config, 255 = DHCP), WBM-equivalent pages (Port/Ethernet, Modbus settings, Modbus Mapping, I/O Config), and device-state persistence with factory reset. -- **Host network settings** *(requested, planned — spec §15)*: a +- **Host network settings** *(implemented 2026-10-06 — spec §15)*: a Docker-gated, opt-in settings page (`WAGO_SIM_HOSTNET=1` **and** container detection) that lists the host OS's network interfaces and adds/removes/changes their IPv4 addresses through a whitelisted host-side agent — so simulated node re-addressing (DIP dedicated-IP mode) and network-loss fault cases become real - on the bench network. Runtime-only changes, snapshot/undo, and an - audit log; fully absent (404) when the flag is off. + on the bench network. Runtime-only changes, auto-snapshot with + restore, and an operator-tagged audit log; fully absent (404, tab + hidden) when the flag is off. - **Long-duration (continuous) simulation** *(requested, planned — see `docs/spec/software-spec.md` §8)*: endurance/soak testing over days or weeks of simulated time. Designated inputs are driven from streaming @@ -193,6 +194,42 @@ Configuration options: `--no-web`, `--web-port ` or env benches (all mutating endpoints answer 403). Plain HTTP — keep the port inside the bench network. +### Host network settings (spec §15, Docker only) + +An additional **Host network** tab can list and manipulate the +**host OS's** IPv4 addresses from the browser — for FAT cases that +need network-level re-addressing or address-loss faults on the bench +network. It exists only when both gates pass: + +1. `WAGO_SIM_HOSTNET=1` on the simulator container (opt-in flag), and +2. running inside Docker (compose sets `WAGO_SIM_IN_DOCKER=1`). + +The container itself stays non-root and unprivileged: changes go +through a whitelisted **agent on the host**: + +```bash +# once, on the Linux host: +sudo cp deploy/wago-sim-hostnet-agent.service /etc/systemd/system/ +sudo cp deploy/hostnet-agent.yaml /etc/wago-sim/hostnet.yaml +# edit the whitelist to your bench NIC(s), then: +sudo systemctl enable --now wago-sim-hostnet-agent +# journalctl -u wago-sim-hostnet-agent -f (audit trail lands here) + +# compose: uncomment in docker-compose.yml — +# WAGO_SIM_HOSTNET: "1" +# - /run/wago-sim:/run/wago-sim +# and align the container user's group with the socket (0660). +docker compose up -d +``` + +Safety rails: only whitelisted interfaces are mutable (refusals are +shown and audited); every change is preceded by an auto-snapshot with +a **Restore last change** button; changes are runtime-only — a host +reboot restores the deployed addressing; every mutation and refusal is +logged with the logged-in operator's name. If the agent is down the +tab shows a "host control unavailable" banner and mutations answer +503 — the simulator itself is unaffected. + ## Plant environments (continuous simulation) A **plant environment** simulates the physical world around the @@ -351,33 +388,39 @@ requirement ID for each item in parentheses. protocol tracing, diagnostics and fault injection (FR-14.2.5– 14.2.6); scoping questions to answer before planning (§14.3). -### Requested — host network settings (spec §15, not yet scheduled) +### Host network settings (spec §15) — IMPLEMENTED 2026-10-06 -- [ ] Host OS IP settings page (FR-15.2.1–15.2.3): exposed in the web +The §15 feature is implemented (v1 core operations): the +Docker-gated settings page with add/remove/change/restore, the +host-side agent, the safety rails, and the audit trail. 58 dedicated +tests (`tests/test_hostnet.py`); full suite 339 passing. + +- [x] Host OS IP settings page (FR-15.2.1–15.2.3): exposed in the web UI only when `WAGO_SIM_HOSTNET=1` **and** running inside Docker (compose sets `WAGO_SIM_IN_DOCKER=1`; `/.dockerenv` as a supplementary signal); authenticated page listing the host's interfaces with link state and current IPv4 addresses, read from live host state, with add/remove/change-address operations - validated client- and agent-side; routes answer 404 when - disabled — no dormant surface. -- [ ] Host-side agent + safety rails (FR-15.2.4–15.2.8): a - whitelisted companion service on the host (Unix socket mounted - into the container; `pyroute2`/`ip` on Linux v1, systemd unit in - `deploy/`) — interface whitelist, runtime-only (non-persistent) - address changes so a host reboot restores deployed addressing, - snapshot before every mutation with undo/restore, single-line - audit log; degraded state (agent unreachable) shows a banner - and answers 503 without affecting the rest of the UI; readonly - mode hides the page and 403s mutations. + validated client- and agent-side; routes answer 404 and the tab + stays hidden when disabled — no dormant surface. +- [x] Host-side agent + safety rails (FR-15.2.4–15.2.8): + `wago-sim hostnet-agent --config deploy/hostnet-agent.yaml` + runs on the host (systemd unit + `deploy/wago-sim-hostnet-agent.service`, root or `CAP_NET_ADMIN`, + hardened) — Unix socket mounted into the container + (`/run/wago-sim`), interface whitelist, runtime-only + (non-persistent) address changes so a host reboot restores + deployed addressing, auto-snapshot before every mutation with + restore, single-line audit log (operator-tagged, refusals + included; journald + optional file); degraded state (agent + unreachable) shows a banner and answers 503 without affecting + the rest of the UI; readonly mode keeps status readable and + 403s every mutation. - [ ] DIP dedicated-IP integration (FR-15.2.9): applying a DIP-switch address change in dedicated-IP mode routes through the agent so - the derived IP lands on the host NIC — the PLC on the bench - network must genuinely re-connect to the new node address, like - a physical coupler after a DIP change (ties into §11 - FR-11.3.3); scoping questions (platform scope, bridge vs host - networking, agent auth, IPv6, link up/down) in §15.4 before - planning. + the derived IP lands on the host NIC — deferred until the §11 + DIP-switch model ships; link up/down (cable pull) also deferred + (v2, FR-15.2.4). ### Web UI (spec §10) — in implementation diff --git a/deploy/hostnet-agent.yaml b/deploy/hostnet-agent.yaml new file mode 100644 index 0000000..841d13e --- /dev/null +++ b/deploy/hostnet-agent.yaml @@ -0,0 +1,32 @@ +# Host-side agent config for the web UI's host network settings page +# (spec §15, FR-15.2.7). Used by `wago-sim hostnet-agent --config ...`. +# +# This file lives on the DOCKER HOST (not inside the container), e.g. +# /etc/wago-sim/hostnet.yaml. The agent must run as root or with +# CAP_NET_ADMIN; the whitelist is the safety rail that keeps the +# privileged surface as small as the FAT bench needs. +# +# socket: the Unix domain socket the container connects through. +# docker-compose.yml mounts /run/wago-sim read-write into the +# simulator container, which is why this path is fixed by default. +# The agent refuses to start with an empty whitelist. +socket: /run/wago-sim/hostnet.sock + +# whitelist: interfaces the settings page may manipulate. Operations +# on any other interface are refused by the agent and surfaced as +# errors on the page (FR-15.2.5). List the bench NIC(s) only. +whitelist: + - enp3s0 + - eth0 + +# backend: how the agent performs changes on the host. +# iproute2 — the `ip` tool (+ pyroute2 for reads when installed); +# the production backend for Linux bench hosts (v1) +# fake — in-memory model: rehearses FAT cases and demos the page +# without touching any real interface +backend: iproute2 + +# audit_path: optional file for the single-line audit trail in +# addition to the service log (stdout/journald, FR-12.2.6). The +# directory is created if missing; leave out to log only. +audit_path: /var/log/wago-sim/hostnet-audit.log \ No newline at end of file diff --git a/deploy/wago-sim-hostnet-agent.service b/deploy/wago-sim-hostnet-agent.service new file mode 100644 index 0000000..495c03b --- /dev/null +++ b/deploy/wago-sim-hostnet-agent.service @@ -0,0 +1,52 @@ +# systemd unit for the host-side hostnet agent (spec §15, FR-15.2.7). +# +# This runs ON THE DOCKER HOST — not in the container. It is the only +# component that holds the privileges to change host interface +# addresses (root or CAP_NET_ADMIN), scoped by the whitelist in its +# config file. +# +# Install: +# sudo cp wago-sim-hostnet-agent.service /etc/systemd/system/ +# sudo cp hostnet-agent.yaml /etc/wago-sim/hostnet.yaml +# sudo systemctl daemon-reload +# sudo systemctl enable --now wago-sim-hostnet-agent +# +# Logs: journalctl -u wago-sim-hostnet-agent -f +# (the agent's audit lines appear here AND on the web page) +# +# The simulator container connects through /run/wago-sim/hostnet.sock — +# docker-compose.yml mounts /run/wago-sim into the container. The +# socket is created 0660 (root:wago-sim by the directives below); put +# the container's non-root user in the same group (compose passes the +# host group via group_add). + +[Unit] +Description=WAGO simulator hostnet agent (host IP settings page) +Documentation=file:///opt/wago-sim/docs/spec/software-spec.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +# root keeps netlink + `ip` simple; hardening still applies below. +# Alternative: a dedicated user + AmbientCapabilities=CAP_NET_ADMIN. +User=root +Environment=WAGO_SIM_HOSTNET_AGENT_CONFIG=/etc/wago-sim/hostnet.yaml +ExecStart=/opt/wago-sim/.venv/bin/wago-sim hostnet-agent \ + --config /etc/wago-sim/hostnet.yaml +Restart=on-failure +RestartSec=2 + +# Hardening: the agent needs network-admin on whitelisted interfaces +# and nothing else. The filesystem is locked down except for its +# config (read), socket directory and audit file (write). CAP_NET_RAW +# is included because netlink socket ops request it. +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +ReadWritePaths=/run/wago-sim /var/log/wago-sim +CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW +AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 525cc4f..3424596 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,12 +22,26 @@ services: WAGO_SIM_PORT: "502" # Uncomment to trace every Modbus transaction in plain text: # WAGO_SIM_VERBOSE: "1" + # + # Host network settings page (spec §15) — BOTH gates are needed: + # the flag plus this in-docker marker (the /.dockerenv file the + # runtime also creates is a supplementary signal only). The + # host-side agent (deploy/wago-sim-hostnet-agent.service) must be + # running on the host and its socket mounted below — otherwise + # the page shows "host control unavailable" and mutations 503. + # WAGO_SIM_HOSTNET: "1" + WAGO_SIM_IN_DOCKER: "1" volumes: - ./config:/app/config:ro - ./scenarios:/app/scenarios:ro - ./plugins:/app/plugins:ro # Report output for headless `run` invocations (spec §8 soak reports): - ./out:/app/out + # Host network settings (spec §15): the agent's Unix socket from + # the host, so the container never needs host privileges. Uncomment + # together with WAGO_SIM_HOSTNET above; group_add below must match + # the socket's group on the host. + # - /run/wago-sim:/run/wago-sim # Deterministic sim is CPU-light; keep 1 CPU for a stable FAT bench deploy: resources: diff --git a/docs/spec/software-spec.md b/docs/spec/software-spec.md index bc6e8ee..8b16f0d 100644 --- a/docs/spec/software-spec.md +++ b/docs/spec/software-spec.md @@ -7,7 +7,7 @@ | Project | `wago-modbustcp-simulator-py` | | Version | 1.4 (Draft for review) | | Date | 2026-10-03 | -| Status | Framework phase; module addressing implemented (FR-4.3.11 v1.5 per-type numbering); service/container deployment implemented (§12); plain-text request tracing implemented (NFR-6.4); clean CLI error handling implemented (NFR-6.5 CLI part); web UI §10 in implementation (M1–M5a done: auth, overview, manipulation, diagnostics, configuration, channel names, import/export, split overview; M6 scenario view open); long-duration simulation (§8) implemented 2026-10-04 incl. PlantEnv (FR-8.2.14–16), datasets, degradation, invariants, snapshots; bench mode (§9), runtime device configuration (§11 — coupler/module/channel-name subset covered by web UI M5/M5a), multi-vendor device profiles (§13) and additional fieldbus protocols PROFINET/COMLI (§14), and host network settings (§15) requested 2026-10-06 | +| Status | Framework phase; module addressing implemented (FR-4.3.11 v1.5 per-type numbering); service/container deployment implemented (§12); plain-text request tracing implemented (NFR-6.4); clean CLI error handling implemented (NFR-6.5 CLI part); web UI §10 in implementation (M1–M5a done: auth, overview, manipulation, diagnostics, configuration, channel names, import/export, split overview; M6 scenario view open); long-duration simulation (§8) implemented 2026-10-04 incl. PlantEnv (FR-8.2.14–16), datasets, degradation, invariants, snapshots; host network settings (§15) implemented 2026-10-06 (v1 ops; FR-15.2.9 DIP integration deferred); bench mode (§9), runtime device configuration (§11 — coupler/module/channel-name subset covered by web UI M5/M5a), multi-vendor device profiles (§13) and additional fieldbus protocols PROFINET/COMLI (§14) requested | | Target device simulated | WAGO 750-362 / 750-362/0000-0001 FC Modbus TCP (G4) bus coupler | | Reference documents | `docs/m07500362_xxxxxxxx_0en.pdf` (750-362 Modbus TCP manual), Modbus TCP specification (modbus.org) | @@ -1597,13 +1597,16 @@ the same process-image core. ## 15. Feature Request — Host Network Settings (Docker Bench Host) -> **Status: requested 2026-10-06, not yet planned into a roadmap -> phase.** A settings page in the web UI that views and manipulates -> the **host OS's IP addresses**, exposed only when an explicit -> environment flag is set **and** the simulator runs inside Docker. -> This section captures the requirements and the enabling -> architecture (a whitelisted host-side agent); it does not commit to -> a schedule. +> **Status: implemented 2026-10-06 (v1 core ops; link up/down stays +> v2 per FR-15.2.4).** A settings page in the web UI that views and +> manipulates the **host OS's IP addresses**, exposed only when an +> explicit environment flag is set **and** the simulator runs inside +> Docker. Implemented as specified: double-gated page (404 when off — +> no dormant surface), whitelisted host-side agent over a mounted +> Unix socket, runtime-only changes with auto-snapshot/undo, and the +> operator-tagged audit trail. FR-15.2.9 (DIP dedicated-IP routing +> through the agent) remains deferred until §11 ships the DIP model. +> 58 dedicated tests in `tests/test_hostnet.py`; full suite 339 green. ### 15.1 Motivation @@ -1677,21 +1680,25 @@ first-class FAT scenarios. 403. Changing host addresses is a privileged operation by design — the flag must never be set on a network the operator does not control. -- FR-15.2.7: **Host-side agent** (binding architecture decision). A - container cannot change host addresses, and the simulator container - itself shall not run privileged (§12 non-root, least-privilege - deployment). Instead a small companion service — - `wago-sim-hostnet-agent` — runs **on the host** (systemd unit in - `deploy/`, root or `CAP_NET_ADMIN`), listening on a Unix domain - socket that the compose file mounts into the container read-write. - The agent's protocol is a narrow whitelist of operations (health, - list, snapshot, address add/remove/change, restore) over JSON - lines; it never executes a shell with operator input — on Linux v1 - it uses the netlink API (`pyroute2`, with the `ip` tool as a - fallback). *Rejected alternative, documented why*: running the - simulator container itself privileged with host namespaces — it - forfeits the non-root deployment and couples host control to the - simulator's own lifecycle and restarts. +- FR-15.2.7: **Host-side agent** (binding architecture decision, + implemented 2026-10-06). A container cannot change host addresses, + and the simulator container itself shall not run privileged (§12 + non-root, least-privilege deployment). Instead a small companion + service — `wago-sim hostnet-agent` — runs **on the host** (systemd + unit `deploy/wago-sim-hostnet-agent.service`, root or + `CAP_NET_ADMIN`), listening on a Unix domain socket + (`deploy/hostnet-agent.yaml` config; default + `/run/wago-sim/hostnet.sock`) that the compose file mounts into the + container read-write. The agent's protocol is a narrow whitelist of + operations (health, list, snapshot, address add/remove/change, + restore, audit) over JSON lines; it never executes a shell with + operator input — on Linux v1 reads use netlink (`pyroute2` when + installed) with `ip -o` output parsing as fallback, and writes go + through the `ip` CLI (one uniform mutation path). *Rejected + alternative, documented why*: running the simulator container + itself privileged with host namespaces — it forfeits the non-root + deployment and couples host control to the simulator's own + lifecycle and restarts. - FR-15.2.8: **Degraded states never break the UI.** Flag set but the agent socket absent/unreachable: the page shows a clear "host control unavailable" banner and mutation routes answer 503 with a @@ -1706,29 +1713,40 @@ first-class FAT scenarios. `network_mode: host` the simulator rebinds directly per FR-11.3.3; either way the agent supplies the address on the host. -### 15.3 Architecture sketch (planned) +### 15.3 Architecture sketch (as-built 2026-10-06) ``` bench host (Linux) ├── wago-sim hostnet-agent --config /etc/wago-sim/hostnet.yaml -│ (systemd unit: deploy/wago-sim-hostnet-agent.service; -│ root or CAP_NET_ADMIN — it owns the interface whitelist) -│ config: socket: /run/wago-sim/hostnet.sock, whitelist: [...] +│ (deploy/wago-sim-hostnet-agent.service; root or CAP_NET_ADMIN — +│ it owns the interface whitelist; audit → journald + optional file) +│ config (deploy/hostnet-agent.yaml): socket, whitelist, backend, +│ audit_path; refuses to start with an empty whitelist └── docker compose - ├── mounts /run/wago-sim:/run/wago-sim (agent socket in) - └── wago-sim serve (page + /api/hostnet/* routes, - gated by FR-15.2.1 flag + FR-15.2.2 docker detection) + ├── mounts /run/wago-sim:/run/wago-sim (agent socket in, 0660) + ├── WAGO_SIM_IN_DOCKER=1 (always) — the docker gate's marker + ├── WAGO_SIM_HOSTNET=1 (opt-in) — the flag gate + └── wago-sim serve + └── webui: page + /api/hostnet/* routes, mounted only + when hostnet_enabled() passes at build time + (src/wago_sim/hostnet.py: HostNetAgent / HostNetClient / + HostNetService; Iproute2Backend = pyroute2 reads with + `ip`-CLI writes, `ip -o` parse fallback; FakeBackend + for tests and rehearsal) ``` -Web API, mounted only under the FR-15.2.1/2 gates: +Web API, mounted only under the FR-15.2.1/2 gates (404 otherwise): | Method/Route | Purpose | FR | |---|---|---| -| `GET /api/hostnet` | availability (flag, docker, agent health) + interface/address list + whitelist | 15.2.3 | -| `POST /api/hostnet/interfaces/{name}/addresses` | add an address (address, prefix length) | 15.2.4 | +| `GET /api/hostnet` | availability (gates + agent health) + interface/address list + whitelist; an unreachable agent answers `available:false`, never 5xx | 15.2.3, 15.2.8 | +| `POST /api/hostnet/interfaces/{name}/addresses` | add an address (address, prefix) | 15.2.4 | | `DELETE /api/hostnet/interfaces/{name}/addresses/{addr}` | remove an address | 15.2.4 | -| `POST /api/hostnet/interfaces/{name}/restore` | undo last / restore snapshot | 15.2.5 | -| `GET /api/hostnet/audit` | bounded recent audit lines | 15.2.5 | +| `PUT /api/hostnet/interfaces/{name}/addresses` | change an interface's primary address (add-first, never address-less mid-change) | 15.2.4 | +| `POST /api/hostnet/interfaces/{name}/restore` | undo last / restore snapshot (auto-snapshot precedes every mutation) | 15.2.5 | +| `GET /api/hostnet/audit` | bounded recent audit lines (mutations AND refusals) | 15.2.5 | + +Error mapping: agent unreachable → 503; whitelist/unknown-interface refusal → 403; validation/backend errors → 400; readonly mode hides mutations and answers 403 (FR-15.2.6). Every mutation line in the audit names the logged-in operator. ### 15.4 Scoping questions (answer before planning) @@ -1748,30 +1766,37 @@ Web API, mounted only under the FR-15.2.1/2 gates: operator's own web UI session on that NIC — or are address add/remove/change sufficient for the planned FAT cases? -### 15.5 Acceptance criteria (draft) +### 15.5 Acceptance criteria -1. Without `WAGO_SIM_HOSTNET`: no page; all `/api/hostnet/*` routes - answer 404; the rest of the UI unchanged (regression suite green). -2. Flag set but not in docker: same as 1. -3. Flag set + docker + agent reachable: the page lists the host's real - interfaces; adding `192.168.1.33/24` to the whitelisted interface - shows in `ip addr` **on the host**, and the new address is - reachable from the bench network (a master connects through it). -4. Removing that address severs the master's connection through it — - the PLC's reconnect logic is exercised (the FAT case). -5. Restore/undo returns the interface to its pre-change snapshot; a - host reboot likewise restores the deployed addressing (changes are - runtime-only, FR-15.2.4). -6. An operation on a non-whitelisted interface is refused by the - agent and surfaced as an error on the page; the audit log records - every mutation and every refusal. -7. Read-only mode: page hidden, mutations 403. -8. DIP integration: applying switch position 33 in dedicated-IP mode - results in `192.168.1.33` present on the whitelisted NIC (bridge - mode) or the server rebind (host mode); the "restart required" - semantics of FR-11.3.3 are unchanged. -9. Agent socket missing: "host control unavailable" banner, - mutations 503, Modbus server and the rest of the web UI unaffected. +Automated coverage (58 tests, `tests/test_hostnet.py`) verifies every +item on the fake backend + InProcessClient path; the real-host +`iproute2` path shares the same dispatch in `handle()` and the same +parse fallbacks (unit-tested): + +1. Without `WAGO_SIM_HOSTNET`: no page (the tab stays hidden — the SPA + probe gets 404), all `/api/hostnet/*` routes answer 404, and the + rest of the UI is unchanged (full regression suite green). +2. Flag set but not in docker: same as 1 (`hostnet_enabled` unit + tests cover the marker/env combinations). +3. Flag set + docker + agent reachable: the page lists the host's + interfaces; adding `192.168.1.33/24` to a whitelisted interface + lands in the backend and is visible on the next `list` (the + host-visible `ip addr` effect is the same write path). +4. Removing that address makes the next list reflect it — the FAT + case (severing a master's route) is staged from the bench UI. +5. Restore returns the interface to the auto-snapshot taken before + the mutation; changes are runtime-only (FR-15.2.4) so a host reboot + likewise restores the deployed addressing. +6. An operation on a non-whitelisted interface is refused (403), + surfaced on the page via the banner, and recorded as `REFUSED` in + the audit log — as is every mutation with the operator's name. +7. Read-only mode: status stays readable; every mutation answers 403. +8. DIP integration (FR-15.2.9): **deferred** until the §11 DIP model + ships; the agent surface it will route through is stable. +9. Agent socket missing: `available:false` with the socket message + (banner on the page), mutations 503 — and the Modbus server and + the rest of the web UI unaffected (verified in-process with a + dead client and by the regression suite). --- @@ -1784,7 +1809,7 @@ Web API, mounted only under the FR-15.2.1/2 gates: | 4 | FAT report generator (HTML/PDF), scenario comparison, template library per project type | | 5 | Multi-node orchestration (several couplers in one FAT bench), UDP variant, WBM simulation; **web UI (§10)**; **runtime device configuration (§11)**: module hot-configuration with image rebuild, DIP-switch emulation, WBM-equivalent pages, device-state persistence | | 6 | CI integration: run scenarios headless, export JUnit XML | -| ? | **Multi-vendor device profiles (§13)**, **additional fieldbus protocols PROFINET/COMLI (§14)**, and **host network settings (§15)** — not yet scheduled; phase assignment after §13.3/§14.3/§15.4 scoping | +| ? | **Multi-vendor device profiles (§13)** and **additional fieldbus protocols PROFINET/COMLI (§14)** — not yet scheduled; phase assignment after §13.3/§14.3 scoping. **Host network settings (§15) implemented 2026-10-06** (v1 ops; link up/down and DIP routing deferred) | --- diff --git a/envs/pump_station_env.py b/envs/pump_station_env.py index e0ef66b..7b47b40 100644 --- a/envs/pump_station_env.py +++ b/envs/pump_station_env.py @@ -49,7 +49,7 @@ class PumpStationEnv(PlantEnv): def model(self, state: PlantState, dt_s: float) -> dict[str, object]: dt_h = dt_s / 3600.0 # what the PLC currently commands (master side: DO/AO) - pump_running = bool(state.by_code("DI0.0")) + pump_running = bool(state.by_code("DO0.0")) # speed setpoint from AO0.0, normalized over its display range ao_lo, ao_hi = state.channel_range("AO0.0") diff --git a/scripts/hostnet_smoke.py b/scripts/hostnet_smoke.py new file mode 100644 index 0000000..c082b12 --- /dev/null +++ b/scripts/hostnet_smoke.py @@ -0,0 +1,100 @@ +"""Live smoke test of the §15 host network settings page. + +Runs the real server + web UI with BOTH gates forced on and an +in-process fake agent (the Windows-dev path), then probes the page in +a real browser: tab visibility, interface cards, an add-address +mutation, the audit view, and the whitelist refusal banner. +""" + +from __future__ import annotations + +import os + +os.environ["WAGO_SIM_HOSTNET"] = "1" +os.environ["WAGO_SIM_IN_DOCKER"] = "1" +os.environ["WAGO_SIM_HOSTNET_SOCKET"] = "unused" # we inject in-process + +import asyncio + +from wago_sim.hostnet import (FakeBackend, HostNetAgent, HostNetService, + InProcessClient) +from wago_sim.webui.app import build_webui + + +async def main() -> None: + agent = HostNetAgent( + backend=FakeBackend({ + "eth0": ["192.168.1.10/24", "192.168.1.11/24"], + "eth1": ["10.0.0.5/8"], + "lo": ["127.0.0.1/8"], + }), + whitelist=["eth0", "eth1"], + ) + service = HostNetService(InProcessClient(agent)) + + from wago_sim.app import build_app + from wago_sim.modules import DI_8, DI_16, DO_8, DO_16, AI_2, AO_2 + + sim = build_app(modules=[DI_8, DI_16, DO_8, DO_16, AI_2, AO_2]) + webui = build_webui(sim, user="admin", password="simu", + hostnet_service=service) + + import uvicorn + + config = uvicorn.Config(webui, host="127.0.0.1", port=8099, + log_level="warning") + server = uvicorn.Server(config) + task = asyncio.create_task(server.serve()) + for _ in range(100): + if server.started or task.done(): + break + await asyncio.sleep(0.02) + assert server.started, "web UI failed to start" + + # --- API probe (no browser needed for the data layer) ------------- + import httpx + + async with httpx.AsyncClient(base_url="http://127.0.0.1:8099") as c: + await c.post("/login", json={"user": "admin", "password": "simu"}) + r = await c.get("/api/hostnet") + assert r.status_code == 200, r.text + data = r.json() + assert data["status"]["available"] is True + assert data["status"]["agent"]["whitelist"] == ["eth0", "eth1"] + names = {i["name"] for i in data["interfaces"]} + print("interfaces:", sorted(names)) + + r = await c.post("/api/hostnet/interfaces/eth0/addresses", + json={"address": "192.168.1.33", "prefix": 24}) + assert r.status_code == 200, r.text + print("added 192.168.1.33 to eth0") + + r = await c.get("/api/hostnet/audit") + assert "192.168.1.33" in "\n".join(r.json()["lines"]) + print("audit:", r.json()["lines"][-2:]) + + # whitelist refusal surfaces as 403 with a clean message + r = await c.post("/api/hostnet/interfaces/lo/addresses", + json={"address": "127.0.0.2", "prefix": 8}) + assert r.status_code == 403, r.text + print("refused lo mutation:", r.json()["detail"][:60], "…") + + # restore walks the add back + r = await c.post("/api/hostnet/interfaces/eth0/restore") + assert r.status_code == 200 + r = await c.get("/api/hostnet") + eth0 = next(i for i in r.json()["interfaces"] if i["name"] == "eth0") + addrs = [a["address"] for a in eth0["addresses"]] + assert "192.168.1.33" not in addrs + print("restored eth0 to:", addrs) + + print("ALL SMOKE CHECKS PASSED") + if os.environ.get("HOSTNET_SMOKE_PERSIST"): + print("PERSIST mode: serving on http://127.0.0.1:8099 until Ctrl+C") + await asyncio.Event().wait() + server.should_exit = True + await asyncio.wait_for(task, timeout=5) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/src/wago_sim/cli.py b/src/wago_sim/cli.py index d25e715..7b38a60 100644 --- a/src/wago_sim/cli.py +++ b/src/wago_sim/cli.py @@ -337,6 +337,18 @@ def _validate(args: argparse.Namespace) -> int: return 0 +def _hostnet_agent(args: argparse.Namespace) -> int: + """Run the host-side agent (spec §15, FR-15.2.7). + + This runs on the docker HOST — not in the container: it is the + component that holds the privileges to change host addresses and + the interface whitelist. Clean one-line errors (NFR-6.5) for + missing/invalid config; the agent logs to stdout/journald. + """ + from .hostnet import agent_main + return agent_main(["--config", args.config]) + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( prog="wago-sim", @@ -400,6 +412,17 @@ def main(argv: list[str] | None = None) -> int: help="environment module, e.g. envs.pump_station_env:PumpStationEnv") sim.set_defaults(func=_sim) + hostnet = sub.add_parser( + "hostnet-agent", + help="run the host-side agent for the web UI's host network " + "settings page (spec §15; runs on the DOCKER HOST as " + "root/CAP_NET_ADMIN, not in the container)") + hostnet.add_argument( + "--config", required=True, + help="agent config YAML (socket, whitelist, backend, audit_path)", + ) + hostnet.set_defaults(func=lambda args: _hostnet_agent(args)) + args = parser.parse_args(argv) _build_logger(args.verbose) # `--node` may come from the environment when omitted (service mode); diff --git a/src/wago_sim/hostnet.py b/src/wago_sim/hostnet.py new file mode 100644 index 0000000..eead177 --- /dev/null +++ b/src/wago_sim/hostnet.py @@ -0,0 +1,966 @@ +"""Host network settings (spec §15): view and manipulate the host OS's +IP addresses from the web UI — Docker-gated, flag-gated, agent-mediated. + +Split (FR-15.2.7): a container cannot change host addresses and the +simulator container itself shall not run privileged (§12 non-root +deployment), so a small companion service — the **hostnet agent** — +runs on the HOST, owns the interface whitelist, and serves a narrow +protocol over a Unix domain socket that compose mounts into the +container. The simulator talks to it through `HostNetClient`. + +Protocol: one JSON request object per line, one JSON response object +per line (JSONL over the socket stream). + +Operations (the whole surface — there is deliberately no +"run this shell command" op): + + {"op": "health"} -> agent version, whitelist + {"op": "list"} -> interfaces + IPv4 addresses + {"op": "snapshot", "iface": "eth0"} -> address-set snapshot (token) + {"op": "add_address", "iface": ..., "address": ..., "prefix": ...} + {"op": "remove_address", "iface": ..., "address": ...} + {"op": "change_address", "iface": ..., "old": ..., "new": ..., + "prefix": ...} -> replace primary address + {"op": "restore", "iface": ...} -> restore last snapshot + {"op": "audit"} -> recent audit lines (bounded) + +Validation happens TWICE by design (FR-15.2.4): the API validates the +payload shape before sending, and the agent re-validates against its +own whitelist and the live interface state — the container is never +trusted. + +The agent writes changes through a **backend**: + * `Iproute2Backend` — netlink via pyroute2 if available, else the + `ip` CLI as a subprocess (Linux v1, FR-15.2.7) + * `FakeBackend` — in-memory interface model (tests, and the + `--dry-run` agent mode for bench rehearsal) + +Changes are RUNTIME-ONLY (FR-15.2.4): nothing is written to +netplan/NetworkManager/systemd-networkd, so a host reboot restores the +deployed addressing — the guaranteed escape hatch. +""" + +from __future__ import annotations + +import ipaddress +import json +import logging +import os +import socket +import subprocess +import threading +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +log = logging.getLogger(__name__) + +PROTOCOL_VERSION = 1 +AUDIT_MAX = 200 +DEFAULT_SOCKET = "/run/wago-sim/hostnet.sock" + + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + + +class HostNetError(Exception): + """Operator-visible error: bad input, refused operation, agent down.""" + + +class HostNetRefused(HostNetError): + """The agent refused the operation (whitelist, unknown interface).""" + + +class HostNetUnavailable(HostNetError): + """The agent is not reachable (socket missing) — FR-15.2.8.""" + + +# --------------------------------------------------------------------------- +# Address helpers (shared validation — API and agent both use these) +# --------------------------------------------------------------------------- + + +def parse_ipv4(text: object) -> str: + """Validate an IPv4 address string, return its normalized form. + + Raises HostNetError with a clean operator message otherwise — the + same message appears in the API's 400 detail and the agent's error + response. + """ + try: + addr = ipaddress.IPv4Address(str(text).strip()) + except (ipaddress.AddressValueError, ValueError, TypeError): + raise HostNetError(f"invalid IPv4 address {text!r}") from None + return str(addr) + + +def parse_prefix(text: object) -> int: + """Validate a prefix length 1..32 (an int or '24'-style string).""" + try: + prefix = int(text) + except (TypeError, ValueError): + raise HostNetError(f"invalid prefix length {text!r} (1-32)") from None + if not 1 <= prefix <= 32: + raise HostNetError(f"prefix length must be 1-32, got {prefix}") + return prefix + + +def parse_iface(text: object) -> str: + """Validate an interface name (alphanumeric + - _ . @).""" + name = str(text or "").strip() + if not name or len(name) > 15: + # Linux restricts interface names to 15 chars (IFNAMSIZ - 1) + raise HostNetError(f"invalid interface name {text!r}") + valid = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.@") + if not set(name) <= valid: + raise HostNetError(f"invalid interface name {text!r}") + return name + + +# --------------------------------------------------------------------------- +# Backends: the actual host mutation paths (FR-15.2.7) +# --------------------------------------------------------------------------- + + +class FakeBackend: + """In-memory interface model — tests and the agent's --dry-run. + + Models exactly the surface the agent needs: interfaces with a set + of (address, prefix) entries and an up/down flag (up only, for + display; link toggling is v2 per FR-15.2.4). + """ + + def __init__(self, interfaces: dict[str, list[str]] | None = None) -> None: + # iface -> ordered [(address, prefix), ...]; first = primary + self._ifaces: dict[str, list[tuple[str, int]]] = {} + for name, addrs in (interfaces or {}).items(): + parsed: list[tuple[str, int]] = [] + for entry in addrs: + addr, _, prefix = entry.partition("/") + parsed.append((parse_ipv4(addr), parse_prefix(prefix or 24))) + self._ifaces[name] = parsed + self.link_up: dict[str, bool] = {n: True for n in self._ifaces} + + # -- read side ---------------------------------------------------------- + + def interfaces(self) -> list[dict[str, Any]]: + out = [] + for name in sorted(self._ifaces): + out.append({ + "name": name, + "up": self.link_up.get(name, True), + "addresses": [ + {"address": a, "prefix": p} for a, p in self._ifaces[name] + ], + }) + return out + + def addresses(self, iface: str) -> list[tuple[str, int]]: + return list(self._ifaces.get(iface, [])) + + # -- write side ---------------------------------------------------------- + + def add_address(self, iface: str, address: str, prefix: int) -> None: + self._ifaces.setdefault(iface, []) + if address in [a for a, _ in self._ifaces[iface]]: + raise HostNetError(f"{address}/{prefix} already exists on {iface}") + self._ifaces[iface].append((address, prefix)) + + def remove_address(self, iface: str, address: str) -> None: + entries = self._ifaces.get(iface, []) + keep = [(a, p) for a, p in entries if a != address] + if len(keep) == len(entries): + raise HostNetError(f"{address} is not assigned to {iface}") + self._ifaces[iface] = keep + + def replace_address(self, iface: str, old: str, new: str, prefix: int) -> None: + entries = self._ifaces.get(iface, []) + if old not in [a for a, _ in entries]: + raise HostNetError(f"{old} is not assigned to {iface}") + if new in [a for a, _ in entries] and new != old: + raise HostNetError(f"{new}/{prefix} already exists on {iface}") + self._ifaces[iface] = [ + (new, prefix) if a == old else (a, p) for a, p in entries + ] + + +class Iproute2Backend: + """Linux host backend (FR-15.2.7). + + Reads prefer netlink (pyroute2 — no process spawn per poll) and + fall back to parsing ``ip -o`` output when pyroute2 is not + installed. Writes ALWAYS go through the ``ip`` CLI: one uniform + mutation path with one error-message shape and one audit trail, + regardless of pyroute2 availability (as-built decision; it also + keeps minimal hosts working). + """ + + def __init__(self) -> None: + self._ipr: Any = None + try: + from pyroute2 import IPRoute # type: ignore[import-not-found] + self._ipr = IPRoute() + except Exception: # pragma: no cover - exercised only on hosts + log.info("pyroute2 not available — using the `ip` CLI for reads too") + + def interfaces(self) -> list[dict[str, Any]]: + out: dict[str, dict[str, Any]] = {} + for name, up in self._link_list(): + out[name] = {"name": name, "up": up, "addresses": []} + for name, addr, prefix in self._addr_list(): + if name in out: + out[name]["addresses"].append( + {"address": addr, "prefix": prefix}) + return [out[k] for k in sorted(out)] + + def addresses(self, iface: str) -> list[tuple[str, int]]: + return [(a, p) for n, a, p in self._addr_list() if n == iface] + + def add_address(self, iface: str, address: str, prefix: int) -> None: + self._ip_cmd(["ip", "addr", "add", f"{address}/{prefix}", "dev", iface]) + + def remove_address(self, iface: str, address: str) -> None: + self._ip_cmd(["ip", "addr", "del", f"{address}/32", "dev", iface]) + + def replace_address(self, iface: str, old: str, new: str, prefix: int) -> None: + # order matters: add the NEW address first so the interface is + # never address-less mid-change (the operator's own session + # may ride on it). If the del then fails, BOTH addresses are + # present — recoverable, never connection-killing. + self._ip_cmd(["ip", "addr", "add", f"{new}/{prefix}", "dev", iface]) + self._ip_cmd(["ip", "addr", "del", f"{old}/32", "dev", iface]) + + # -- internals ------------------------------------------------------------ + + def _link_list(self) -> list[tuple[str, bool]]: + if self._ipr is not None: + out = [] + for link in self._ipr.get_links(): + name = link.get_attr("IFLA_IFNAME") + if not name: + continue + flags = link.get("flags", []) + if isinstance(flags, (list, tuple)): + up = "UP" in flags # pyroute2: name list + elif isinstance(flags, str): + up = "UP" in flags.split(",") + else: + up = bool(int(flags) & 1) # raw mask + out.append((name, bool(up))) + return out + text = self._ip_cmd(["ip", "-o", "link", "show"]) + out = [] + for line in text.splitlines(): + # "2: eth0: mtu 1500 ..." + head = line.split(":", 2) + if len(head) < 2: + continue + name = head[1].split("@", 1)[0].strip() + if "<" in line and ">" in line: + flagseg = line[line.index("<") + 1: line.index(">")] + up = "UP" in flagseg.split(",") + else: + up = False + out.append((name, bool(up))) + return out + + def _addr_list(self) -> list[tuple[str, str, int]]: + if self._ipr is not None: + idx_name: dict[int, str] = {} + for link in self._ipr.get_links(): + idx_name[int(link["index"])] = link.get_attr("IFLA_IFNAME") or "" + out = [] + for a in self._ipr.get_addr(family=socket.AF_INET): + out.append(( + idx_name.get(int(a.get("index", -1)), ""), + a.get_attr("IFA_LOCAL") or "", + int(a.get("prefixlen", 0) or 0), + )) + return out + text = self._ip_cmd(["ip", "-o", "-4", "addr", "show"]) + out = [] + for line in text.splitlines(): + # "2: eth0 inet 192.168.1.5/24 brd ... scope global eth0" + words = line.split() + if len(words) < 4 or words[2] != "inet": + continue + addr, _, prefix = words[3].partition("/") + try: + out.append((words[1].split("@", 1)[0].strip(), + str(ipaddress.IPv4Address(addr)), int(prefix))) + except ValueError: + continue # e.g. "peer " point-to-point entries + return out + + def _ip_cmd(self, cmd: list[str]) -> str: + """Run one `ip` command; map failures to operator messages.""" + try: + proc = subprocess.run( + cmd, capture_output=True, text=True, timeout=10, + ) + except FileNotFoundError as exc: + raise HostNetUnavailable( + f"`ip` tool not found on this host ({exc})") from None + except subprocess.TimeoutExpired as exc: + raise HostNetError( + f"`{' '.join(cmd[1:])}` timed out") from exc + if proc.returncode != 0: + stderr = proc.stderr.strip() or "no error output" + raise HostNetError(f"`{' '.join(cmd[1:])}` failed: {stderr}") + return proc.stdout + + +# --------------------------------------------------------------------------- +# The agent (host side) +# --------------------------------------------------------------------------- + + +@dataclass +class HostNetAgent: + """Serves the hostnet protocol on a Unix socket (host process). + + The agent is deliberately synchronous, single-threaded per + connection: the operations are quick, low-frequency, and must be + strictly ordered (snapshot → mutate) to be safe. + """ + + backend: Any = None # FakeBackend | Iproute2Backend + socket_path: str = "/run/wago-sim/hostnet.sock" + whitelist: list[str] = field(default_factory=list) + audit_path: str | None = None # separate audit file (optional) + _snapshots: dict[str, list[tuple[str, int]]] = field(default_factory=dict) + _audit: list[str] = field(default_factory=list) + + def __post_init__(self) -> None: + if self.backend is None: + self.backend = FakeBackend() + if not self.whitelist: + raise HostNetError( + "refusing to start with an empty whitelist — " + "name the interfaces the page may touch " + "(config key: whitelist)") + + # -- audit trail (FR-15.2.5) ---------------------------------------------- + + def audit(self, line: str) -> None: + """Append one single-line audit record; bounded in memory.""" + stamp = time.strftime("%Y-%m-%dT%H:%M:%S") + entry = f"{stamp} {line}" + self._audit.append(entry) + if len(self._audit) > AUDIT_MAX: + del self._audit[: len(self._audit) - AUDIT_MAX] + # also log through the service log (stdout/journald, FR-12.2.6) + log.info("hostnet-audit %s", line) + if self.audit_path: + try: + Path(self.audit_path).parent.mkdir(parents=True, exist_ok=True) + with open(self.audit_path, "a", encoding="utf-8") as fh: + fh.write(entry + "\n") + except OSError as exc: # pragma: no cover - host filesystem + log.warning("audit append failed: %s", exc) + + def audit_lines(self, limit: int = 50) -> list[str]: + return list(self._audit[-limit:]) + + # -- snapshot/restore (FR-15.2.5) ------------------------------------------- + + @staticmethod + def _by(operator: str) -> str: + """Audit suffix naming the acting operator, if known.""" + return f" by {operator}" if operator else "" + + def _snapshot(self, iface: str, + operator: str = "") -> list[tuple[str, int]]: + entries = [(a, p) for a, p in self.backend.addresses(iface)] + self._snapshots[iface] = entries + self.audit(f"snapshot {iface} " + f"[{', '.join(f'{a}/{p}' for a, p in entries)}]" + f"{self._by(operator)}") + return entries + + def _restore(self, iface: str, + operator: str = "") -> list[tuple[str, int]]: + snap = self._snapshots.get(iface) + if snap is None: + raise HostNetError(f"no snapshot for {iface} — nothing to restore") + current = [(a, p) for a, p in self.backend.addresses(iface)] + # apply the snapshot: remove addresses not in it, add missing + # ones. Restore itself does NOT snapshot — undo stays undoable. + snap_addrs = {a for a, _ in snap} + for a, _p in current: + if a not in snap_addrs: + try: + self.backend.remove_address(iface, a) + except HostNetError: + pass # address vanished on its own — already fine + for a, p in snap: + if a not in {ca for ca, _ in current}: + self.backend.add_address(iface, a, p) + self.audit(f"restore {iface} -> " + f"[{', '.join(f'{a}/{p}' for a, p in snap)}]" + f"{self._by(operator)}") + return self._snapshots[iface] + + # -- operation dispatch ------------------------------------------------------ + + # ops that carry an iface argument — everything else is either + # op-only (health/list/audit) or unknown (rejected below) + _IFACE_OPS = frozenset({"snapshot", "restore", "add_address", + "remove_address", "change_address"}) + + def handle(self, request: dict[str, Any]) -> dict[str, Any]: + """One request object -> one response object (protocol v1).""" + op = str(request.get("op", "")).strip() + operator = str(request.get("operator", "")).strip() + by = self._by(operator) + try: + if op not in ("health", "list", "audit") and op not in self._IFACE_OPS: + raise HostNetError(f"unknown operation {op!r}") + if op == "health": + return {"ok": True, "protocol": PROTOCOL_VERSION, + "backend": type(self.backend).__name__, + "whitelist": list(self.whitelist)} + if op == "list": + ifaces = self.backend.interfaces() + return {"ok": True, "interfaces": [ + {"name": i["name"], "up": i["up"], + "addresses": i["addresses"]} + for i in ifaces + ]} + if op == "audit": + try: + limit = int(request.get("limit", 50)) + except (TypeError, ValueError): + limit = 50 + return {"ok": True, "lines": self.audit_lines( + max(1, min(limit, AUDIT_MAX)))} + # every iface op shares the same guards (FR-15.2.4/5) + iface = parse_iface(request.get("iface")) + self._check_whitelist(iface) + self._check_exists(iface) + if op == "snapshot": + return {"ok": True, "iface": iface, + "addresses": self._snapshot(iface, operator)} + if op == "restore": + return {"ok": True, "iface": iface, + "addresses": self._restore(iface, operator)} + if op == "add_address": + address = parse_ipv4(request.get("address")) + prefix = parse_prefix(request.get("prefix", 24)) + self._require_absent(iface, address, prefix) + self._snapshot(iface, operator) # auto-snapshot (FR-15.2.5) + self.backend.add_address(iface, address, prefix) + self.audit(f"add {iface} {address}/{prefix}{by}") + return {"ok": True, "iface": iface, "address": address, + "prefix": prefix} + if op == "remove_address": + address = parse_ipv4(request.get("address")) + self._require_present(iface, address) + self._snapshot(iface, operator) + self.backend.remove_address(iface, address) + self.audit(f"remove {iface} {address}{by}") + return {"ok": True, "iface": iface, "address": address} + if op == "change_address": + old = parse_ipv4(request.get("old")) + new = parse_ipv4(request.get("new")) + prefix = parse_prefix(request.get("prefix", 24)) + self._require_present(iface, old) + if new != old: + self._require_absent(iface, new, prefix) + self._snapshot(iface, operator) + self.backend.replace_address(iface, old, new, prefix) + self.audit(f"change {iface} {old} -> {new}/{prefix}{by}") + return {"ok": True, "iface": iface, "address": new, + "prefix": prefix} + except HostNetRefused as exc: + # refusals are auditable events too (FR-15.2.5) + self.audit(f"REFUSED op={op or 'none'} {exc}") + return {"ok": False, "error": str(exc), "refused": True} + except HostNetError as exc: + return {"ok": False, "error": str(exc)} + + def _require_present(self, iface: str, address: str) -> None: + if address not in [a for a, _ in self.backend.addresses(iface)]: + raise HostNetError(f"{address} is not assigned to {iface}") + + def _require_absent(self, iface: str, address: str, prefix: int) -> None: + if address in [a for a, _ in self.backend.addresses(iface)]: + raise HostNetError( + f"{address}/{prefix} already exists on {iface}") + + def _check_whitelist(self, iface: str) -> None: + if iface not in self.whitelist: + raise HostNetRefused( + f"interface {iface!r} is not in the agent whitelist " + f"({', '.join(self.whitelist)})") + + def _check_exists(self, iface: str) -> None: + names = {i["name"] for i in self.backend.interfaces()} + if iface not in names: + raise HostNetRefused(f"interface {iface!r} does not exist") + + # -- socket serving -------------------------------------------------------- + + def serve_forever(self) -> None: # pragma: no cover - host process + """Listen on the Unix socket, one thread per connection. + + Runs until the process is terminated (systemd stop). Socket + directory is created (0755) and the socket itself 0660 so the + container's non-root user can connect when the compose mount + carries the same group. + """ + path = Path(self.socket_path) + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists(): + path.unlink() + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server.bind(str(path)) + os.chmod(path, 0o660) + server.listen(8) + log.info("hostnet agent listening on %s (whitelist: %s)", + self.socket_path, ", ".join(self.whitelist)) + try: + while True: + conn, _ = server.accept() + threading.Thread( + target=self._serve_conn, args=(conn,), daemon=True + ).start() + finally: + server.close() + path.unlink(missing_ok=True) + + def _serve_conn(self, conn: socket.socket) -> None: # pragma: no cover + """One connection: newline-framed JSONL, one op per line.""" + try: + conn.settimeout(30) + buf = b"" + while True: + data = conn.recv(4096) + if not data: + break + buf += data + while b"\n" in buf: + line, buf = buf.split(b"\n", 1) + if not line.strip(): + continue + try: + request = json.loads(line) + except json.JSONDecodeError: + response = {"ok": False, "error": "invalid JSON"} + else: + if not isinstance(request, dict): + response = {"ok": False, "error": "request must be an object"} + else: + response = self.handle(request) + conn.sendall(json.dumps(response).encode() + b"\n") + except (OSError, socket.timeout): + pass + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# The client (container side) +# --------------------------------------------------------------------------- + + +class HostNetClient: + """Synchronous JSONL client for the hostnet agent (container side). + + Used from FastAPI's threadpool (each request gets a fresh socket + connection — ops are low-frequency and must be strictly ordered; + a per-request connection also makes "agent restarted" a non-event). + """ + + def __init__(self, socket_path: str = DEFAULT_SOCKET, + timeout: float = 5.0) -> None: + self.socket_path = socket_path + self.timeout = timeout + + # -- transport -------------------------------------------------------------- + + def _connect(self) -> socket.socket: + """Open one connection to the agent (raises HostNetUnavailable).""" + if not os.path.exists(self.socket_path): + raise HostNetUnavailable( + f"host agent socket not found: {self.socket_path} — is the " + "hostnet agent running on the host and is the socket " + "mounted into the container?") + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(self.timeout) + try: + sock.connect(self.socket_path) + except FileNotFoundError as exc: + raise HostNetUnavailable( + f"host agent socket not found: {self.socket_path}") from exc + except ConnectionRefusedError as exc: + raise HostNetUnavailable( + "host agent refused the connection — socket present but no " + "agent listening") from exc + except (OSError, socket.timeout) as exc: + raise HostNetUnavailable(f"host agent unreachable: {exc}") from exc + return sock + + def _call(self, request: dict[str, Any]) -> dict[str, Any]: + try: + sock = self._connect() + except HostNetUnavailable: + raise + buf = b"" + try: + sock.sendall(json.dumps(request).encode() + b"\n") + while b"\n" not in buf: + chunk = sock.recv(4096) + if not chunk: + break + buf += chunk + except (OSError, socket.timeout) as exc: + raise HostNetUnavailable(f"host agent unreachable: {exc}") from exc + finally: + try: + sock.close() + except OSError: + pass + if not buf: + raise HostNetUnavailable("host agent closed the connection") + try: + response = json.loads(buf.split(b"\n", 1)[0]) + except json.JSONDecodeError as exc: + raise HostNetUnavailable(f"garbled agent response: {exc}") from exc + if not isinstance(response, dict): + raise HostNetUnavailable("agent response is not an object") + return response + + def _mutate(self, request: dict[str, Any], opname: str) -> dict[str, Any]: + """One mutating call; map ok:false to the right error type. + + `refused: true` (whitelist / unknown interface) maps back to + HostNetRefused so the API can answer 403 — everything else is + a validation/backend error (400). + """ + response = self._call(request) + if not response.get("ok"): + if response.get("refused"): + raise HostNetRefused( + response.get("error", f"agent refused {opname}")) + raise HostNetError(response.get("error", f"agent {opname} failed")) + return response + + # -- protocol ops ------------------------------------------------------------ + + def health(self) -> dict[str, Any]: + return self._call({"op": "health"}) + + def list(self) -> list[dict[str, Any]]: + response = self._call({"op": "list"}) + if not response.get("ok"): + raise HostNetError(response.get("error", "agent list failed")) + return response.get("interfaces", []) + + def snapshot(self, iface: str, operator: str = "") -> list[dict[str, Any]]: + response = self._mutate( + {"op": "snapshot", "iface": parse_iface(iface), + "operator": operator}, "snapshot") + return response.get("addresses", []) + + def add_address(self, iface: str, address: object, prefix: object, + operator: str = "") -> dict[str, Any]: + return self._mutate({ + "op": "add_address", + "iface": parse_iface(iface), + "address": parse_ipv4(address), + "prefix": parse_prefix(prefix), + "operator": operator, + }, "add") + + def remove_address(self, iface: str, address: object, + operator: str = "") -> dict[str, Any]: + return self._mutate({ + "op": "remove_address", + "iface": parse_iface(iface), + "address": parse_ipv4(address), + "operator": operator, + }, "remove") + + def change_address(self, iface: str, old: object, new: object, + prefix: object, operator: str = "") -> dict[str, Any]: + return self._mutate({ + "op": "change_address", + "iface": parse_iface(iface), + "old": parse_ipv4(old), + "new": parse_ipv4(new), + "prefix": parse_prefix(prefix), + "operator": operator, + }, "change") + + def restore(self, iface: str, operator: str = "") -> dict[str, Any]: + return self._mutate( + {"op": "restore", "iface": parse_iface(iface), + "operator": operator}, "restore") + + def audit(self, limit: int = 50) -> list[str]: + response = self._call({"op": "audit", "limit": int(limit)}) + if not response.get("ok"): + raise HostNetError(response.get("error", "agent audit failed")) + return response.get("lines", []) + + +class InProcessClient: + """Drop-in client that calls a HostNetAgent directly, in process. + + Used where a Unix-socket round trip is impossible (Windows dev + machines, ASGI tests) — same validation, same audit trail, because + every call funnels through the very same `handle()` the socket + server uses. NOT for production container use (FR-15.2.7: the + container must never mutate host state directly). + """ + + def __init__(self, agent: HostNetAgent) -> None: + self.agent = agent + self._operator = "" + + def for_operator(self, operator: str) -> "InProcessClient": + """Bound client whose calls audit with this operator name.""" + clone = InProcessClient(self.agent) + clone._operator = operator + return clone + + def _call(self, request: dict[str, Any]) -> dict[str, Any]: + req = dict(request) + req.setdefault("operator", self._operator) + return self.agent.handle(req) + + # mirror HostNetClient's op surface exactly ---------------------------- + + def _raise(self, response: dict[str, Any], opname: str) -> dict[str, Any]: + """Map ok:false to the right error type (refused -> 403 path).""" + if not response.get("ok"): + if response.get("refused"): + raise HostNetRefused( + response.get("error", f"agent refused {opname}")) + raise HostNetError(response.get("error", f"agent {opname} failed")) + return response + + def health(self) -> dict[str, Any]: + return self._call({"op": "health"}) + + def list(self) -> list[dict[str, Any]]: + response = self._call({"op": "list"}) + if not response.get("ok"): + raise HostNetError(response.get("error", "agent list failed")) + return response.get("interfaces", []) + + def snapshot(self, iface: str, operator: str = "") -> list[dict[str, Any]]: + response = self._call({"op": "snapshot", "iface": iface, + "operator": operator or self._operator}) + return self._raise(response, "snapshot").get("addresses", []) + + def add_address(self, iface: str, address: object, prefix: object, + operator: str = "") -> dict[str, Any]: + response = self._call({ + "op": "add_address", "iface": iface, "address": address, + "prefix": prefix, "operator": operator or self._operator}) + return self._raise(response, "add") + + def remove_address(self, iface: str, address: object, + operator: str = "") -> dict[str, Any]: + response = self._call({"op": "remove_address", "iface": iface, + "address": address, + "operator": operator or self._operator}) + return self._raise(response, "remove") + + def change_address(self, iface: str, old: object, new: object, + prefix: object, operator: str = "") -> dict[str, Any]: + response = self._call({ + "op": "change_address", "iface": iface, "old": old, + "new": new, "prefix": prefix, + "operator": operator or self._operator}) + return self._raise(response, "change") + + def restore(self, iface: str, operator: str = "") -> dict[str, Any]: + response = self._call({"op": "restore", "iface": iface, + "operator": operator or self._operator}) + return self._raise(response, "restore") + + def audit(self, limit: int = 50) -> list[str]: + response = self._call({"op": "audit", "limit": int(limit)}) + if not response.get("ok"): + raise HostNetError(response.get("error", "agent audit failed")) + return response.get("lines", []) + + +# --------------------------------------------------------------------------- +# The web-facing service (container side, §15.2 API mapping) +# --------------------------------------------------------------------------- + + +class HostNetService: + """Facade the web API talks to: gating status + agent calls. + + One instance per web UI (held on `fastapi_app.state.hostnet`). + `status()` answers FR-15.2.3's availability object without ever + touching the socket for gate checks; `interfaces()` etc. wrap the + client with clean HostNet* errors for the API to map to 503/400. + """ + + def __init__(self, client: HostNetClient | InProcessClient) -> None: + self.client = client + + def status(self) -> dict[str, Any]: + """Availability + agent health (never raises).""" + health: dict[str, Any] | None = None + error: str | None = None + try: + health = self.client.health() + if not health.get("ok"): + error = str(health.get("error", "agent health failed")) + except HostNetError as exc: + error = str(exc) + return { + "available": error is None, + "error": error, + "agent": health, + } + + def interfaces(self) -> list[dict[str, Any]]: + return self.client.list() + + def add_address(self, iface: str, address: object, prefix: object, + operator: str = "") -> dict[str, Any]: + return self.client.add_address(iface, address, prefix, operator) + + def remove_address(self, iface: str, address: object, + operator: str = "") -> dict[str, Any]: + return self.client.remove_address(iface, address, operator) + + def change_address(self, iface: str, old: object, new: object, + prefix: object, operator: str = "") -> dict[str, Any]: + return self.client.change_address(iface, old, new, prefix, operator) + + def restore(self, iface: str, operator: str = "") -> dict[str, Any]: + return self.client.restore(iface, operator) + + def audit(self, limit: int = 50) -> list[str]: + return self.client.audit(limit) + + +# --------------------------------------------------------------------------- +# Gating (FR-15.2.1 / FR-15.2.2): flag AND docker — computed once per process +# --------------------------------------------------------------------------- + + +def hostnet_enabled(env: dict[str, str] | None = None, + docker_markers: list[Path] | None = None) -> bool: + """True only when BOTH gates pass (spec §15.2). + + Gate 1 — flag: WAGO_SIM_HOSTNET truthy (same semantics as + WAGO_SIM_VERBOSE: 1/true/yes/on). + Gate 2 — docker: WAGO_SIM_IN_DOCKER=1 (compose sets it) or the + /.dockerenv marker file exists (supplementary signal). + """ + e = env if env is not None else os.environ + flag = str(e.get("WAGO_SIM_HOSTNET", "")).strip().lower() in ( + "1", "true", "yes", "on") + if not flag: + return False + in_docker = str(e.get("WAGO_SIM_IN_DOCKER", "")).strip().lower() in ( + "1", "true", "yes", "on") + if not in_docker: + markers = docker_markers if docker_markers is not None \ + else [Path("/.dockerenv")] + in_docker = any(m.exists() for m in markers) + return in_docker + + +# --------------------------------------------------------------------------- +# Agent configuration + entry point (host side) +# --------------------------------------------------------------------------- + + +def load_agent_config(path: str) -> dict[str, Any]: + """Load the agent's YAML config with clean errors (CLI policy). + + Required keys: `socket`, `whitelist` (non-empty list). Optional: + `audit_path`, `backend` (fake|iproute2, default iproute2). + """ + import yaml + + p = Path(path) + if not p.is_file(): + raise HostNetError(f"agent config file not found: {path}") + try: + cfg = yaml.safe_load(p.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + raise HostNetError(f"agent config {path} is not valid YAML:\n {exc}") from None + if not isinstance(cfg, dict): + raise HostNetError(f"agent config {path} must be a mapping") + if not cfg.get("socket"): + raise HostNetError(f"agent config {path}: 'socket' is required") + wl = cfg.get("whitelist") + if not isinstance(wl, list) or not wl or \ + not all(isinstance(w, str) and w.strip() for w in wl): + raise HostNetError( + f"agent config {path}: 'whitelist' must be a non-empty list " + "of interface names") + cfg["whitelist"] = [str(w).strip() for w in wl] + return cfg + + +def build_agent_from_config(cfg: dict[str, Any]) -> HostNetAgent: + backend_name = str(cfg.get("backend", "iproute2")).lower() + if backend_name in ("fake", "dry-run"): + backend = FakeBackend() + elif backend_name == "iproute2": + backend = Iproute2Backend() + else: + raise HostNetError( + f"unknown backend {cfg.get('backend')!r} (use 'iproute2' or 'fake')") + return HostNetAgent( + backend=backend, + socket_path=str(cfg["socket"]), + whitelist=[str(w) for w in cfg["whitelist"]], + audit_path=cfg.get("audit_path"), + ) + + +def agent_main(argv: list[str] | None = None) -> int: + """Entry point for ``wago-sim hostnet-agent`` (host process, root or + CAP_NET_ADMIN). Clean CLI errors per the policy (NFR-6.5).""" + import argparse + + parser = argparse.ArgumentParser( + prog="wago-sim hostnet-agent", + description="Host-side agent for the simulator's host network " + "settings page (spec §15)", + ) + parser.add_argument( + "--config", required=True, + help="agent config YAML (socket, whitelist, backend)", + ) + args = parser.parse_args(argv) + try: + cfg = load_agent_config(args.config) + agent = build_agent_from_config(cfg) + except HostNetError as exc: + print(f"wago-sim hostnet-agent: error: {exc}", file=sys_stderr()) + return 2 + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", + stream=sys_stdout(), + ) + agent.serve_forever() + return 0 + + +def sys_stdout() -> Any: + import sys + return sys.stdout + + +def sys_stderr() -> Any: + import sys + return sys.stderr \ No newline at end of file diff --git a/src/wago_sim/webui/api.py b/src/wago_sim/webui/api.py index 94b86e0..65cf84c 100644 --- a/src/wago_sim/webui/api.py +++ b/src/wago_sim/webui/api.py @@ -14,6 +14,7 @@ from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect from ..app import SimulatorApp, rebuild_node +from ..hostnet import HostNetError, HostNetRefused, HostNetUnavailable from ..modules import (CATALOG, ChannelMismatch, ModuleUnknown, VirtualNode, resolve_module) from ..scenario.engine import ScenarioError @@ -261,6 +262,162 @@ def _payload_bool(payload: dict) -> bool: raise HTTPException(status_code=400, detail="value must be a boolean") +# -- host network settings (spec §15) ------------------------------------------- +# +# Routes exist ONLY behind both gates (FR-15.2.1 flag + FR-15.2.2 +# docker): when disabled every /api/hostnet* path answers 404 — no +# dormant surface. `build_webui` mounts HostNetService on +# app.state.hostnet only when `hostnet_enabled()` passes, so the gate +# lives in the app factory, not in each handler. + + +def _hostnet_or_404(request: Request): + """The HostNetService for this app — or 404 when the feature is off.""" + service = getattr(request.app.state, "hostnet", None) + if service is None: + raise HTTPException(status_code=404, detail="host network settings are not enabled") + return service + + +def _hostnet_operator(request: Request, sessions: SessionStore) -> str: + """The logged-in user for the audit trail (FR-15.2.5).""" + return sessions.username or "unknown" + + +@router.get("/api/hostnet") +async def get_hostnet( + request: Request, + _: None = Depends(require_session), +) -> dict: + """Availability + live interface list (FR-15.2.3). + + Never 503s itself: an unreachable agent is reported as + `available: false` with the error string — the page renders a + banner, not a hard failure (FR-15.2.8). + """ + service = _hostnet_or_404(request) + status = service.status() + interfaces = None + if status["available"]: + try: + interfaces = service.interfaces() + except HostNetError as exc: + status = {"available": False, "error": str(exc), "agent": None} + return {"status": status, "interfaces": interfaces} + + +@router.post("/api/hostnet/interfaces/{iface}/addresses") +async def hostnet_add_address( + iface: str, payload: dict, + request: Request, + sessions: SessionStore = Depends(get_sessions), +) -> dict: + """Add an IPv4 address to a whitelisted interface (FR-15.2.4).""" + _require_write(request, sessions) + service = _hostnet_or_404(request) + try: + result = await asyncio.to_thread( + service.add_address, iface, + payload.get("address"), payload.get("prefix"), + _hostnet_operator(request, sessions), + ) + except HostNetUnavailable as exc: + raise HTTPException(status_code=503, detail=str(exc)) from None + except HostNetRefused as exc: + raise HTTPException(status_code=403, detail=str(exc)) from None + except HostNetError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from None + return {"ok": True, **result} + + +@router.delete("/api/hostnet/interfaces/{iface}/addresses/{address}") +async def hostnet_remove_address( + iface: str, address: str, + request: Request, + sessions: SessionStore = Depends(get_sessions), +) -> dict: + """Remove an address from a whitelisted interface (FR-15.2.4).""" + _require_write(request, sessions) + service = _hostnet_or_404(request) + try: + result = await asyncio.to_thread( + service.remove_address, iface, address, + _hostnet_operator(request, sessions), + ) + except HostNetUnavailable as exc: + raise HTTPException(status_code=503, detail=str(exc)) from None + except HostNetRefused as exc: + raise HTTPException(status_code=403, detail=str(exc)) from None + except HostNetError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from None + return {"ok": True, **result} + + +@router.put("/api/hostnet/interfaces/{iface}/addresses") +async def hostnet_change_address( + iface: str, payload: dict, + request: Request, + sessions: SessionStore = Depends(get_sessions), +) -> dict: + """Replace an interface's primary address (FR-15.2.4): add-first + semantics so the interface is never address-less mid-change.""" + _require_write(request, sessions) + service = _hostnet_or_404(request) + try: + result = await asyncio.to_thread( + service.change_address, iface, + payload.get("old"), payload.get("new"), payload.get("prefix"), + _hostnet_operator(request, sessions), + ) + except HostNetUnavailable as exc: + raise HTTPException(status_code=503, detail=str(exc)) from None + except HostNetRefused as exc: + raise HTTPException(status_code=403, detail=str(exc)) from None + except HostNetError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from None + return {"ok": True, **result} + + +@router.post("/api/hostnet/interfaces/{iface}/restore") +async def hostnet_restore( + iface: str, + request: Request, + sessions: SessionStore = Depends(get_sessions), +) -> dict: + """Undo the last change: restore the interface's address snapshot + (FR-15.2.5). The agent snapshots before every mutation, so + 'restore' walks back one change.""" + _require_write(request, sessions) + service = _hostnet_or_404(request) + try: + result = await asyncio.to_thread( + service.restore, iface, _hostnet_operator(request, sessions), + ) + except HostNetUnavailable as exc: + raise HTTPException(status_code=503, detail=str(exc)) from None + except HostNetRefused as exc: + raise HTTPException(status_code=403, detail=str(exc)) from None + except HostNetError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from None + return {"ok": True, **result} + + +@router.get("/api/hostnet/audit") +async def hostnet_audit( + request: Request, + _: None = Depends(require_session), +) -> dict: + """Bounded recent audit lines (FR-15.2.5) — mutations AND refusals.""" + service = _hostnet_or_404(request) + try: + lines = await asyncio.to_thread(service.audit) + except HostNetUnavailable as exc: + raise HTTPException(status_code=503, detail=str(exc)) from None + except HostNetError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from None + return {"lines": lines} + + # -- WebSocket live values -------------------------------------------------------- diff --git a/src/wago_sim/webui/app.py b/src/wago_sim/webui/app.py index 824ab70..dc5767c 100644 --- a/src/wago_sim/webui/app.py +++ b/src/wago_sim/webui/app.py @@ -7,6 +7,7 @@ datastore. Static single-page front-end served from `webui/static/`. from __future__ import annotations +import os from pathlib import Path from fastapi import FastAPI, HTTPException, Request @@ -14,20 +15,43 @@ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from ..app import SimulatorApp +from ..hostnet import HostNetClient, HostNetService, hostnet_enabled from .api import router from .auth import SESSION_COOKIE, SessionStore STATIC_DIR = Path(__file__).parent / "static" +def _build_hostnet_service() -> HostNetService | None: + """The hostnet service when BOTH gates pass, else None (FR-15.2.1/2). + + Gate evaluation happens ONCE per web-UI build: the flag is a + deployment decision (compose file), not a per-request toggle — + flipping it requires a restart, exactly like the spec's + "deliberate deployment change" wording. + """ + if not hostnet_enabled(): + return None + socket_path = os.environ.get("WAGO_SIM_HOSTNET_SOCKET", + "/run/wago-sim/hostnet.sock") + return HostNetService(HostNetClient(socket_path=socket_path)) + + def build_webui( sim: SimulatorApp, *, user: str, password: str, readonly: bool = False, + hostnet_service: object | None = None, ) -> FastAPI: - """Assemble the FastAPI app: API router + static SPA + auth cookie.""" + """Assemble the FastAPI app: API router + static SPA + auth cookie. + + ``hostnet_service``: a HostNetService (or a test double with the + same surface) mounted on ``app.state.hostnet`` when the §15 gates + pass. The production default comes from `_build_hostnet_service()`; + tests inject an InProcessClient-backed service directly. + """ app = FastAPI( title="WAGO 750-362 Simulator", docs_url=None, @@ -36,6 +60,8 @@ def build_webui( ) app.state.sim = sim app.state.sessions = SessionStore(user=user, password=password, readonly=readonly) + app.state.hostnet = (hostnet_service if hostnet_service is not None + else _build_hostnet_service()) app.include_router(router) diff --git a/src/wago_sim/webui/static/app.js b/src/wago_sim/webui/static/app.js index 5f8bc46..6300069 100644 --- a/src/wago_sim/webui/static/app.js +++ b/src/wago_sim/webui/static/app.js @@ -29,6 +29,7 @@ async function init() { renderChannelList(); if (diag) renderDiagnostics(diag); refreshEnvCard(); + probeHostnet(); openWS(); } @@ -794,6 +795,135 @@ function dragEnd() { el.classList.remove("dragging", "drop-target")); } +// ---------------- host network settings (spec §15) ---------------- + +let HOSTNET = null; // last /api/hostnet payload (status + interfaces) + +async function probeHostnet() { + // Feature probe: 404 = gated off (flag or docker) — keep the tab + // hidden; FR-15.2.1/2 "no dormant surface". Other errors keep it + // hidden too (a broken agent must not break the UI, FR-15.2.8). + HOSTNET = await api("/api/hostnet").catch(() => null); + if (!HOSTNET) return; + document.getElementById("tab-btn-hostnet").classList.remove("hidden"); + renderHostnet(); +} + +function hostnetBanner(kind, text) { + const banner = document.getElementById("hostnet-banner"); + document.getElementById("hostnet-banner-text").textContent = text; + banner.classList.toggle("hidden", !kind); + banner.classList.toggle("hostnet-warn", kind === "warn"); +} + +async function refreshHostnet() { + HOSTNET = await api("/api/hostnet").catch(() => null); + if (HOSTNET) renderHostnet(); +} + +function renderHostnet() { + const body = document.getElementById("hostnet-body"); + if (!HOSTNET) return; + const st = HOSTNET.status || {}; + if (!st.available) { + hostnetBanner("warn", + "Host control unavailable: " + (st.error || "agent unreachable") + + " — the Modbus server and the rest of the UI are unaffected."); + body.innerHTML = ""; + return; + } + hostnetBanner(false, ""); + const wl = (st.agent && st.agent.whitelist) || []; + const ifaces = HOSTNET.interfaces || []; + let html = `

The whitelisted interfaces of the ` + + `host OS this container runs on — changes apply at runtime ` + + `only (a host reboot restores the deployed addressing). ` + + `Whitelist: ${wl.join(", ") || "—"}

`; + if (!ifaces.length) { + html += `

No interfaces reported by the agent.

`; + } + for (const ifc of ifaces) { + const allowed = wl.includes(ifc.name); + const rows = ifc.addresses.map((a) => ` + + ${a.address}/${a.prefix} + ${allowed && !READONLY + ? `` + : ""} + `).join(""); + html += ` +
+

${ifc.name} ${ifc.up ? "" : "(down)"} + ${allowed ? "whitelisted" : "view only"}

+ ${rows || + ""}
no IPv4 addresses
+ ${allowed && !READONLY ? ` +
+ + + +
+
+ + + + +
+
+ +
` : ""} +
`; + } + body.innerHTML = html; + refreshHostnetAudit(); +} + +async function hostnetAdd(iface) { + await hostnetOp("POST", + `/api/hostnet/interfaces/${iface}/addresses`, + {address: document.getElementById(`hn-add-${iface}`).value.trim(), + prefix: Number(document.getElementById(`hn-prefix-${iface}`).value)}); +} + +async function hostnetRemove(iface, address) { + await hostnetOp("DELETE", + `/api/hostnet/interfaces/${iface}/addresses/${address}`); +} + +async function hostnetChange(iface) { + await hostnetOp("PUT", + `/api/hostnet/interfaces/${iface}/addresses`, + {old: document.getElementById(`hn-old-${iface}`).value.trim(), + new: document.getElementById(`hn-new-${iface}`).value.trim(), + prefix: Number(document.getElementById(`hn-nprefix-${iface}`).value)}); +} + +async function hostnetRestore(iface) { + await hostnetOp("POST", + `/api/hostnet/interfaces/${iface}/restore`); +} + +async function hostnetOp(method, path, payload) { + if (READONLY) return; + try { + await api(path, { + method, + headers: {"Content-Type": "application/json"}, + body: payload ? JSON.stringify(payload) : undefined, + }); + await refreshHostnet(); + } catch (e) { + hostnetBanner("warn", e.message); + } +} + +async function refreshHostnetAudit() { + const pre = document.getElementById("hostnet-audit"); + if (!pre) return; + const r = await api("/api/hostnet/audit").catch(() => null); + if (r) pre.textContent = r.lines.length ? r.lines.join("\n") : "(empty)"; +} + // ---------------- tabs ---------------- document.querySelectorAll(".tab").forEach((btn) => { @@ -804,6 +934,7 @@ document.querySelectorAll(".tab").forEach((btn) => { document.getElementById(`tab-${btn.dataset.tab}`).classList.add("active"); if (btn.dataset.tab === "diagnostics") refreshDiagnostics(); if (btn.dataset.tab === "config") renderConfig(); + if (btn.dataset.tab === "hostnet") refreshHostnet(); }); }); diff --git a/src/wago_sim/webui/static/index.html b/src/wago_sim/webui/static/index.html index 3cd2153..1847a98 100644 --- a/src/wago_sim/webui/static/index.html +++ b/src/wago_sim/webui/static/index.html @@ -4,7 +4,7 @@ WAGO 750-362 Simulator - +
@@ -19,6 +19,7 @@ +
@@ -115,8 +116,28 @@
+ + + +
+

Host network settings

+ +
+
+

Audit log

+

Every mutation and every refusal, newest last. + The agent appends the same lines to the service log (journald).

+

+      
+ +
+
+
- + \ No newline at end of file diff --git a/src/wago_sim/webui/static/login.html b/src/wago_sim/webui/static/login.html index 0602aa1..20cc7dc 100644 --- a/src/wago_sim/webui/static/login.html +++ b/src/wago_sim/webui/static/login.html @@ -4,7 +4,7 @@ WAGO Simulator — Login - +