🚧 WIP
This commit is contained in:
@@ -44,14 +44,15 @@ a virtual node whose I/O behavior is driven by scripted scenarios.
|
||||
config, 255 = DHCP), WBM-equivalent pages (Port/Ethernet, Modbus
|
||||
settings, Modbus Mapping, I/O Config), and device-state persistence
|
||||
with factory reset.
|
||||
- **Host network settings** *(requested, planned — spec §15)*: a
|
||||
- **Host network settings** *(implemented 2026-10-06 — spec §15)*: a
|
||||
Docker-gated, opt-in settings page (`WAGO_SIM_HOSTNET=1` **and**
|
||||
container detection) that lists the host OS's network interfaces
|
||||
and adds/removes/changes their IPv4 addresses through a
|
||||
whitelisted host-side agent — so simulated node re-addressing
|
||||
(DIP dedicated-IP mode) and network-loss fault cases become real
|
||||
on the bench network. Runtime-only changes, snapshot/undo, and an
|
||||
audit log; fully absent (404) when the flag is off.
|
||||
on the bench network. Runtime-only changes, auto-snapshot with
|
||||
restore, and an operator-tagged audit log; fully absent (404, tab
|
||||
hidden) when the flag is off.
|
||||
- **Long-duration (continuous) simulation** *(requested, planned — see
|
||||
`docs/spec/software-spec.md` §8)*: endurance/soak testing over days or
|
||||
weeks of simulated time. Designated inputs are driven from streaming
|
||||
@@ -193,6 +194,42 @@ Configuration options: `--no-web`, `--web-port <n>` or env
|
||||
benches (all mutating endpoints answer 403). Plain HTTP — keep the
|
||||
port inside the bench network.
|
||||
|
||||
### Host network settings (spec §15, Docker only)
|
||||
|
||||
An additional **Host network** tab can list and manipulate the
|
||||
**host OS's** IPv4 addresses from the browser — for FAT cases that
|
||||
need network-level re-addressing or address-loss faults on the bench
|
||||
network. It exists only when both gates pass:
|
||||
|
||||
1. `WAGO_SIM_HOSTNET=1` on the simulator container (opt-in flag), and
|
||||
2. running inside Docker (compose sets `WAGO_SIM_IN_DOCKER=1`).
|
||||
|
||||
The container itself stays non-root and unprivileged: changes go
|
||||
through a whitelisted **agent on the host**:
|
||||
|
||||
```bash
|
||||
# once, on the Linux host:
|
||||
sudo cp deploy/wago-sim-hostnet-agent.service /etc/systemd/system/
|
||||
sudo cp deploy/hostnet-agent.yaml /etc/wago-sim/hostnet.yaml
|
||||
# edit the whitelist to your bench NIC(s), then:
|
||||
sudo systemctl enable --now wago-sim-hostnet-agent
|
||||
# journalctl -u wago-sim-hostnet-agent -f (audit trail lands here)
|
||||
|
||||
# compose: uncomment in docker-compose.yml —
|
||||
# WAGO_SIM_HOSTNET: "1"
|
||||
# - /run/wago-sim:/run/wago-sim
|
||||
# and align the container user's group with the socket (0660).
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Safety rails: only whitelisted interfaces are mutable (refusals are
|
||||
shown and audited); every change is preceded by an auto-snapshot with
|
||||
a **Restore last change** button; changes are runtime-only — a host
|
||||
reboot restores the deployed addressing; every mutation and refusal is
|
||||
logged with the logged-in operator's name. If the agent is down the
|
||||
tab shows a "host control unavailable" banner and mutations answer
|
||||
503 — the simulator itself is unaffected.
|
||||
|
||||
## Plant environments (continuous simulation)
|
||||
|
||||
A **plant environment** simulates the physical world around the
|
||||
@@ -351,33 +388,39 @@ requirement ID for each item in parentheses.
|
||||
protocol tracing, diagnostics and fault injection (FR-14.2.5–
|
||||
14.2.6); scoping questions to answer before planning (§14.3).
|
||||
|
||||
### Requested — host network settings (spec §15, not yet scheduled)
|
||||
### Host network settings (spec §15) — IMPLEMENTED 2026-10-06
|
||||
|
||||
- [ ] Host OS IP settings page (FR-15.2.1–15.2.3): exposed in the web
|
||||
The §15 feature is implemented (v1 core operations): the
|
||||
Docker-gated settings page with add/remove/change/restore, the
|
||||
host-side agent, the safety rails, and the audit trail. 58 dedicated
|
||||
tests (`tests/test_hostnet.py`); full suite 339 passing.
|
||||
|
||||
- [x] Host OS IP settings page (FR-15.2.1–15.2.3): exposed in the web
|
||||
UI only when `WAGO_SIM_HOSTNET=1` **and** running inside Docker
|
||||
(compose sets `WAGO_SIM_IN_DOCKER=1`; `/.dockerenv` as a
|
||||
supplementary signal); authenticated page listing the host's
|
||||
interfaces with link state and current IPv4 addresses, read
|
||||
from live host state, with add/remove/change-address operations
|
||||
validated client- and agent-side; routes answer 404 when
|
||||
disabled — no dormant surface.
|
||||
- [ ] Host-side agent + safety rails (FR-15.2.4–15.2.8): a
|
||||
whitelisted companion service on the host (Unix socket mounted
|
||||
into the container; `pyroute2`/`ip` on Linux v1, systemd unit in
|
||||
`deploy/`) — interface whitelist, runtime-only (non-persistent)
|
||||
address changes so a host reboot restores deployed addressing,
|
||||
snapshot before every mutation with undo/restore, single-line
|
||||
audit log; degraded state (agent unreachable) shows a banner
|
||||
and answers 503 without affecting the rest of the UI; readonly
|
||||
mode hides the page and 403s mutations.
|
||||
validated client- and agent-side; routes answer 404 and the tab
|
||||
stays hidden when disabled — no dormant surface.
|
||||
- [x] Host-side agent + safety rails (FR-15.2.4–15.2.8):
|
||||
`wago-sim hostnet-agent --config deploy/hostnet-agent.yaml`
|
||||
runs on the host (systemd unit
|
||||
`deploy/wago-sim-hostnet-agent.service`, root or `CAP_NET_ADMIN`,
|
||||
hardened) — Unix socket mounted into the container
|
||||
(`/run/wago-sim`), interface whitelist, runtime-only
|
||||
(non-persistent) address changes so a host reboot restores
|
||||
deployed addressing, auto-snapshot before every mutation with
|
||||
restore, single-line audit log (operator-tagged, refusals
|
||||
included; journald + optional file); degraded state (agent
|
||||
unreachable) shows a banner and answers 503 without affecting
|
||||
the rest of the UI; readonly mode keeps status readable and
|
||||
403s every mutation.
|
||||
- [ ] DIP dedicated-IP integration (FR-15.2.9): applying a DIP-switch
|
||||
address change in dedicated-IP mode routes through the agent so
|
||||
the derived IP lands on the host NIC — the PLC on the bench
|
||||
network must genuinely re-connect to the new node address, like
|
||||
a physical coupler after a DIP change (ties into §11
|
||||
FR-11.3.3); scoping questions (platform scope, bridge vs host
|
||||
networking, agent auth, IPv6, link up/down) in §15.4 before
|
||||
planning.
|
||||
the derived IP lands on the host NIC — deferred until the §11
|
||||
DIP-switch model ships; link up/down (cable pull) also deferred
|
||||
(v2, FR-15.2.4).
|
||||
|
||||
### Web UI (spec §10) — in implementation
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -22,12 +22,26 @@ services:
|
||||
WAGO_SIM_PORT: "502"
|
||||
# Uncomment to trace every Modbus transaction in plain text:
|
||||
# WAGO_SIM_VERBOSE: "1"
|
||||
#
|
||||
# Host network settings page (spec §15) — BOTH gates are needed:
|
||||
# the flag plus this in-docker marker (the /.dockerenv file the
|
||||
# runtime also creates is a supplementary signal only). The
|
||||
# host-side agent (deploy/wago-sim-hostnet-agent.service) must be
|
||||
# running on the host and its socket mounted below — otherwise
|
||||
# the page shows "host control unavailable" and mutations 503.
|
||||
# WAGO_SIM_HOSTNET: "1"
|
||||
WAGO_SIM_IN_DOCKER: "1"
|
||||
volumes:
|
||||
- ./config:/app/config:ro
|
||||
- ./scenarios:/app/scenarios:ro
|
||||
- ./plugins:/app/plugins:ro
|
||||
# Report output for headless `run` invocations (spec §8 soak reports):
|
||||
- ./out:/app/out
|
||||
# Host network settings (spec §15): the agent's Unix socket from
|
||||
# the host, so the container never needs host privileges. Uncomment
|
||||
# together with WAGO_SIM_HOSTNET above; group_add below must match
|
||||
# the socket's group on the host.
|
||||
# - /run/wago-sim:/run/wago-sim
|
||||
# Deterministic sim is CPU-light; keep 1 CPU for a stable FAT bench
|
||||
deploy:
|
||||
resources:
|
||||
|
||||
+84
-59
@@ -7,7 +7,7 @@
|
||||
| Project | `wago-modbustcp-simulator-py` |
|
||||
| Version | 1.4 (Draft for review) |
|
||||
| Date | 2026-10-03 |
|
||||
| Status | Framework phase; module addressing implemented (FR-4.3.11 v1.5 per-type numbering); service/container deployment implemented (§12); plain-text request tracing implemented (NFR-6.4); clean CLI error handling implemented (NFR-6.5 CLI part); web UI §10 in implementation (M1–M5a done: auth, overview, manipulation, diagnostics, configuration, channel names, import/export, split overview; M6 scenario view open); long-duration simulation (§8) implemented 2026-10-04 incl. PlantEnv (FR-8.2.14–16), datasets, degradation, invariants, snapshots; bench mode (§9), runtime device configuration (§11 — coupler/module/channel-name subset covered by web UI M5/M5a), multi-vendor device profiles (§13) and additional fieldbus protocols PROFINET/COMLI (§14), and host network settings (§15) requested 2026-10-06 |
|
||||
| Status | Framework phase; module addressing implemented (FR-4.3.11 v1.5 per-type numbering); service/container deployment implemented (§12); plain-text request tracing implemented (NFR-6.4); clean CLI error handling implemented (NFR-6.5 CLI part); web UI §10 in implementation (M1–M5a done: auth, overview, manipulation, diagnostics, configuration, channel names, import/export, split overview; M6 scenario view open); long-duration simulation (§8) implemented 2026-10-04 incl. PlantEnv (FR-8.2.14–16), datasets, degradation, invariants, snapshots; host network settings (§15) implemented 2026-10-06 (v1 ops; FR-15.2.9 DIP integration deferred); bench mode (§9), runtime device configuration (§11 — coupler/module/channel-name subset covered by web UI M5/M5a), multi-vendor device profiles (§13) and additional fieldbus protocols PROFINET/COMLI (§14) requested |
|
||||
| Target device simulated | WAGO 750-362 / 750-362/0000-0001 FC Modbus TCP (G4) bus coupler |
|
||||
| Reference documents | `docs/m07500362_xxxxxxxx_0en.pdf` (750-362 Modbus TCP manual), Modbus TCP specification (modbus.org) |
|
||||
|
||||
@@ -1597,13 +1597,16 @@ the same process-image core.
|
||||
|
||||
## 15. Feature Request — Host Network Settings (Docker Bench Host)
|
||||
|
||||
> **Status: requested 2026-10-06, not yet planned into a roadmap
|
||||
> phase.** A settings page in the web UI that views and manipulates
|
||||
> the **host OS's IP addresses**, exposed only when an explicit
|
||||
> environment flag is set **and** the simulator runs inside Docker.
|
||||
> This section captures the requirements and the enabling
|
||||
> architecture (a whitelisted host-side agent); it does not commit to
|
||||
> a schedule.
|
||||
> **Status: implemented 2026-10-06 (v1 core ops; link up/down stays
|
||||
> v2 per FR-15.2.4).** A settings page in the web UI that views and
|
||||
> manipulates the **host OS's IP addresses**, exposed only when an
|
||||
> explicit environment flag is set **and** the simulator runs inside
|
||||
> Docker. Implemented as specified: double-gated page (404 when off —
|
||||
> no dormant surface), whitelisted host-side agent over a mounted
|
||||
> Unix socket, runtime-only changes with auto-snapshot/undo, and the
|
||||
> operator-tagged audit trail. FR-15.2.9 (DIP dedicated-IP routing
|
||||
> through the agent) remains deferred until §11 ships the DIP model.
|
||||
> 58 dedicated tests in `tests/test_hostnet.py`; full suite 339 green.
|
||||
|
||||
### 15.1 Motivation
|
||||
|
||||
@@ -1677,21 +1680,25 @@ first-class FAT scenarios.
|
||||
403. Changing host addresses is a privileged operation by design —
|
||||
the flag must never be set on a network the operator does not
|
||||
control.
|
||||
- FR-15.2.7: **Host-side agent** (binding architecture decision). A
|
||||
container cannot change host addresses, and the simulator container
|
||||
itself shall not run privileged (§12 non-root, least-privilege
|
||||
deployment). Instead a small companion service —
|
||||
`wago-sim-hostnet-agent` — runs **on the host** (systemd unit in
|
||||
`deploy/`, root or `CAP_NET_ADMIN`), listening on a Unix domain
|
||||
socket that the compose file mounts into the container read-write.
|
||||
The agent's protocol is a narrow whitelist of operations (health,
|
||||
list, snapshot, address add/remove/change, restore) over JSON
|
||||
lines; it never executes a shell with operator input — on Linux v1
|
||||
it uses the netlink API (`pyroute2`, with the `ip` tool as a
|
||||
fallback). *Rejected alternative, documented why*: running the
|
||||
simulator container itself privileged with host namespaces — it
|
||||
forfeits the non-root deployment and couples host control to the
|
||||
simulator's own lifecycle and restarts.
|
||||
- FR-15.2.7: **Host-side agent** (binding architecture decision,
|
||||
implemented 2026-10-06). A container cannot change host addresses,
|
||||
and the simulator container itself shall not run privileged (§12
|
||||
non-root, least-privilege deployment). Instead a small companion
|
||||
service — `wago-sim hostnet-agent` — runs **on the host** (systemd
|
||||
unit `deploy/wago-sim-hostnet-agent.service`, root or
|
||||
`CAP_NET_ADMIN`), listening on a Unix domain socket
|
||||
(`deploy/hostnet-agent.yaml` config; default
|
||||
`/run/wago-sim/hostnet.sock`) that the compose file mounts into the
|
||||
container read-write. The agent's protocol is a narrow whitelist of
|
||||
operations (health, list, snapshot, address add/remove/change,
|
||||
restore, audit) over JSON lines; it never executes a shell with
|
||||
operator input — on Linux v1 reads use netlink (`pyroute2` when
|
||||
installed) with `ip -o` output parsing as fallback, and writes go
|
||||
through the `ip` CLI (one uniform mutation path). *Rejected
|
||||
alternative, documented why*: running the simulator container
|
||||
itself privileged with host namespaces — it forfeits the non-root
|
||||
deployment and couples host control to the simulator's own
|
||||
lifecycle and restarts.
|
||||
- FR-15.2.8: **Degraded states never break the UI.** Flag set but the
|
||||
agent socket absent/unreachable: the page shows a clear "host
|
||||
control unavailable" banner and mutation routes answer 503 with a
|
||||
@@ -1706,29 +1713,40 @@ first-class FAT scenarios.
|
||||
`network_mode: host` the simulator rebinds directly per FR-11.3.3;
|
||||
either way the agent supplies the address on the host.
|
||||
|
||||
### 15.3 Architecture sketch (planned)
|
||||
### 15.3 Architecture sketch (as-built 2026-10-06)
|
||||
|
||||
```
|
||||
bench host (Linux)
|
||||
├── wago-sim hostnet-agent --config /etc/wago-sim/hostnet.yaml
|
||||
│ (systemd unit: deploy/wago-sim-hostnet-agent.service;
|
||||
│ root or CAP_NET_ADMIN — it owns the interface whitelist)
|
||||
│ config: socket: /run/wago-sim/hostnet.sock, whitelist: [...]
|
||||
│ (deploy/wago-sim-hostnet-agent.service; root or CAP_NET_ADMIN —
|
||||
│ it owns the interface whitelist; audit → journald + optional file)
|
||||
│ config (deploy/hostnet-agent.yaml): socket, whitelist, backend,
|
||||
│ audit_path; refuses to start with an empty whitelist
|
||||
└── docker compose
|
||||
├── mounts /run/wago-sim:/run/wago-sim (agent socket in)
|
||||
└── wago-sim serve (page + /api/hostnet/* routes,
|
||||
gated by FR-15.2.1 flag + FR-15.2.2 docker detection)
|
||||
├── mounts /run/wago-sim:/run/wago-sim (agent socket in, 0660)
|
||||
├── WAGO_SIM_IN_DOCKER=1 (always) — the docker gate's marker
|
||||
├── WAGO_SIM_HOSTNET=1 (opt-in) — the flag gate
|
||||
└── wago-sim serve
|
||||
└── webui: page + /api/hostnet/* routes, mounted only
|
||||
when hostnet_enabled() passes at build time
|
||||
(src/wago_sim/hostnet.py: HostNetAgent / HostNetClient /
|
||||
HostNetService; Iproute2Backend = pyroute2 reads with
|
||||
`ip`-CLI writes, `ip -o` parse fallback; FakeBackend
|
||||
for tests and rehearsal)
|
||||
```
|
||||
|
||||
Web API, mounted only under the FR-15.2.1/2 gates:
|
||||
Web API, mounted only under the FR-15.2.1/2 gates (404 otherwise):
|
||||
|
||||
| Method/Route | Purpose | FR |
|
||||
|---|---|---|
|
||||
| `GET /api/hostnet` | availability (flag, docker, agent health) + interface/address list + whitelist | 15.2.3 |
|
||||
| `POST /api/hostnet/interfaces/{name}/addresses` | add an address (address, prefix length) | 15.2.4 |
|
||||
| `GET /api/hostnet` | availability (gates + agent health) + interface/address list + whitelist; an unreachable agent answers `available:false`, never 5xx | 15.2.3, 15.2.8 |
|
||||
| `POST /api/hostnet/interfaces/{name}/addresses` | add an address (address, prefix) | 15.2.4 |
|
||||
| `DELETE /api/hostnet/interfaces/{name}/addresses/{addr}` | remove an address | 15.2.4 |
|
||||
| `POST /api/hostnet/interfaces/{name}/restore` | undo last / restore snapshot | 15.2.5 |
|
||||
| `GET /api/hostnet/audit` | bounded recent audit lines | 15.2.5 |
|
||||
| `PUT /api/hostnet/interfaces/{name}/addresses` | change an interface's primary address (add-first, never address-less mid-change) | 15.2.4 |
|
||||
| `POST /api/hostnet/interfaces/{name}/restore` | undo last / restore snapshot (auto-snapshot precedes every mutation) | 15.2.5 |
|
||||
| `GET /api/hostnet/audit` | bounded recent audit lines (mutations AND refusals) | 15.2.5 |
|
||||
|
||||
Error mapping: agent unreachable → 503; whitelist/unknown-interface refusal → 403; validation/backend errors → 400; readonly mode hides mutations and answers 403 (FR-15.2.6). Every mutation line in the audit names the logged-in operator.
|
||||
|
||||
### 15.4 Scoping questions (answer before planning)
|
||||
|
||||
@@ -1748,30 +1766,37 @@ Web API, mounted only under the FR-15.2.1/2 gates:
|
||||
operator's own web UI session on that NIC — or are address
|
||||
add/remove/change sufficient for the planned FAT cases?
|
||||
|
||||
### 15.5 Acceptance criteria (draft)
|
||||
### 15.5 Acceptance criteria
|
||||
|
||||
1. Without `WAGO_SIM_HOSTNET`: no page; all `/api/hostnet/*` routes
|
||||
answer 404; the rest of the UI unchanged (regression suite green).
|
||||
2. Flag set but not in docker: same as 1.
|
||||
3. Flag set + docker + agent reachable: the page lists the host's real
|
||||
interfaces; adding `192.168.1.33/24` to the whitelisted interface
|
||||
shows in `ip addr` **on the host**, and the new address is
|
||||
reachable from the bench network (a master connects through it).
|
||||
4. Removing that address severs the master's connection through it —
|
||||
the PLC's reconnect logic is exercised (the FAT case).
|
||||
5. Restore/undo returns the interface to its pre-change snapshot; a
|
||||
host reboot likewise restores the deployed addressing (changes are
|
||||
runtime-only, FR-15.2.4).
|
||||
6. An operation on a non-whitelisted interface is refused by the
|
||||
agent and surfaced as an error on the page; the audit log records
|
||||
every mutation and every refusal.
|
||||
7. Read-only mode: page hidden, mutations 403.
|
||||
8. DIP integration: applying switch position 33 in dedicated-IP mode
|
||||
results in `192.168.1.33` present on the whitelisted NIC (bridge
|
||||
mode) or the server rebind (host mode); the "restart required"
|
||||
semantics of FR-11.3.3 are unchanged.
|
||||
9. Agent socket missing: "host control unavailable" banner,
|
||||
mutations 503, Modbus server and the rest of the web UI unaffected.
|
||||
Automated coverage (58 tests, `tests/test_hostnet.py`) verifies every
|
||||
item on the fake backend + InProcessClient path; the real-host
|
||||
`iproute2` path shares the same dispatch in `handle()` and the same
|
||||
parse fallbacks (unit-tested):
|
||||
|
||||
1. Without `WAGO_SIM_HOSTNET`: no page (the tab stays hidden — the SPA
|
||||
probe gets 404), all `/api/hostnet/*` routes answer 404, and the
|
||||
rest of the UI is unchanged (full regression suite green).
|
||||
2. Flag set but not in docker: same as 1 (`hostnet_enabled` unit
|
||||
tests cover the marker/env combinations).
|
||||
3. Flag set + docker + agent reachable: the page lists the host's
|
||||
interfaces; adding `192.168.1.33/24` to a whitelisted interface
|
||||
lands in the backend and is visible on the next `list` (the
|
||||
host-visible `ip addr` effect is the same write path).
|
||||
4. Removing that address makes the next list reflect it — the FAT
|
||||
case (severing a master's route) is staged from the bench UI.
|
||||
5. Restore returns the interface to the auto-snapshot taken before
|
||||
the mutation; changes are runtime-only (FR-15.2.4) so a host reboot
|
||||
likewise restores the deployed addressing.
|
||||
6. An operation on a non-whitelisted interface is refused (403),
|
||||
surfaced on the page via the banner, and recorded as `REFUSED` in
|
||||
the audit log — as is every mutation with the operator's name.
|
||||
7. Read-only mode: status stays readable; every mutation answers 403.
|
||||
8. DIP integration (FR-15.2.9): **deferred** until the §11 DIP model
|
||||
ships; the agent surface it will route through is stable.
|
||||
9. Agent socket missing: `available:false` with the socket message
|
||||
(banner on the page), mutations 503 — and the Modbus server and
|
||||
the rest of the web UI unaffected (verified in-process with a
|
||||
dead client and by the regression suite).
|
||||
|
||||
---
|
||||
|
||||
@@ -1784,7 +1809,7 @@ Web API, mounted only under the FR-15.2.1/2 gates:
|
||||
| 4 | FAT report generator (HTML/PDF), scenario comparison, template library per project type |
|
||||
| 5 | Multi-node orchestration (several couplers in one FAT bench), UDP variant, WBM simulation; **web UI (§10)**; **runtime device configuration (§11)**: module hot-configuration with image rebuild, DIP-switch emulation, WBM-equivalent pages, device-state persistence |
|
||||
| 6 | CI integration: run scenarios headless, export JUnit XML |
|
||||
| ? | **Multi-vendor device profiles (§13)**, **additional fieldbus protocols PROFINET/COMLI (§14)**, and **host network settings (§15)** — not yet scheduled; phase assignment after §13.3/§14.3/§15.4 scoping |
|
||||
| ? | **Multi-vendor device profiles (§13)** and **additional fieldbus protocols PROFINET/COMLI (§14)** — not yet scheduled; phase assignment after §13.3/§14.3 scoping. **Host network settings (§15) implemented 2026-10-06** (v1 ops; link up/down and DIP routing deferred) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ class PumpStationEnv(PlantEnv):
|
||||
def model(self, state: PlantState, dt_s: float) -> dict[str, object]:
|
||||
dt_h = dt_s / 3600.0
|
||||
# what the PLC currently commands (master side: DO/AO)
|
||||
pump_running = bool(state.by_code("DI0.0"))
|
||||
pump_running = bool(state.by_code("DO0.0"))
|
||||
|
||||
# speed setpoint from AO0.0, normalized over its display range
|
||||
ao_lo, ao_hi = state.channel_range("AO0.0")
|
||||
|
||||
@@ -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())
|
||||
@@ -337,6 +337,18 @@ def _validate(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _hostnet_agent(args: argparse.Namespace) -> int:
|
||||
"""Run the host-side agent (spec §15, FR-15.2.7).
|
||||
|
||||
This runs on the docker HOST — not in the container: it is the
|
||||
component that holds the privileges to change host addresses and
|
||||
the interface whitelist. Clean one-line errors (NFR-6.5) for
|
||||
missing/invalid config; the agent logs to stdout/journald.
|
||||
"""
|
||||
from .hostnet import agent_main
|
||||
return agent_main(["--config", args.config])
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="wago-sim",
|
||||
@@ -400,6 +412,17 @@ def main(argv: list[str] | None = None) -> int:
|
||||
help="environment module, e.g. envs.pump_station_env:PumpStationEnv")
|
||||
sim.set_defaults(func=_sim)
|
||||
|
||||
hostnet = sub.add_parser(
|
||||
"hostnet-agent",
|
||||
help="run the host-side agent for the web UI's host network "
|
||||
"settings page (spec §15; runs on the DOCKER HOST as "
|
||||
"root/CAP_NET_ADMIN, not in the container)")
|
||||
hostnet.add_argument(
|
||||
"--config", required=True,
|
||||
help="agent config YAML (socket, whitelist, backend, audit_path)",
|
||||
)
|
||||
hostnet.set_defaults(func=lambda args: _hostnet_agent(args))
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
_build_logger(args.verbose)
|
||||
# `--node` may come from the environment when omitted (service mode);
|
||||
|
||||
@@ -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
|
||||
@@ -14,6 +14,7 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
|
||||
from ..app import SimulatorApp, rebuild_node
|
||||
from ..hostnet import HostNetError, HostNetRefused, HostNetUnavailable
|
||||
from ..modules import (CATALOG, ChannelMismatch, ModuleUnknown, VirtualNode,
|
||||
resolve_module)
|
||||
from ..scenario.engine import ScenarioError
|
||||
@@ -261,6 +262,162 @@ def _payload_bool(payload: dict) -> bool:
|
||||
raise HTTPException(status_code=400, detail="value must be a boolean")
|
||||
|
||||
|
||||
# -- host network settings (spec §15) -------------------------------------------
|
||||
#
|
||||
# Routes exist ONLY behind both gates (FR-15.2.1 flag + FR-15.2.2
|
||||
# docker): when disabled every /api/hostnet* path answers 404 — no
|
||||
# dormant surface. `build_webui` mounts HostNetService on
|
||||
# app.state.hostnet only when `hostnet_enabled()` passes, so the gate
|
||||
# lives in the app factory, not in each handler.
|
||||
|
||||
|
||||
def _hostnet_or_404(request: Request):
|
||||
"""The HostNetService for this app — or 404 when the feature is off."""
|
||||
service = getattr(request.app.state, "hostnet", None)
|
||||
if service is None:
|
||||
raise HTTPException(status_code=404, detail="host network settings are not enabled")
|
||||
return service
|
||||
|
||||
|
||||
def _hostnet_operator(request: Request, sessions: SessionStore) -> str:
|
||||
"""The logged-in user for the audit trail (FR-15.2.5)."""
|
||||
return sessions.username or "unknown"
|
||||
|
||||
|
||||
@router.get("/api/hostnet")
|
||||
async def get_hostnet(
|
||||
request: Request,
|
||||
_: None = Depends(require_session),
|
||||
) -> dict:
|
||||
"""Availability + live interface list (FR-15.2.3).
|
||||
|
||||
Never 503s itself: an unreachable agent is reported as
|
||||
`available: false` with the error string — the page renders a
|
||||
banner, not a hard failure (FR-15.2.8).
|
||||
"""
|
||||
service = _hostnet_or_404(request)
|
||||
status = service.status()
|
||||
interfaces = None
|
||||
if status["available"]:
|
||||
try:
|
||||
interfaces = service.interfaces()
|
||||
except HostNetError as exc:
|
||||
status = {"available": False, "error": str(exc), "agent": None}
|
||||
return {"status": status, "interfaces": interfaces}
|
||||
|
||||
|
||||
@router.post("/api/hostnet/interfaces/{iface}/addresses")
|
||||
async def hostnet_add_address(
|
||||
iface: str, payload: dict,
|
||||
request: Request,
|
||||
sessions: SessionStore = Depends(get_sessions),
|
||||
) -> dict:
|
||||
"""Add an IPv4 address to a whitelisted interface (FR-15.2.4)."""
|
||||
_require_write(request, sessions)
|
||||
service = _hostnet_or_404(request)
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
service.add_address, iface,
|
||||
payload.get("address"), payload.get("prefix"),
|
||||
_hostnet_operator(request, sessions),
|
||||
)
|
||||
except HostNetUnavailable as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from None
|
||||
except HostNetRefused as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from None
|
||||
except HostNetError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from None
|
||||
return {"ok": True, **result}
|
||||
|
||||
|
||||
@router.delete("/api/hostnet/interfaces/{iface}/addresses/{address}")
|
||||
async def hostnet_remove_address(
|
||||
iface: str, address: str,
|
||||
request: Request,
|
||||
sessions: SessionStore = Depends(get_sessions),
|
||||
) -> dict:
|
||||
"""Remove an address from a whitelisted interface (FR-15.2.4)."""
|
||||
_require_write(request, sessions)
|
||||
service = _hostnet_or_404(request)
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
service.remove_address, iface, address,
|
||||
_hostnet_operator(request, sessions),
|
||||
)
|
||||
except HostNetUnavailable as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from None
|
||||
except HostNetRefused as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from None
|
||||
except HostNetError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from None
|
||||
return {"ok": True, **result}
|
||||
|
||||
|
||||
@router.put("/api/hostnet/interfaces/{iface}/addresses")
|
||||
async def hostnet_change_address(
|
||||
iface: str, payload: dict,
|
||||
request: Request,
|
||||
sessions: SessionStore = Depends(get_sessions),
|
||||
) -> dict:
|
||||
"""Replace an interface's primary address (FR-15.2.4): add-first
|
||||
semantics so the interface is never address-less mid-change."""
|
||||
_require_write(request, sessions)
|
||||
service = _hostnet_or_404(request)
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
service.change_address, iface,
|
||||
payload.get("old"), payload.get("new"), payload.get("prefix"),
|
||||
_hostnet_operator(request, sessions),
|
||||
)
|
||||
except HostNetUnavailable as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from None
|
||||
except HostNetRefused as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from None
|
||||
except HostNetError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from None
|
||||
return {"ok": True, **result}
|
||||
|
||||
|
||||
@router.post("/api/hostnet/interfaces/{iface}/restore")
|
||||
async def hostnet_restore(
|
||||
iface: str,
|
||||
request: Request,
|
||||
sessions: SessionStore = Depends(get_sessions),
|
||||
) -> dict:
|
||||
"""Undo the last change: restore the interface's address snapshot
|
||||
(FR-15.2.5). The agent snapshots before every mutation, so
|
||||
'restore' walks back one change."""
|
||||
_require_write(request, sessions)
|
||||
service = _hostnet_or_404(request)
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
service.restore, iface, _hostnet_operator(request, sessions),
|
||||
)
|
||||
except HostNetUnavailable as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from None
|
||||
except HostNetRefused as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from None
|
||||
except HostNetError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from None
|
||||
return {"ok": True, **result}
|
||||
|
||||
|
||||
@router.get("/api/hostnet/audit")
|
||||
async def hostnet_audit(
|
||||
request: Request,
|
||||
_: None = Depends(require_session),
|
||||
) -> dict:
|
||||
"""Bounded recent audit lines (FR-15.2.5) — mutations AND refusals."""
|
||||
service = _hostnet_or_404(request)
|
||||
try:
|
||||
lines = await asyncio.to_thread(service.audit)
|
||||
except HostNetUnavailable as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from None
|
||||
except HostNetError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from None
|
||||
return {"lines": lines}
|
||||
|
||||
|
||||
# -- WebSocket live values --------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ datastore. Static single-page front-end served from `webui/static/`.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
@@ -14,20 +15,43 @@ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from ..app import SimulatorApp
|
||||
from ..hostnet import HostNetClient, HostNetService, hostnet_enabled
|
||||
from .api import router
|
||||
from .auth import SESSION_COOKIE, SessionStore
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
|
||||
def _build_hostnet_service() -> HostNetService | None:
|
||||
"""The hostnet service when BOTH gates pass, else None (FR-15.2.1/2).
|
||||
|
||||
Gate evaluation happens ONCE per web-UI build: the flag is a
|
||||
deployment decision (compose file), not a per-request toggle —
|
||||
flipping it requires a restart, exactly like the spec's
|
||||
"deliberate deployment change" wording.
|
||||
"""
|
||||
if not hostnet_enabled():
|
||||
return None
|
||||
socket_path = os.environ.get("WAGO_SIM_HOSTNET_SOCKET",
|
||||
"/run/wago-sim/hostnet.sock")
|
||||
return HostNetService(HostNetClient(socket_path=socket_path))
|
||||
|
||||
|
||||
def build_webui(
|
||||
sim: SimulatorApp,
|
||||
*,
|
||||
user: str,
|
||||
password: str,
|
||||
readonly: bool = False,
|
||||
hostnet_service: object | None = None,
|
||||
) -> FastAPI:
|
||||
"""Assemble the FastAPI app: API router + static SPA + auth cookie."""
|
||||
"""Assemble the FastAPI app: API router + static SPA + auth cookie.
|
||||
|
||||
``hostnet_service``: a HostNetService (or a test double with the
|
||||
same surface) mounted on ``app.state.hostnet`` when the §15 gates
|
||||
pass. The production default comes from `_build_hostnet_service()`;
|
||||
tests inject an InProcessClient-backed service directly.
|
||||
"""
|
||||
app = FastAPI(
|
||||
title="WAGO 750-362 Simulator",
|
||||
docs_url=None,
|
||||
@@ -36,6 +60,8 @@ def build_webui(
|
||||
)
|
||||
app.state.sim = sim
|
||||
app.state.sessions = SessionStore(user=user, password=password, readonly=readonly)
|
||||
app.state.hostnet = (hostnet_service if hostnet_service is not None
|
||||
else _build_hostnet_service())
|
||||
|
||||
app.include_router(router)
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ async function init() {
|
||||
renderChannelList();
|
||||
if (diag) renderDiagnostics(diag);
|
||||
refreshEnvCard();
|
||||
probeHostnet();
|
||||
openWS();
|
||||
}
|
||||
|
||||
@@ -794,6 +795,135 @@ function dragEnd() {
|
||||
el.classList.remove("dragging", "drop-target"));
|
||||
}
|
||||
|
||||
// ---------------- host network settings (spec §15) ----------------
|
||||
|
||||
let HOSTNET = null; // last /api/hostnet payload (status + interfaces)
|
||||
|
||||
async function probeHostnet() {
|
||||
// Feature probe: 404 = gated off (flag or docker) — keep the tab
|
||||
// hidden; FR-15.2.1/2 "no dormant surface". Other errors keep it
|
||||
// hidden too (a broken agent must not break the UI, FR-15.2.8).
|
||||
HOSTNET = await api("/api/hostnet").catch(() => null);
|
||||
if (!HOSTNET) return;
|
||||
document.getElementById("tab-btn-hostnet").classList.remove("hidden");
|
||||
renderHostnet();
|
||||
}
|
||||
|
||||
function hostnetBanner(kind, text) {
|
||||
const banner = document.getElementById("hostnet-banner");
|
||||
document.getElementById("hostnet-banner-text").textContent = text;
|
||||
banner.classList.toggle("hidden", !kind);
|
||||
banner.classList.toggle("hostnet-warn", kind === "warn");
|
||||
}
|
||||
|
||||
async function refreshHostnet() {
|
||||
HOSTNET = await api("/api/hostnet").catch(() => null);
|
||||
if (HOSTNET) renderHostnet();
|
||||
}
|
||||
|
||||
function renderHostnet() {
|
||||
const body = document.getElementById("hostnet-body");
|
||||
if (!HOSTNET) return;
|
||||
const st = HOSTNET.status || {};
|
||||
if (!st.available) {
|
||||
hostnetBanner("warn",
|
||||
"Host control unavailable: " + (st.error || "agent unreachable") +
|
||||
" — the Modbus server and the rest of the UI are unaffected.");
|
||||
body.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
hostnetBanner(false, "");
|
||||
const wl = (st.agent && st.agent.whitelist) || [];
|
||||
const ifaces = HOSTNET.interfaces || [];
|
||||
let html = `<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 ----------------
|
||||
|
||||
document.querySelectorAll(".tab").forEach((btn) => {
|
||||
@@ -804,6 +934,7 @@ document.querySelectorAll(".tab").forEach((btn) => {
|
||||
document.getElementById(`tab-${btn.dataset.tab}`).classList.add("active");
|
||||
if (btn.dataset.tab === "diagnostics") refreshDiagnostics();
|
||||
if (btn.dataset.tab === "config") renderConfig();
|
||||
if (btn.dataset.tab === "hostnet") refreshHostnet();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<title>WAGO 750-362 Simulator</title>
|
||||
<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>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
@@ -19,6 +19,7 @@
|
||||
<button data-tab="overview" class="tab active">Overview</button>
|
||||
<button data-tab="config" class="tab">Configuration</button>
|
||||
<button data-tab="diagnostics" class="tab">Diagnostics</button>
|
||||
<button data-tab="hostnet" class="tab hidden" id="tab-btn-hostnet">Host network</button>
|
||||
</nav>
|
||||
<div class="topbar-right">
|
||||
<span id="conn-state" class="conn-dot" title="WebSocket state"></span>
|
||||
@@ -115,8 +116,28 @@
|
||||
<div class="diag-card" id="diag-faults"></div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<script src="/static/app.js?v=4"></script>
|
||||
<script src="/static/app.js?v=5"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<title>WAGO Simulator — Login</title>
|
||||
<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>
|
||||
<body class="login-body">
|
||||
<div class="login-card">
|
||||
|
||||
@@ -377,3 +377,35 @@ button.primary:hover { background: #1b5e20; }
|
||||
.range-sep { color: #6b7684; }
|
||||
.hidden-file { display: none; }
|
||||
#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;
|
||||
}
|
||||
@@ -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
@@ -283,8 +283,10 @@ class TestEnvControl:
|
||||
assert st["sim_time_ms"] > 0
|
||||
r = await client.post("/api/env/reset")
|
||||
assert r.status_code == 200
|
||||
st = (await client.get("/api/env")).json()
|
||||
assert st["sim_time_ms"] == 0.0 # deterministic fresh episode
|
||||
# the reset RESPONSE carries the post-reset status: asserting
|
||||
# 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")
|
||||
|
||||
async def test_restart_fresh_instance(self, client, app):
|
||||
|
||||
Reference in New Issue
Block a user