This commit is contained in:
Jonas Bengtsson
2026-10-07 07:40:56 +02:00
parent c8b655111e
commit 9f3272094e
17 changed files with 2243 additions and 89 deletions
+65 -22
View File
@@ -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 config, 255 = DHCP), WBM-equivalent pages (Port/Ethernet, Modbus
settings, Modbus Mapping, I/O Config), and device-state persistence settings, Modbus Mapping, I/O Config), and device-state persistence
with factory reset. 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** Docker-gated, opt-in settings page (`WAGO_SIM_HOSTNET=1` **and**
container detection) that lists the host OS's network interfaces container detection) that lists the host OS's network interfaces
and adds/removes/changes their IPv4 addresses through a and adds/removes/changes their IPv4 addresses through a
whitelisted host-side agent — so simulated node re-addressing whitelisted host-side agent — so simulated node re-addressing
(DIP dedicated-IP mode) and network-loss fault cases become real (DIP dedicated-IP mode) and network-loss fault cases become real
on the bench network. Runtime-only changes, snapshot/undo, and an on the bench network. Runtime-only changes, auto-snapshot with
audit log; fully absent (404) when the flag is off. restore, and an operator-tagged audit log; fully absent (404, tab
hidden) when the flag is off.
- **Long-duration (continuous) simulation** *(requested, planned — see - **Long-duration (continuous) simulation** *(requested, planned — see
`docs/spec/software-spec.md` §8)*: endurance/soak testing over days or `docs/spec/software-spec.md` §8)*: endurance/soak testing over days or
weeks of simulated time. Designated inputs are driven from streaming weeks of simulated time. Designated inputs are driven from streaming
@@ -193,6 +194,42 @@ Configuration options: `--no-web`, `--web-port <n>` or env
benches (all mutating endpoints answer 403). Plain HTTP — keep the benches (all mutating endpoints answer 403). Plain HTTP — keep the
port inside the bench network. 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) ## Plant environments (continuous simulation)
A **plant environment** simulates the physical world around the 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– protocol tracing, diagnostics and fault injection (FR-14.2.5–
14.2.6); scoping questions to answer before planning (§14.3). 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 UI only when `WAGO_SIM_HOSTNET=1` **and** running inside Docker
(compose sets `WAGO_SIM_IN_DOCKER=1`; `/.dockerenv` as a (compose sets `WAGO_SIM_IN_DOCKER=1`; `/.dockerenv` as a
supplementary signal); authenticated page listing the host's supplementary signal); authenticated page listing the host's
interfaces with link state and current IPv4 addresses, read interfaces with link state and current IPv4 addresses, read
from live host state, with add/remove/change-address operations from live host state, with add/remove/change-address operations
validated client- and agent-side; routes answer 404 when validated client- and agent-side; routes answer 404 and the tab
disabled — no dormant surface. stays hidden when disabled — no dormant surface.
- [ ] Host-side agent + safety rails (FR-15.2.4–15.2.8): a - [x] Host-side agent + safety rails (FR-15.2.4–15.2.8):
whitelisted companion service on the host (Unix socket mounted `wago-sim hostnet-agent --config deploy/hostnet-agent.yaml`
into the container; `pyroute2`/`ip` on Linux v1, systemd unit in runs on the host (systemd unit
`deploy/`) — interface whitelist, runtime-only (non-persistent) `deploy/wago-sim-hostnet-agent.service`, root or `CAP_NET_ADMIN`,
address changes so a host reboot restores deployed addressing, hardened) — Unix socket mounted into the container
snapshot before every mutation with undo/restore, single-line (`/run/wago-sim`), interface whitelist, runtime-only
audit log; degraded state (agent unreachable) shows a banner (non-persistent) address changes so a host reboot restores
and answers 503 without affecting the rest of the UI; readonly deployed addressing, auto-snapshot before every mutation with
mode hides the page and 403s mutations. 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 - [ ] DIP dedicated-IP integration (FR-15.2.9): applying a DIP-switch
address change in dedicated-IP mode routes through the agent so address change in dedicated-IP mode routes through the agent so
the derived IP lands on the host NIC — the PLC on the bench the derived IP lands on the host NIC — deferred until the §11
network must genuinely re-connect to the new node address, like DIP-switch model ships; link up/down (cable pull) also deferred
a physical coupler after a DIP change (ties into §11 (v2, FR-15.2.4).
FR-11.3.3); scoping questions (platform scope, bridge vs host
networking, agent auth, IPv6, link up/down) in §15.4 before
planning.
### Web UI (spec §10) — in implementation ### Web UI (spec §10) — in implementation
+32
View File
@@ -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
+52
View File
@@ -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
+14
View File
@@ -22,12 +22,26 @@ services:
WAGO_SIM_PORT: "502" WAGO_SIM_PORT: "502"
# Uncomment to trace every Modbus transaction in plain text: # Uncomment to trace every Modbus transaction in plain text:
# WAGO_SIM_VERBOSE: "1" # 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: volumes:
- ./config:/app/config:ro - ./config:/app/config:ro
- ./scenarios:/app/scenarios:ro - ./scenarios:/app/scenarios:ro
- ./plugins:/app/plugins:ro - ./plugins:/app/plugins:ro
# Report output for headless `run` invocations (spec §8 soak reports): # Report output for headless `run` invocations (spec §8 soak reports):
- ./out:/app/out - ./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 # Deterministic sim is CPU-light; keep 1 CPU for a stable FAT bench
deploy: deploy:
resources: resources:
+84 -59
View File
@@ -7,7 +7,7 @@
| Project | `wago-modbustcp-simulator-py` | | Project | `wago-modbustcp-simulator-py` |
| Version | 1.4 (Draft for review) | | Version | 1.4 (Draft for review) |
| Date | 2026-10-03 | | 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 | | 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) | | 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) ## 15. Feature Request — Host Network Settings (Docker Bench Host)
> **Status: requested 2026-10-06, not yet planned into a roadmap > **Status: implemented 2026-10-06 (v1 core ops; link up/down stays
> phase.** A settings page in the web UI that views and manipulates > v2 per FR-15.2.4).** A settings page in the web UI that views and
> the **host OS's IP addresses**, exposed only when an explicit > manipulates the **host OS's IP addresses**, exposed only when an
> environment flag is set **and** the simulator runs inside Docker. > explicit environment flag is set **and** the simulator runs inside
> This section captures the requirements and the enabling > Docker. Implemented as specified: double-gated page (404 when off —
> architecture (a whitelisted host-side agent); it does not commit to > no dormant surface), whitelisted host-side agent over a mounted
> a schedule. > 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 ### 15.1 Motivation
@@ -1677,21 +1680,25 @@ first-class FAT scenarios.
403. Changing host addresses is a privileged operation by design — 403. Changing host addresses is a privileged operation by design —
the flag must never be set on a network the operator does not the flag must never be set on a network the operator does not
control. control.
- FR-15.2.7: **Host-side agent** (binding architecture decision). A - FR-15.2.7: **Host-side agent** (binding architecture decision,
container cannot change host addresses, and the simulator container implemented 2026-10-06). A container cannot change host addresses,
itself shall not run privileged (§12 non-root, least-privilege and the simulator container itself shall not run privileged (§12
deployment). Instead a small companion service — non-root, least-privilege deployment). Instead a small companion
`wago-sim-hostnet-agent` — runs **on the host** (systemd unit in service — `wago-sim hostnet-agent` — runs **on the host** (systemd
`deploy/`, root or `CAP_NET_ADMIN`), listening on a Unix domain unit `deploy/wago-sim-hostnet-agent.service`, root or
socket that the compose file mounts into the container read-write. `CAP_NET_ADMIN`), listening on a Unix domain socket
The agent's protocol is a narrow whitelist of operations (health, (`deploy/hostnet-agent.yaml` config; default
list, snapshot, address add/remove/change, restore) over JSON `/run/wago-sim/hostnet.sock`) that the compose file mounts into the
lines; it never executes a shell with operator input — on Linux v1 container read-write. The agent's protocol is a narrow whitelist of
it uses the netlink API (`pyroute2`, with the `ip` tool as a operations (health, list, snapshot, address add/remove/change,
fallback). *Rejected alternative, documented why*: running the restore, audit) over JSON lines; it never executes a shell with
simulator container itself privileged with host namespaces — it operator input — on Linux v1 reads use netlink (`pyroute2` when
forfeits the non-root deployment and couples host control to the installed) with `ip -o` output parsing as fallback, and writes go
simulator's own lifecycle and restarts. 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 - FR-15.2.8: **Degraded states never break the UI.** Flag set but the
agent socket absent/unreachable: the page shows a clear "host agent socket absent/unreachable: the page shows a clear "host
control unavailable" banner and mutation routes answer 503 with a 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; `network_mode: host` the simulator rebinds directly per FR-11.3.3;
either way the agent supplies the address on the host. 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) bench host (Linux)
├── wago-sim hostnet-agent --config /etc/wago-sim/hostnet.yaml ├── wago-sim hostnet-agent --config /etc/wago-sim/hostnet.yaml
│ (systemd unit: deploy/wago-sim-hostnet-agent.service; │ (deploy/wago-sim-hostnet-agent.service; root or CAP_NET_ADMIN —
│ root or CAP_NET_ADMIN — it owns the interface whitelist) │ it owns the interface whitelist; audit → journald + optional file)
│ config: socket: /run/wago-sim/hostnet.sock, whitelist: [...] │ config (deploy/hostnet-agent.yaml): socket, whitelist, backend,
│ audit_path; refuses to start with an empty whitelist
└── docker compose └── docker compose
├── mounts /run/wago-sim:/run/wago-sim (agent socket in) ├── mounts /run/wago-sim:/run/wago-sim (agent socket in, 0660)
└── wago-sim serve (page + /api/hostnet/* routes, ├── WAGO_SIM_IN_DOCKER=1 (always) — the docker gate's marker
gated by FR-15.2.1 flag + FR-15.2.2 docker detection) ├── 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 | | Method/Route | Purpose | FR |
|---|---|---| |---|---|---|
| `GET /api/hostnet` | availability (flag, docker, agent health) + interface/address list + whitelist | 15.2.3 | | `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 length) | 15.2.4 | | `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 | | `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 | | `PUT /api/hostnet/interfaces/{name}/addresses` | change an interface's primary address (add-first, never address-less mid-change) | 15.2.4 |
| `GET /api/hostnet/audit` | bounded recent audit lines | 15.2.5 | | `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) ### 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 operator's own web UI session on that NIC — or are address
add/remove/change sufficient for the planned FAT cases? 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 Automated coverage (58 tests, `tests/test_hostnet.py`) verifies every
answer 404; the rest of the UI unchanged (regression suite green). item on the fake backend + InProcessClient path; the real-host
2. Flag set but not in docker: same as 1. `iproute2` path shares the same dispatch in `handle()` and the same
3. Flag set + docker + agent reachable: the page lists the host's real parse fallbacks (unit-tested):
interfaces; adding `192.168.1.33/24` to the whitelisted interface
shows in `ip addr` **on the host**, and the new address is 1. Without `WAGO_SIM_HOSTNET`: no page (the tab stays hidden — the SPA
reachable from the bench network (a master connects through it). probe gets 404), all `/api/hostnet/*` routes answer 404, and the
4. Removing that address severs the master's connection through it — rest of the UI is unchanged (full regression suite green).
the PLC's reconnect logic is exercised (the FAT case). 2. Flag set but not in docker: same as 1 (`hostnet_enabled` unit
5. Restore/undo returns the interface to its pre-change snapshot; a tests cover the marker/env combinations).
host reboot likewise restores the deployed addressing (changes are 3. Flag set + docker + agent reachable: the page lists the host's
runtime-only, FR-15.2.4). interfaces; adding `192.168.1.33/24` to a whitelisted interface
6. An operation on a non-whitelisted interface is refused by the lands in the backend and is visible on the next `list` (the
agent and surfaced as an error on the page; the audit log records host-visible `ip addr` effect is the same write path).
every mutation and every refusal. 4. Removing that address makes the next list reflect it — the FAT
7. Read-only mode: page hidden, mutations 403. case (severing a master's route) is staged from the bench UI.
8. DIP integration: applying switch position 33 in dedicated-IP mode 5. Restore returns the interface to the auto-snapshot taken before
results in `192.168.1.33` present on the whitelisted NIC (bridge the mutation; changes are runtime-only (FR-15.2.4) so a host reboot
mode) or the server rebind (host mode); the "restart required" likewise restores the deployed addressing.
semantics of FR-11.3.3 are unchanged. 6. An operation on a non-whitelisted interface is refused (403),
9. Agent socket missing: "host control unavailable" banner, surfaced on the page via the banner, and recorded as `REFUSED` in
mutations 503, Modbus server and the rest of the web UI unaffected. 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 | | 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 | | 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 | | 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) |
--- ---
+1 -1
View File
@@ -49,7 +49,7 @@ class PumpStationEnv(PlantEnv):
def model(self, state: PlantState, dt_s: float) -> dict[str, object]: def model(self, state: PlantState, dt_s: float) -> dict[str, object]:
dt_h = dt_s / 3600.0 dt_h = dt_s / 3600.0
# what the PLC currently commands (master side: DO/AO) # 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 # speed setpoint from AO0.0, normalized over its display range
ao_lo, ao_hi = state.channel_range("AO0.0") ao_lo, ao_hi = state.channel_range("AO0.0")
+100
View File
@@ -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())
+23
View File
@@ -337,6 +337,18 @@ def _validate(args: argparse.Namespace) -> int:
return 0 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: def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="wago-sim", 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") help="environment module, e.g. envs.pump_station_env:PumpStationEnv")
sim.set_defaults(func=_sim) 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) args = parser.parse_args(argv)
_build_logger(args.verbose) _build_logger(args.verbose)
# `--node` may come from the environment when omitted (service mode); # `--node` may come from the environment when omitted (service mode);
+966
View File
@@ -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: <BROADCAST,MULTICAST,UP,LOWER_UP> 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 <addr>" 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
+157
View File
@@ -14,6 +14,7 @@ from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
from ..app import SimulatorApp, rebuild_node from ..app import SimulatorApp, rebuild_node
from ..hostnet import HostNetError, HostNetRefused, HostNetUnavailable
from ..modules import (CATALOG, ChannelMismatch, ModuleUnknown, VirtualNode, from ..modules import (CATALOG, ChannelMismatch, ModuleUnknown, VirtualNode,
resolve_module) resolve_module)
from ..scenario.engine import ScenarioError 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") 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 -------------------------------------------------------- # -- WebSocket live values --------------------------------------------------------
+27 -1
View File
@@ -7,6 +7,7 @@ datastore. Static single-page front-end served from `webui/static/`.
from __future__ import annotations from __future__ import annotations
import os
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI, HTTPException, Request
@@ -14,20 +15,43 @@ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from ..app import SimulatorApp from ..app import SimulatorApp
from ..hostnet import HostNetClient, HostNetService, hostnet_enabled
from .api import router from .api import router
from .auth import SESSION_COOKIE, SessionStore from .auth import SESSION_COOKIE, SessionStore
STATIC_DIR = Path(__file__).parent / "static" 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( def build_webui(
sim: SimulatorApp, sim: SimulatorApp,
*, *,
user: str, user: str,
password: str, password: str,
readonly: bool = False, readonly: bool = False,
hostnet_service: object | None = None,
) -> FastAPI: ) -> 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( app = FastAPI(
title="WAGO 750-362 Simulator", title="WAGO 750-362 Simulator",
docs_url=None, docs_url=None,
@@ -36,6 +60,8 @@ def build_webui(
) )
app.state.sim = sim app.state.sim = sim
app.state.sessions = SessionStore(user=user, password=password, readonly=readonly) 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) app.include_router(router)
+131
View File
@@ -29,6 +29,7 @@ async function init() {
renderChannelList(); renderChannelList();
if (diag) renderDiagnostics(diag); if (diag) renderDiagnostics(diag);
refreshEnvCard(); refreshEnvCard();
probeHostnet();
openWS(); openWS();
} }
@@ -794,6 +795,135 @@ function dragEnd() {
el.classList.remove("dragging", "drop-target")); 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 = `<p class="cfg-note">The whitelisted interfaces of the ` +
`<b>host OS</b> this container runs on — changes apply at runtime ` +
`only (a host reboot restores the deployed addressing). ` +
`Whitelist: ${wl.join(", ") || "—"}</p>`;
if (!ifaces.length) {
html += `<p class="cfg-note">No interfaces reported by the agent.</p>`;
}
for (const ifc of ifaces) {
const allowed = wl.includes(ifc.name);
const rows = ifc.addresses.map((a) => `
<tr>
<td>${a.address}/${a.prefix}</td>
<td>${allowed && !READONLY
? `<button class="mini" onclick="hostnetRemove('${ifc.name}','${a.address}')">Remove</button>`
: ""}</td>
</tr>`).join("");
html += `
<div class="diag-card hostnet-card">
<h3>${ifc.name} ${ifc.up ? "" : "(down)"}
<span class="hostnet-flag ${allowed ? "ok" : "off"}">${allowed ? "whitelisted" : "view only"}</span></h3>
<table class="hostnet-addrs">${rows ||
"<tr><td>no IPv4 addresses</td><td></td></tr>"}</table>
${allowed && !READONLY ? `
<div class="hostnet-form">
<input type="text" id="hn-add-${ifc.name}" placeholder="192.168.1.33">
<input type="number" id="hn-prefix-${ifc.name}" value="24" min="1" max="32">
<button onclick="hostnetAdd('${ifc.name}')">Add address</button>
</div>
<div class="hostnet-form">
<input type="text" id="hn-old-${ifc.name}" placeholder="old address">
<input type="text" id="hn-new-${ifc.name}" placeholder="new address">
<input type="number" id="hn-nprefix-${ifc.name}" value="24" min="1" max="32">
<button onclick="hostnetChange('${ifc.name}')">Change</button>
</div>
<div class="fault-buttons">
<button onclick="hostnetRestore('${ifc.name}')">Restore last change</button>
</div>` : ""}
</div>`;
}
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 ---------------- // ---------------- tabs ----------------
document.querySelectorAll(".tab").forEach((btn) => { document.querySelectorAll(".tab").forEach((btn) => {
@@ -804,6 +934,7 @@ document.querySelectorAll(".tab").forEach((btn) => {
document.getElementById(`tab-${btn.dataset.tab}`).classList.add("active"); document.getElementById(`tab-${btn.dataset.tab}`).classList.add("active");
if (btn.dataset.tab === "diagnostics") refreshDiagnostics(); if (btn.dataset.tab === "diagnostics") refreshDiagnostics();
if (btn.dataset.tab === "config") renderConfig(); if (btn.dataset.tab === "config") renderConfig();
if (btn.dataset.tab === "hostnet") refreshHostnet();
}); });
}); });
+23 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<title>WAGO 750-362 Simulator</title> <title>WAGO 750-362 Simulator</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/style.css?v=4"> <link rel="stylesheet" href="/static/style.css?v=5">
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
@@ -19,6 +19,7 @@
<button data-tab="overview" class="tab active">Overview</button> <button data-tab="overview" class="tab active">Overview</button>
<button data-tab="config" class="tab">Configuration</button> <button data-tab="config" class="tab">Configuration</button>
<button data-tab="diagnostics" class="tab">Diagnostics</button> <button data-tab="diagnostics" class="tab">Diagnostics</button>
<button data-tab="hostnet" class="tab hidden" id="tab-btn-hostnet">Host network</button>
</nav> </nav>
<div class="topbar-right"> <div class="topbar-right">
<span id="conn-state" class="conn-dot" title="WebSocket state"></span> <span id="conn-state" class="conn-dot" title="WebSocket state"></span>
@@ -115,8 +116,28 @@
<div class="diag-card" id="diag-faults"></div> <div class="diag-card" id="diag-faults"></div>
</div> </div>
</section> </section>
<!-- ============ Host network settings (spec §15) ============ -->
<!-- Hidden unless the server exposes the feature (flag + docker
gates); the tab button above stays .hidden until then. -->
<section id="tab-hostnet" class="tab-page">
<h2>Host network settings</h2>
<div id="hostnet-banner" class="diag-card hostnet-banner hidden">
<p id="hostnet-banner-text"></p>
</div>
<div id="hostnet-body"></div>
<div class="diag-card" id="hostnet-audit-card">
<h3>Audit log</h3>
<p class="cfg-note">Every mutation and every refusal, newest last.
The agent appends the same lines to the service log (journald).</p>
<pre id="hostnet-audit" class="hostnet-audit"></pre>
<div class="fault-buttons">
<button id="hostnet-audit-refresh">Refresh</button>
</div>
</div>
</section>
</main> </main>
<script src="/static/app.js?v=4"></script> <script src="/static/app.js?v=5"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<title>WAGO Simulator — Login</title> <title>WAGO Simulator — Login</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/style.css?v=4"> <link rel="stylesheet" href="/static/style.css?v=5">
</head> </head>
<body class="login-body"> <body class="login-body">
<div class="login-card"> <div class="login-card">
+33 -1
View File
@@ -376,4 +376,36 @@ button.primary:hover { background: #1b5e20; }
} }
.range-sep { color: #6b7684; } .range-sep { color: #6b7684; }
.hidden-file { display: none; } .hidden-file { display: none; }
#cfg-io-buttons { display: flex; gap: 8px; margin-top: 8px; } #cfg-io-buttons { display: flex; gap: 8px; margin-top: 8px; }
/* ---------- host network settings (spec §15) ---------- */
.hostnet-banner {
border-left: 4px solid #e8a23c;
background: #fdf6e9;
margin-bottom: 14px;
}
.hostnet-banner p { margin: 0; color: #7a5b12; font-size: 0.88rem; }
.hostnet-card h3 { display: flex; align-items: center; gap: 8px; }
.hostnet-flag {
font-size: 0.68rem; font-weight: 500; padding: 2px 7px;
border-radius: 10px; text-transform: uppercase; letter-spacing: 0.03em;
}
.hostnet-flag.ok { background: #e2f5e6; color: #1d7a33; }
.hostnet-flag.off { background: #e8ecf1; color: #56606c; }
.hostnet-addrs { width: 100%; margin-bottom: 8px; }
.hostnet-form {
display: flex; gap: 8px; align-items: center; margin-top: 8px;
flex-wrap: wrap;
}
.hostnet-form input {
padding: 6px 8px; border: 1px solid #c5ccd4; border-radius: 4px;
font-size: 0.85rem; font-family: Consolas, monospace;
}
.hostnet-form input[type="number"] { width: 70px; }
.hostnet-audit {
max-height: 220px; overflow-y: auto; background: #f4f6f8;
border: 1px solid #e3e7ec; border-radius: 4px; padding: 10px 12px;
font-family: Consolas, monospace; font-size: 0.78rem;
white-space: pre-wrap; margin: 0 0 10px;
}
+530
View File
@@ -0,0 +1,530 @@
"""Host network settings (spec §15): agent ops, gating, web API.
Two layers:
* the agent protocol itself — validation, whitelist refusals,
auto-snapshot + restore, audit trail, config loading (FakeBackend;
the Linux backend shares the same dispatch in `handle`)
* the web API — the FR-15.2.1/2 gates (404 when off), readonly 403,
mutation mapping through an InProcessClient-backed HostNetService
(the socket path is exercised only on real Linux hosts).
"""
from __future__ import annotations
import pytest
from wago_sim.hostnet import (FakeBackend, HostNetAgent, HostNetError,
HostNetRefused, HostNetService,
HostNetUnavailable, InProcessClient,
hostnet_enabled, load_agent_config,
parse_iface, parse_ipv4, parse_prefix)
from wago_sim.webui.app import build_webui
@pytest.fixture
def agent():
"""Agent with two whitelisted interfaces on the fake backend."""
return HostNetAgent(
backend=FakeBackend({"eth0": ["192.168.1.10/24"],
"eth1": ["10.0.0.5/8"],
"lo": ["127.0.0.1/8"]}),
whitelist=["eth0", "eth1"],
)
@pytest.fixture
def service(agent):
return HostNetService(InProcessClient(agent))
@pytest.fixture
async def hostnet_client(service):
"""httpx client against a web UI with the hostnet service mounted."""
from wago_sim.app import build_app
from wago_sim.modules import DI_8, DO_8
sim = build_app(modules=[DI_8, DO_8])
webui = build_webui(sim, user="admin", password="simu",
hostnet_service=service)
import httpx
transport = httpx.ASGITransport(app=webui)
async with httpx.AsyncClient(transport=transport,
base_url="http://test") as c:
r = await c.post("/login", json={"user": "admin", "password": "simu"})
assert r.status_code == 200
yield c
@pytest.fixture
async def plain_client():
"""httpx client against a web UI withOUT the hostnet service."""
from wago_sim.app import build_app
from wago_sim.modules import DI_8, DO_8
sim = build_app(modules=[DI_8, DO_8])
webui = build_webui(sim, user="admin", password="simu")
import httpx
transport = httpx.ASGITransport(app=webui)
async with httpx.AsyncClient(transport=transport,
base_url="http://test") as c:
r = await c.post("/login", json={"user": "admin", "password": "simu"})
assert r.status_code == 200
yield c
# ---------------------------------------------------------------------------
# Validation helpers (FR-15.2.4 — used by both the API and the agent)
# ---------------------------------------------------------------------------
class TestValidation:
def test_ipv4_normalized(self):
assert parse_ipv4(" 192.168.1.33 ") == "192.168.1.33"
@pytest.mark.parametrize("bad", ["banana", "1.2.3", "1.2.3.999", "",
None, 33, "192.168.1.1/24"])
def test_ipv4_rejected(self, bad):
with pytest.raises(HostNetError):
parse_ipv4(bad)
def test_prefix_bounds(self):
assert parse_prefix(24) == 24
assert parse_prefix("28") == 28
for bad in (0, 33, "x", None, -1):
with pytest.raises(HostNetError):
parse_prefix(bad)
def test_iface_name_shape(self):
assert parse_iface("eth0") == "eth0"
assert parse_iface("enp3s0") == "enp3s0"
assert parse_iface("br-123_4") == "br-123_4"
for bad in ("", None, "eth 0", "eth0; rm -rf /", "x" * 16, "/proc/x"):
with pytest.raises(HostNetError):
parse_iface(bad)
# ---------------------------------------------------------------------------
# Agent protocol (FR-15.2.4 / 15.2.5)
# ---------------------------------------------------------------------------
class TestAgentOps:
def test_health_reports_whitelist(self, agent):
r = agent.handle({"op": "health"})
assert r["ok"] is True
assert r["whitelist"] == ["eth0", "eth1"]
assert r["protocol"] == 1
def test_list_is_live_state(self, agent):
r = agent.handle({"op": "list"})
names = {i["name"] for i in r["interfaces"]}
assert names == {"eth0", "eth1", "lo"}
eth0 = next(i for i in r["interfaces"] if i["name"] == "eth0")
assert eth0["addresses"] == [{"address": "192.168.1.10", "prefix": 24}]
def test_add_address(self, agent):
r = agent.handle({"op": "add_address", "iface": "eth0",
"address": "192.168.1.33", "prefix": 24})
assert r["ok"] is True
eth0 = next(i for i in agent.handle({"op": "list"})["interfaces"]
if i["name"] == "eth0")
assert {"address": "192.168.1.33", "prefix": 24} in eth0["addresses"]
def test_add_duplicate_rejected(self, agent):
agent.handle({"op": "add_address", "iface": "eth0",
"address": "192.168.1.33", "prefix": 24})
r = agent.handle({"op": "add_address", "iface": "eth0",
"address": "192.168.1.33", "prefix": 24})
assert r["ok"] is False
assert "already exists" in r["error"]
def test_remove_address(self, agent):
agent.handle({"op": "add_address", "iface": "eth0",
"address": "192.168.1.33", "prefix": 24})
r = agent.handle({"op": "remove_address", "iface": "eth0",
"address": "192.168.1.33"})
assert r["ok"] is True
eth0 = next(i for i in agent.handle({"op": "list"})["interfaces"]
if i["name"] == "eth0")
assert all(a["address"] != "192.168.1.33" for a in eth0["addresses"])
def test_remove_missing_rejected(self, agent):
r = agent.handle({"op": "remove_address", "iface": "eth0",
"address": "9.9.9.9"})
assert r["ok"] is False
assert "not assigned" in r["error"]
def test_change_address(self, agent):
r = agent.handle({"op": "change_address", "iface": "eth0",
"old": "192.168.1.10", "new": "192.168.1.20",
"prefix": 24})
assert r["ok"] is True
eth0 = next(i for i in agent.handle({"op": "list"})["interfaces"]
if i["name"] == "eth0")
assert {"address": "192.168.1.20", "prefix": 24} in eth0["addresses"]
assert all(a["address"] != "192.168.1.10" for a in eth0["addresses"])
def test_whitelist_refusal(self, agent):
# lo exists but is NOT whitelisted -> refused + audited
r = agent.handle({"op": "add_address", "iface": "lo",
"address": "127.0.0.2", "prefix": 8})
assert r["ok"] is False
assert r.get("refused") is True
assert "whitelist" in r["error"]
def test_unknown_interface_refused(self, agent):
r = agent.handle({"op": "snapshot", "iface": "wlan9"})
assert r["ok"] is False
assert r.get("refused") is True
def test_unknown_op(self, agent):
r = agent.handle({"op": "reboot_host"})
assert r["ok"] is False
assert "unknown operation" in r["error"]
def test_invalid_input_rejected(self, agent):
r = agent.handle({"op": "add_address", "iface": "eth0",
"address": "not-an-ip", "prefix": 24})
assert r["ok"] is False
assert "invalid IPv4" in r["error"]
class TestSnapshotRestore:
"""FR-15.2.5: snapshot before every mutation; restore walks back."""
def _addresses(self, agent, iface):
return [a["address"] for i in agent.handle({"op": "list"})["interfaces"]
if i["name"] == iface for a in i["addresses"]]
def test_auto_snapshot_on_add(self, agent):
before = self._addresses(agent, "eth0")
agent.handle({"op": "add_address", "iface": "eth0",
"address": "192.168.1.33", "prefix": 24})
# restore walks back to the pre-add state
r = agent.handle({"op": "restore", "iface": "eth0"})
assert r["ok"] is True
assert self._addresses(agent, "eth0") == before
def test_restore_after_remove(self, agent):
agent.handle({"op": "remove_address", "iface": "eth0",
"address": "192.168.1.10"})
assert "192.168.1.10" not in self._addresses(agent, "eth0")
agent.handle({"op": "restore", "iface": "eth0"})
assert "192.168.1.10" in self._addresses(agent, "eth0")
def test_restore_without_snapshot_rejected(self, agent):
r = agent.handle({"op": "restore", "iface": "eth1"})
# eth1 had no mutation yet -> nothing to restore
assert r["ok"] is False
assert "no snapshot" in r["error"]
def test_explicit_snapshot_op(self, agent):
r = agent.handle({"op": "snapshot", "iface": "eth0"})
assert r["ok"] is True
assert [a[0] for a in r["addresses"]] == ["192.168.1.10"]
class TestAudit:
def test_mutations_and_refusals_audited(self, agent):
agent.handle({"op": "add_address", "iface": "eth0",
"address": "192.168.1.33", "prefix": 24,
"operator": "admin"})
agent.handle({"op": "add_address", "iface": "lo",
"address": "127.0.0.2", "prefix": 8})
lines = agent.handle({"op": "audit", "limit": 50})["lines"]
joined = "\n".join(lines)
assert "add eth0 192.168.1.33/24 by admin" in joined
assert "REFUSED" in joined
assert "whitelist" in joined
def test_audit_bounded(self, agent):
for i in range(10):
agent.handle({"op": "add_address", "iface": "eth0",
"address": f"10.60.{i}.1", "prefix": 24})
lines = agent.handle({"op": "audit", "limit": 5})["lines"]
assert len(lines) == 5
def test_operator_tagged(self, agent):
agent.handle({"op": "add_address", "iface": "eth0",
"address": "10.9.9.1", "prefix": 24,
"operator": "commissioner"})
joined = "\n".join(agent.handle({"op": "audit"})["lines"])
assert "by commissioner" in joined
# ---------------------------------------------------------------------------
# Gating (FR-15.2.1 / 15.2.2)
# ---------------------------------------------------------------------------
class TestGating:
def test_off_without_flag(self, tmp_path):
assert hostnet_enabled({"WAGO_SIM_HOSTNET": ""}, []) is False
assert hostnet_enabled({}, []) is False
def test_flag_alone_is_not_enough(self, tmp_path):
# flag set, docker markers absent, no env marker -> off
assert hostnet_enabled(
{"WAGO_SIM_HOSTNET": "1", "WAGO_SIM_IN_DOCKER": "0"}, []) is False
def test_flag_and_docker_env(self):
assert hostnet_enabled(
{"WAGO_SIM_HOSTNET": "1", "WAGO_SIM_IN_DOCKER": "1"}, []) is True
def test_flag_and_marker_file(self, tmp_path):
marker = tmp_path / ".dockerenv"
marker.write_text("")
assert hostnet_enabled({"WAGO_SIM_HOSTNET": "true"}, [marker]) is True
@pytest.mark.parametrize("value", ["1", "true", "yes", "on", "YES", " On "])
def test_flag_truthy_forms(self, value):
assert hostnet_enabled(
{"WAGO_SIM_HOSTNET": value, "WAGO_SIM_IN_DOCKER": "1"}, []) is True
def test_flag_falsy_form(self):
assert hostnet_enabled(
{"WAGO_SIM_HOSTNET": "0", "WAGO_SIM_IN_DOCKER": "1"}, []) is False
# ---------------------------------------------------------------------------
# Agent config + startup rules
# ---------------------------------------------------------------------------
class TestAgentConfig:
def test_valid_config(self, tmp_path):
cfg_file = tmp_path / "agent.yaml"
cfg_file.write_text(
"socket: /tmp/x.sock\nwhitelist: [eth0, eth1]\naudit_path: /tmp/a.log\n")
cfg = load_agent_config(str(cfg_file))
assert cfg["socket"] == "/tmp/x.sock"
assert cfg["whitelist"] == ["eth0", "eth1"]
def test_missing_file(self):
with pytest.raises(HostNetError):
load_agent_config("nope/missing.yaml")
def test_empty_whitelist_refused_at_startup(self, tmp_path):
# a config without a whitelist must not start the agent
cfg_file = tmp_path / "agent.yaml"
cfg_file.write_text("socket: /tmp/x.sock\nwhitelist: []\n")
with pytest.raises(HostNetError):
HostNetAgent(backend=FakeBackend(), whitelist=[])
def test_bad_yaml(self, tmp_path):
cfg_file = tmp_path / "agent.yaml"
cfg_file.write_text("socket: [unclosed\n")
with pytest.raises(HostNetError):
load_agent_config(str(cfg_file))
# ---------------------------------------------------------------------------
# Web API (spec §15.3 surface)
# ---------------------------------------------------------------------------
class TestHostnetAPI:
async def test_disabled_routes_are_404(self, plain_client):
# FR-15.2.1/2: no page, no API surface when the gates are off
r = await plain_client.get("/api/hostnet")
assert r.status_code == 404
r = await plain_client.get("/api/hostnet/audit")
assert r.status_code == 404
r = await plain_client.post("/api/hostnet/interfaces/eth0/addresses",
json={"address": "10.1.1.1", "prefix": 24})
assert r.status_code == 404
async def test_status_and_list(self, hostnet_client):
r = await hostnet_client.get("/api/hostnet")
assert r.status_code == 200
data = r.json()
assert data["status"]["available"] is True
assert data["status"]["agent"]["whitelist"] == ["eth0", "eth1"]
names = {i["name"] for i in data["interfaces"]}
assert names == {"eth0", "eth1", "lo"}
async def test_add_then_remove_via_api(self, hostnet_client):
r = await hostnet_client.post(
"/api/hostnet/interfaces/eth0/addresses",
json={"address": "192.168.1.33", "prefix": 24})
assert r.status_code == 200, r.text
assert r.json()["address"] == "192.168.1.33"
r = await hostnet_client.delete(
"/api/hostnet/interfaces/eth0/addresses/192.168.1.33")
assert r.status_code == 200
async def test_change_via_api(self, hostnet_client):
r = await hostnet_client.put(
"/api/hostnet/interfaces/eth0/addresses",
json={"old": "192.168.1.10", "new": "192.168.1.20",
"prefix": 24})
assert r.status_code == 200
r = await hostnet_client.get("/api/hostnet")
eth0 = next(i for i in r.json()["interfaces"] if i["name"] == "eth0")
assert {"address": "192.168.1.20", "prefix": 24} in eth0["addresses"]
async def test_restore_via_api(self, hostnet_client):
await hostnet_client.post(
"/api/hostnet/interfaces/eth0/addresses",
json={"address": "192.168.1.33", "prefix": 24})
r = await hostnet_client.post("/api/hostnet/interfaces/eth0/restore")
assert r.status_code == 200
r = await hostnet_client.get("/api/hostnet")
eth0 = next(i for i in r.json()["interfaces"] if i["name"] == "eth0")
assert all(a["address"] != "192.168.1.33" for a in eth0["addresses"])
async def test_bad_input_400(self, hostnet_client):
r = await hostnet_client.post(
"/api/hostnet/interfaces/eth0/addresses",
json={"address": "banana", "prefix": 24})
assert r.status_code == 400
assert "invalid IPv4" in r.json()["detail"]
async def test_non_whitelisted_403(self, hostnet_client):
r = await hostnet_client.post(
"/api/hostnet/interfaces/lo/addresses",
json={"address": "127.0.0.2", "prefix": 8})
assert r.status_code == 403
assert "whitelist" in r.json()["detail"]
async def test_audit_view(self, hostnet_client):
await hostnet_client.post(
"/api/hostnet/interfaces/eth0/addresses",
json={"address": "192.168.1.33", "prefix": 24})
r = await hostnet_client.get("/api/hostnet/audit")
assert r.status_code == 200
assert "192.168.1.33" in "\n".join(r.json()["lines"])
async def test_operator_in_audit_trail(self, hostnet_client):
await hostnet_client.post(
"/api/hostnet/interfaces/eth0/addresses",
json={"address": "10.77.0.1", "prefix": 24})
r = await hostnet_client.get("/api/hostnet/audit")
# the logged-in user (admin) is named in every mutation line
assert "by admin" in "\n".join(r.json()["lines"])
class TestHostnetReadonly:
@pytest.fixture
async def ro_client(self, agent):
from wago_sim.app import build_app
from wago_sim.modules import DI_8, DO_8
import httpx
sim = build_app(modules=[DI_8, DO_8])
service = HostNetService(InProcessClient(agent))
webui = build_webui(sim, user="admin", password="simu",
readonly=True, hostnet_service=service)
transport = httpx.ASGITransport(app=webui)
async with httpx.AsyncClient(transport=transport,
base_url="http://test") as c:
await c.post("/login", json={"user": "admin", "password": "simu"})
yield c
async def test_read_status_allowed(self, ro_client):
r = await ro_client.get("/api/hostnet")
assert r.status_code == 200
assert r.json()["status"]["available"] is True
async def test_mutations_403_in_readonly(self, ro_client):
r = await ro_client.post(
"/api/hostnet/interfaces/eth0/addresses",
json={"address": "192.168.1.33", "prefix": 24})
assert r.status_code == 403
r = await ro_client.delete(
"/api/hostnet/interfaces/eth0/addresses/192.168.1.10")
assert r.status_code == 403
r = await ro_client.put(
"/api/hostnet/interfaces/eth0/addresses",
json={"old": "192.168.1.10", "new": "1.2.3.4", "prefix": 24})
assert r.status_code == 403
r = await ro_client.post("/api/hostnet/interfaces/eth0/restore")
assert r.status_code == 403
class TestUnavailableAgent:
async def test_unavailable_reported_not_raised(self):
"""FR-15.2.8: agent down -> available:false, no 5xx, banner text."""
from wago_sim.app import build_app
from wago_sim.modules import DI_8, DO_8
import httpx
class DeadClient:
"""Agent down: every op raises HostNetUnavailable (FR-15.2.8)."""
def health(self):
raise HostNetUnavailable("socket not found")
def list(self):
raise HostNetUnavailable("socket not found")
def add_address(self, iface, address, prefix, operator=""):
raise HostNetUnavailable("socket not found")
def remove_address(self, iface, address, operator=""):
raise HostNetUnavailable("socket not found")
def change_address(self, iface, old, new, prefix, operator=""):
raise HostNetUnavailable("socket not found")
def restore(self, iface, operator=""):
raise HostNetUnavailable("socket not found")
def audit(self, limit=50):
raise HostNetUnavailable("socket not found")
sim = build_app(modules=[DI_8, DO_8])
webui = build_webui(sim, user="admin", password="simu",
hostnet_service=HostNetService(DeadClient()))
transport = httpx.ASGITransport(app=webui)
async with httpx.AsyncClient(transport=transport,
base_url="http://test") as c:
await c.post("/login", json={"user": "admin", "password": "simu"})
r = await c.get("/api/hostnet")
assert r.status_code == 200
data = r.json()
assert data["status"]["available"] is False
assert "socket not found" in data["status"]["error"]
assert data["interfaces"] is None
# mutations report 503 with the same message
r = await c.post("/api/hostnet/interfaces/eth0/addresses",
json={"address": "1.2.3.4", "prefix": 24})
assert r.status_code == 503
async def test_hostnet_client_without_socket(self, tmp_path):
from wago_sim.hostnet import HostNetClient
client = HostNetClient(socket_path=str(tmp_path / "missing.sock"))
with pytest.raises(HostNetUnavailable):
client.health()
class TestIproute2ParseFallback:
"""The `ip -o` output parsers — the fallback read path."""
def test_addr_parse(self, monkeypatch):
monkeypatch.setenv("PATH", "/nonexistent") # `ip` never found
from wago_sim.hostnet import Iproute2Backend
backend = Iproute2Backend.__new__(Iproute2Backend)
backend._ipr = None
sample = ("2: eth0 inet 192.168.1.5/24 brd 192.168.1.255 "
"scope global eth0\n"
"3: eth1 inet 10.0.0.9/8 brd 10.255.255.255 "
"scope global eth1\n"
"4: docker0 inet 172.17.0.1/16 brd 172.17.255.255 "
"scope global docker0\n")
monkeypatch.setattr(backend, "_ip_cmd", lambda cmd: sample)
addrs = backend._addr_list()
assert ("eth0", "192.168.1.5", 24) in addrs
assert ("eth1", "10.0.0.9", 8) in addrs
def test_link_parse(self, monkeypatch):
monkeypatch.setenv("PATH", "/nonexistent")
from wago_sim.hostnet import Iproute2Backend
backend = Iproute2Backend.__new__(Iproute2Backend)
backend._ipr = None
sample = ("1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue\n"
"2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500\n"
"3: eth9: <BROADCAST,MULTICAST> mtu 1500\n")
monkeypatch.setattr(backend, "_ip_cmd", lambda cmd: sample)
links = backend._link_list()
assert ("lo", True) in links
assert ("eth0", True) in links
assert ("eth9", False) in links
+4 -2
View File
@@ -283,8 +283,10 @@ class TestEnvControl:
assert st["sim_time_ms"] > 0 assert st["sim_time_ms"] > 0
r = await client.post("/api/env/reset") r = await client.post("/api/env/reset")
assert r.status_code == 200 assert r.status_code == 200
st = (await client.get("/api/env")).json() # the reset RESPONSE carries the post-reset status: asserting
assert st["sim_time_ms"] == 0.0 # deterministic fresh episode # on a follow-up GET would race the running 10 ms ticker
# (which legitimately ticks between the two requests)
assert r.json()["sim_time_ms"] == 0.0 # deterministic fresh episode
await client.post("/api/env/stop") await client.post("/api/env/stop")
async def test_restart_fresh_instance(self, client, app): async def test_restart_fresh_instance(self, client, app):