Compare commits
2
Commits
597b5bf73c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f3272094e | ||
|
|
c8b655111e |
@@ -44,6 +44,15 @@ a virtual node whose I/O behavior is driven by scripted scenarios.
|
|||||||
config, 255 = DHCP), WBM-equivalent pages (Port/Ethernet, Modbus
|
config, 255 = DHCP), WBM-equivalent pages (Port/Ethernet, Modbus
|
||||||
settings, Modbus Mapping, I/O Config), and device-state persistence
|
settings, Modbus Mapping, I/O Config), and device-state persistence
|
||||||
with factory reset.
|
with factory reset.
|
||||||
|
- **Host network settings** *(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, 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
|
- **Long-duration (continuous) simulation** *(requested, planned — see
|
||||||
`docs/spec/software-spec.md` §8)*: endurance/soak testing over days or
|
`docs/spec/software-spec.md` §8)*: endurance/soak testing over days or
|
||||||
weeks of simulated time. Designated inputs are driven from streaming
|
weeks of simulated time. Designated inputs are driven from streaming
|
||||||
@@ -185,6 +194,42 @@ Configuration options: `--no-web`, `--web-port <n>` or env
|
|||||||
benches (all mutating endpoints answer 403). Plain HTTP — keep the
|
benches (all mutating endpoints answer 403). Plain HTTP — keep the
|
||||||
port inside the bench network.
|
port inside the bench network.
|
||||||
|
|
||||||
|
### Host network settings (spec §15, Docker only)
|
||||||
|
|
||||||
|
An additional **Host network** tab can list and manipulate the
|
||||||
|
**host OS's** IPv4 addresses from the browser — for FAT cases that
|
||||||
|
need network-level re-addressing or address-loss faults on the bench
|
||||||
|
network. It exists only when both gates pass:
|
||||||
|
|
||||||
|
1. `WAGO_SIM_HOSTNET=1` on the simulator container (opt-in flag), and
|
||||||
|
2. running inside Docker (compose sets `WAGO_SIM_IN_DOCKER=1`).
|
||||||
|
|
||||||
|
The container itself stays non-root and unprivileged: changes go
|
||||||
|
through a whitelisted **agent on the host**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# once, on the Linux host:
|
||||||
|
sudo cp deploy/wago-sim-hostnet-agent.service /etc/systemd/system/
|
||||||
|
sudo cp deploy/hostnet-agent.yaml /etc/wago-sim/hostnet.yaml
|
||||||
|
# edit the whitelist to your bench NIC(s), then:
|
||||||
|
sudo systemctl enable --now wago-sim-hostnet-agent
|
||||||
|
# journalctl -u wago-sim-hostnet-agent -f (audit trail lands here)
|
||||||
|
|
||||||
|
# compose: uncomment in docker-compose.yml —
|
||||||
|
# WAGO_SIM_HOSTNET: "1"
|
||||||
|
# - /run/wago-sim:/run/wago-sim
|
||||||
|
# and align the container user's group with the socket (0660).
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Safety rails: only whitelisted interfaces are mutable (refusals are
|
||||||
|
shown and audited); every change is preceded by an auto-snapshot with
|
||||||
|
a **Restore last change** button; changes are runtime-only — a host
|
||||||
|
reboot restores the deployed addressing; every mutation and refusal is
|
||||||
|
logged with the logged-in operator's name. If the agent is down the
|
||||||
|
tab shows a "host control unavailable" banner and mutations answer
|
||||||
|
503 — the simulator itself is unaffected.
|
||||||
|
|
||||||
## Plant environments (continuous simulation)
|
## Plant environments (continuous simulation)
|
||||||
|
|
||||||
A **plant environment** simulates the physical world around the
|
A **plant environment** simulates the physical world around the
|
||||||
@@ -343,6 +388,40 @@ requirement ID for each item in parentheses.
|
|||||||
protocol tracing, diagnostics and fault injection (FR-14.2.5–
|
protocol tracing, diagnostics and fault injection (FR-14.2.5–
|
||||||
14.2.6); scoping questions to answer before planning (§14.3).
|
14.2.6); scoping questions to answer before planning (§14.3).
|
||||||
|
|
||||||
|
### Host network settings (spec §15) — IMPLEMENTED 2026-10-06
|
||||||
|
|
||||||
|
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 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 — 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
|
### Web UI (spec §10) — in implementation
|
||||||
|
|
||||||
- [x] Spec §10.3–10.6 updated with binding implementation decisions:
|
- [x] Spec §10.3–10.6 updated with binding implementation decisions:
|
||||||
|
|||||||
@@ -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"
|
WAGO_SIM_PORT: "502"
|
||||||
# Uncomment to trace every Modbus transaction in plain text:
|
# Uncomment to trace every Modbus transaction in plain text:
|
||||||
# WAGO_SIM_VERBOSE: "1"
|
# WAGO_SIM_VERBOSE: "1"
|
||||||
|
#
|
||||||
|
# Host network settings page (spec §15) — BOTH gates are needed:
|
||||||
|
# the flag plus this in-docker marker (the /.dockerenv file the
|
||||||
|
# runtime also creates is a supplementary signal only). The
|
||||||
|
# host-side agent (deploy/wago-sim-hostnet-agent.service) must be
|
||||||
|
# running on the host and its socket mounted below — otherwise
|
||||||
|
# the page shows "host control unavailable" and mutations 503.
|
||||||
|
# WAGO_SIM_HOSTNET: "1"
|
||||||
|
WAGO_SIM_IN_DOCKER: "1"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config:/app/config:ro
|
- ./config:/app/config:ro
|
||||||
- ./scenarios:/app/scenarios:ro
|
- ./scenarios:/app/scenarios:ro
|
||||||
- ./plugins:/app/plugins:ro
|
- ./plugins:/app/plugins:ro
|
||||||
# Report output for headless `run` invocations (spec §8 soak reports):
|
# Report output for headless `run` invocations (spec §8 soak reports):
|
||||||
- ./out:/app/out
|
- ./out:/app/out
|
||||||
|
# Host network settings (spec §15): the agent's Unix socket from
|
||||||
|
# the host, so the container never needs host privileges. Uncomment
|
||||||
|
# together with WAGO_SIM_HOSTNET above; group_add below must match
|
||||||
|
# the socket's group on the host.
|
||||||
|
# - /run/wago-sim:/run/wago-sim
|
||||||
# Deterministic sim is CPU-light; keep 1 CPU for a stable FAT bench
|
# Deterministic sim is CPU-light; keep 1 CPU for a stable FAT bench
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
|
|||||||
+211
-5
@@ -7,7 +7,7 @@
|
|||||||
| Project | `wago-modbustcp-simulator-py` |
|
| Project | `wago-modbustcp-simulator-py` |
|
||||||
| Version | 1.4 (Draft for review) |
|
| Version | 1.4 (Draft for review) |
|
||||||
| Date | 2026-10-03 |
|
| Date | 2026-10-03 |
|
||||||
| Status | Framework phase; module addressing implemented (FR-4.3.11 v1.5 per-type numbering); service/container deployment implemented (§12); plain-text request tracing implemented (NFR-6.4); clean CLI error handling implemented (NFR-6.5 CLI part); web UI §10 in implementation (M1–M5a done: auth, overview, manipulation, diagnostics, configuration, channel names, import/export, split overview; M6 scenario view open); long-duration simulation (§8) implemented 2026-10-04 incl. PlantEnv (FR-8.2.14–16), datasets, degradation, invariants, snapshots; bench mode (§9), runtime device configuration (§11 — coupler/module/channel-name subset covered by web UI M5/M5a), multi-vendor device profiles (§13) and additional fieldbus protocols PROFINET/COMLI (§14) requested |
|
| Status | Framework phase; module addressing implemented (FR-4.3.11 v1.5 per-type numbering); service/container deployment implemented (§12); plain-text request tracing implemented (NFR-6.4); clean CLI error handling implemented (NFR-6.5 CLI part); web UI §10 in implementation (M1–M5a done: auth, overview, manipulation, diagnostics, configuration, channel names, import/export, split overview; M6 scenario view open); long-duration simulation (§8) implemented 2026-10-04 incl. PlantEnv (FR-8.2.14–16), datasets, degradation, invariants, snapshots; host network settings (§15) implemented 2026-10-06 (v1 ops; FR-15.2.9 DIP integration deferred); bench mode (§9), runtime device configuration (§11 — coupler/module/channel-name subset covered by web UI M5/M5a), multi-vendor device profiles (§13) and additional fieldbus protocols PROFINET/COMLI (§14) requested |
|
||||||
| Target device simulated | WAGO 750-362 / 750-362/0000-0001 FC Modbus TCP (G4) bus coupler |
|
| Target device simulated | WAGO 750-362 / 750-362/0000-0001 FC Modbus TCP (G4) bus coupler |
|
||||||
| Reference documents | `docs/m07500362_xxxxxxxx_0en.pdf` (750-362 Modbus TCP manual), Modbus TCP specification (modbus.org) |
|
| Reference documents | `docs/m07500362_xxxxxxxx_0en.pdf` (750-362 Modbus TCP manual), Modbus TCP specification (modbus.org) |
|
||||||
|
|
||||||
@@ -1018,7 +1018,8 @@ Terminal monitors (§9) suffice for single engineers, but FAT benches are
|
|||||||
message (alias-ownership rules of §8 FR-8.2.2 apply).
|
message (alias-ownership rules of §8 FR-8.2.2 apply).
|
||||||
- FR-10.2.10: The UI's **configuration** pages (Ethernet/port settings,
|
- FR-10.2.10: The UI's **configuration** pages (Ethernet/port settings,
|
||||||
Modbus watchdog settings, I/O Config with SAVE CONFIG, Modbus Mapping
|
Modbus watchdog settings, I/O Config with SAVE CONFIG, Modbus Mapping
|
||||||
overview, address-selection DIP switch) are specified in §11; §10 covers
|
overview, address-selection DIP switch) are specified in §11; the
|
||||||
|
Docker-gated host network settings page is specified in §15. §10 covers
|
||||||
value/state oversight and manipulation.
|
value/state oversight and manipulation.
|
||||||
- FR-10.2.11: **Channel names** (requested 2026-10-03): every module
|
- FR-10.2.11: **Channel names** (requested 2026-10-03): every module
|
||||||
channel can carry an application-specific name (e.g. `start_button`,
|
channel can carry an application-specific name (e.g. `start_button`,
|
||||||
@@ -1594,7 +1595,212 @@ the same process-image core.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 15. Roadmap (post-framework)
|
## 15. Feature Request — Host Network Settings (Docker Bench Host)
|
||||||
|
|
||||||
|
> **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
|
||||||
|
|
||||||
|
A containerized simulator (§12) cannot reach beyond its own network
|
||||||
|
namespace. It can rebind its listen address (FR-11.3.3 dedicated-IP
|
||||||
|
mode), but the **host's** network interfaces — what the bench network
|
||||||
|
and the PLC under test actually see — are outside the container. As a
|
||||||
|
result, FAT cases that depend on *network-level* events cannot be
|
||||||
|
staged from inside the simulator today:
|
||||||
|
|
||||||
|
- "Re-addressing" the virtual coupler (DIP switch, FR-11.3.1–11.3.3)
|
||||||
|
changes nothing on the bench network in the default bridge
|
||||||
|
deployment: the PLC keeps talking to the unchanged host address, so
|
||||||
|
the reconnect/re-addressing logic in the PLC program is never
|
||||||
|
exercised.
|
||||||
|
- Cable-pull / node-power-off fault cases require stopping the whole
|
||||||
|
container (coarse — every port drops at once) or shell access to the
|
||||||
|
bench host, breaking the "operate everything from the browser"
|
||||||
|
workflow of the web UI (§10).
|
||||||
|
- Moving the simulated node to a different subnet mid-bench
|
||||||
|
(multi-bench swaps, address conflicts) likewise needs host access.
|
||||||
|
|
||||||
|
The requested feature closes the gap: an opt-in settings page through
|
||||||
|
which an authenticated operator changes the host OS's IP addresses
|
||||||
|
from the bench UI — making the simulated node's network identity
|
||||||
|
**real on the bench network**, and making network-level fault cases
|
||||||
|
first-class FAT scenarios.
|
||||||
|
|
||||||
|
### 15.2 Requirements
|
||||||
|
|
||||||
|
- FR-15.2.1: **Opt-in via environment flag.** The feature is disabled
|
||||||
|
unless `WAGO_SIM_HOSTNET` is set truthy (same semantics as
|
||||||
|
`WAGO_SIM_VERBOSE`: `1`/`true`/`yes`/`on`); the flag lives in the
|
||||||
|
compose file so enabling it is a visible, deliberate deployment
|
||||||
|
change. Default: off. When off, the page is absent and every
|
||||||
|
`/api/hostnet/*` route answers 404 — no dormant surface.
|
||||||
|
- FR-15.2.2: **Docker-only.** Independently, the simulator must detect
|
||||||
|
a container environment — binding: the compose file sets
|
||||||
|
`WAGO_SIM_IN_DOCKER=1` (supplementary signal: `/.dockerenv` exists).
|
||||||
|
Outside docker the routes stay 404 regardless of the flag: a
|
||||||
|
bare-metal operator has direct access to the OS network settings;
|
||||||
|
the page exists precisely because a container does not.
|
||||||
|
- FR-15.2.3: **Settings page.** A page in the web UI's configuration
|
||||||
|
area (alongside the Port/Ethernet page, FR-11.4.1) listing the
|
||||||
|
host's network interfaces — name, link state, current IPv4
|
||||||
|
addresses — read from **live host state**, not a desired-state
|
||||||
|
mirror, so out-of-band changes made on the host appear on refresh.
|
||||||
|
The page also shows the agent's health and the effective whitelist.
|
||||||
|
- FR-15.2.4: **Operations.** Add an address (address + prefix length),
|
||||||
|
remove an address, replace an interface's primary address — each
|
||||||
|
validated client-side and agent-side (address/prefix syntax,
|
||||||
|
interface exists, interface whitelisted). Changes are
|
||||||
|
**runtime-only** (like `ip addr add`: non-persistent across host
|
||||||
|
reboots); v1 deliberately does not write netplan/NetworkManager/
|
||||||
|
systemd-networkd configuration, so a host reboot is a guaranteed
|
||||||
|
escape hatch back to the deployed addressing. Optional v2, behind
|
||||||
|
the same whitelist and an explicit "disruptive" confirmation: set
|
||||||
|
an interface's link up/down (cable-pull simulation).
|
||||||
|
- FR-15.2.5: **Safety rails.** The agent enforces an interface
|
||||||
|
**whitelist** from its own config file (operations on other
|
||||||
|
interfaces are refused and surfaced as errors on the page); it
|
||||||
|
snapshots an interface's address set before every mutation, and the
|
||||||
|
page offers **undo last** / **restore interface**; every mutation
|
||||||
|
and every refusal is appended to a single-line **audit log**
|
||||||
|
(operator session, interface, old → new, timestamp) written to
|
||||||
|
stdout/journald like the simulator's own logging (FR-12.2.6) and
|
||||||
|
viewable (bounded) on the page.
|
||||||
|
- FR-15.2.6: **Trust boundary** (same stance as FR-10.2.16): the page
|
||||||
|
requires an authenticated session (FR-10.2.8); read-only mode
|
||||||
|
(`WAGO_SIM_WEB_READONLY`) hides the page and answers mutations with
|
||||||
|
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,
|
||||||
|
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
|
||||||
|
message; the Modbus server and the rest of the web UI are
|
||||||
|
unaffected.
|
||||||
|
- FR-15.2.9: **DIP dedicated-IP integration** (FR-11.3.3). When a DIP
|
||||||
|
address change is applied in dedicated-IP mode and the derived
|
||||||
|
interface is whitelisted, the apply step may route through the same
|
||||||
|
agent — the derived IP is added to the host NIC — so the PLC on the
|
||||||
|
bench network must genuinely re-connect to the new node address,
|
||||||
|
exactly like a physical coupler after a DIP change. Under
|
||||||
|
`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 (as-built 2026-10-06)
|
||||||
|
|
||||||
|
```
|
||||||
|
bench host (Linux)
|
||||||
|
├── wago-sim hostnet-agent --config /etc/wago-sim/hostnet.yaml
|
||||||
|
│ (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, 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 (404 otherwise):
|
||||||
|
|
||||||
|
| Method/Route | Purpose | FR |
|
||||||
|
|---|---|---|
|
||||||
|
| `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 |
|
||||||
|
| `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)
|
||||||
|
|
||||||
|
1. Platform scope v1: Linux bench hosts only (agent via `pyroute2`)?
|
||||||
|
Windows hosts run the container inside Docker Desktop/WSL2 — the
|
||||||
|
container's "host" is the WSL2 VM; reaching the Windows NICs needs
|
||||||
|
a Windows-side agent over a named pipe — defer or include?
|
||||||
|
2. Networking-mode story for the DIP integration (FR-15.2.9): keep
|
||||||
|
the current bridge deployment (host IP + mapped port) as v1, or
|
||||||
|
require `network_mode: host` for full parity (node IP == host IP,
|
||||||
|
port 502)?
|
||||||
|
3. Agent authentication: Unix-socket file permissions only, or a
|
||||||
|
shared token in the agent config to defend against other local
|
||||||
|
users?
|
||||||
|
4. IPv6 in v1 or deferred?
|
||||||
|
5. Does link up/down (cable pull) belong in v1 — it can sever the
|
||||||
|
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
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Roadmap (post-framework)
|
||||||
|
|
||||||
| Phase | Feature |
|
| Phase | Feature |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -1603,11 +1809,11 @@ the same process-image core.
|
|||||||
| 4 | FAT report generator (HTML/PDF), scenario comparison, template library per project type |
|
| 4 | FAT report generator (HTML/PDF), scenario comparison, template library per project type |
|
||||||
| 5 | Multi-node orchestration (several couplers in one FAT bench), UDP variant, WBM simulation; **web UI (§10)**; **runtime device configuration (§11)**: module hot-configuration with image rebuild, DIP-switch emulation, WBM-equivalent pages, device-state persistence |
|
| 5 | Multi-node orchestration (several couplers in one FAT bench), UDP variant, WBM simulation; **web UI (§10)**; **runtime device configuration (§11)**: module hot-configuration with image rebuild, DIP-switch emulation, WBM-equivalent pages, device-state persistence |
|
||||||
| 6 | CI integration: run scenarios headless, export JUnit XML |
|
| 6 | CI integration: run scenarios headless, export JUnit XML |
|
||||||
| ? | **Multi-vendor device profiles (§13)** and **additional fieldbus protocols PROFINET/COMLI (§14)** — not yet scheduled; phase assignment after §13.3/§14.3 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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 16. References
|
## 17. References
|
||||||
|
|
||||||
- WAGO 750-362 manual `m07500362_xxxxxxxx_0en.pdf`, sections 7.2–7.3 (process image), 11.2 (Modbus), 12.2 (module data architecture)
|
- WAGO 750-362 manual `m07500362_xxxxxxxx_0en.pdf`, sections 7.2–7.3 (process image), 11.2 (Modbus), 12.2 (module data architecture)
|
||||||
- WAGO I/O System 750 system description `m075xxxxx_xxxxxxxx_0en.pdf`
|
- WAGO I/O System 750 system description `m075xxxxx_xxxxxxxx_0en.pdf`
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class PumpStationEnv(PlantEnv):
|
|||||||
def model(self, state: PlantState, dt_s: float) -> dict[str, object]:
|
def model(self, state: PlantState, dt_s: float) -> dict[str, object]:
|
||||||
dt_h = dt_s / 3600.0
|
dt_h = dt_s / 3600.0
|
||||||
# what the PLC currently commands (master side: DO/AO)
|
# what the PLC currently commands (master side: DO/AO)
|
||||||
pump_running = bool(state.by_code("DI0.0"))
|
pump_running = bool(state.by_code("DO0.0"))
|
||||||
|
|
||||||
# speed setpoint from AO0.0, normalized over its display range
|
# speed setpoint from AO0.0, normalized over its display range
|
||||||
ao_lo, ao_hi = state.channel_range("AO0.0")
|
ao_lo, ao_hi = state.channel_range("AO0.0")
|
||||||
|
|||||||
@@ -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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _hostnet_agent(args: argparse.Namespace) -> int:
|
||||||
|
"""Run the host-side agent (spec §15, FR-15.2.7).
|
||||||
|
|
||||||
|
This runs on the docker HOST — not in the container: it is the
|
||||||
|
component that holds the privileges to change host addresses and
|
||||||
|
the interface whitelist. Clean one-line errors (NFR-6.5) for
|
||||||
|
missing/invalid config; the agent logs to stdout/journald.
|
||||||
|
"""
|
||||||
|
from .hostnet import agent_main
|
||||||
|
return agent_main(["--config", args.config])
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="wago-sim",
|
prog="wago-sim",
|
||||||
@@ -400,6 +412,17 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
help="environment module, e.g. envs.pump_station_env:PumpStationEnv")
|
help="environment module, e.g. envs.pump_station_env:PumpStationEnv")
|
||||||
sim.set_defaults(func=_sim)
|
sim.set_defaults(func=_sim)
|
||||||
|
|
||||||
|
hostnet = sub.add_parser(
|
||||||
|
"hostnet-agent",
|
||||||
|
help="run the host-side agent for the web UI's host network "
|
||||||
|
"settings page (spec §15; runs on the DOCKER HOST as "
|
||||||
|
"root/CAP_NET_ADMIN, not in the container)")
|
||||||
|
hostnet.add_argument(
|
||||||
|
"--config", required=True,
|
||||||
|
help="agent config YAML (socket, whitelist, backend, audit_path)",
|
||||||
|
)
|
||||||
|
hostnet.set_defaults(func=lambda args: _hostnet_agent(args))
|
||||||
|
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
_build_logger(args.verbose)
|
_build_logger(args.verbose)
|
||||||
# `--node` may come from the environment when omitted (service mode);
|
# `--node` may come from the environment when omitted (service mode);
|
||||||
|
|||||||
@@ -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 fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
from ..app import SimulatorApp, rebuild_node
|
from ..app import SimulatorApp, rebuild_node
|
||||||
|
from ..hostnet import HostNetError, HostNetRefused, HostNetUnavailable
|
||||||
from ..modules import (CATALOG, ChannelMismatch, ModuleUnknown, VirtualNode,
|
from ..modules import (CATALOG, ChannelMismatch, ModuleUnknown, VirtualNode,
|
||||||
resolve_module)
|
resolve_module)
|
||||||
from ..scenario.engine import ScenarioError
|
from ..scenario.engine import ScenarioError
|
||||||
@@ -261,6 +262,162 @@ def _payload_bool(payload: dict) -> bool:
|
|||||||
raise HTTPException(status_code=400, detail="value must be a boolean")
|
raise HTTPException(status_code=400, detail="value must be a boolean")
|
||||||
|
|
||||||
|
|
||||||
|
# -- host network settings (spec §15) -------------------------------------------
|
||||||
|
#
|
||||||
|
# Routes exist ONLY behind both gates (FR-15.2.1 flag + FR-15.2.2
|
||||||
|
# docker): when disabled every /api/hostnet* path answers 404 — no
|
||||||
|
# dormant surface. `build_webui` mounts HostNetService on
|
||||||
|
# app.state.hostnet only when `hostnet_enabled()` passes, so the gate
|
||||||
|
# lives in the app factory, not in each handler.
|
||||||
|
|
||||||
|
|
||||||
|
def _hostnet_or_404(request: Request):
|
||||||
|
"""The HostNetService for this app — or 404 when the feature is off."""
|
||||||
|
service = getattr(request.app.state, "hostnet", None)
|
||||||
|
if service is None:
|
||||||
|
raise HTTPException(status_code=404, detail="host network settings are not enabled")
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
def _hostnet_operator(request: Request, sessions: SessionStore) -> str:
|
||||||
|
"""The logged-in user for the audit trail (FR-15.2.5)."""
|
||||||
|
return sessions.username or "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/hostnet")
|
||||||
|
async def get_hostnet(
|
||||||
|
request: Request,
|
||||||
|
_: None = Depends(require_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Availability + live interface list (FR-15.2.3).
|
||||||
|
|
||||||
|
Never 503s itself: an unreachable agent is reported as
|
||||||
|
`available: false` with the error string — the page renders a
|
||||||
|
banner, not a hard failure (FR-15.2.8).
|
||||||
|
"""
|
||||||
|
service = _hostnet_or_404(request)
|
||||||
|
status = service.status()
|
||||||
|
interfaces = None
|
||||||
|
if status["available"]:
|
||||||
|
try:
|
||||||
|
interfaces = service.interfaces()
|
||||||
|
except HostNetError as exc:
|
||||||
|
status = {"available": False, "error": str(exc), "agent": None}
|
||||||
|
return {"status": status, "interfaces": interfaces}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/hostnet/interfaces/{iface}/addresses")
|
||||||
|
async def hostnet_add_address(
|
||||||
|
iface: str, payload: dict,
|
||||||
|
request: Request,
|
||||||
|
sessions: SessionStore = Depends(get_sessions),
|
||||||
|
) -> dict:
|
||||||
|
"""Add an IPv4 address to a whitelisted interface (FR-15.2.4)."""
|
||||||
|
_require_write(request, sessions)
|
||||||
|
service = _hostnet_or_404(request)
|
||||||
|
try:
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
service.add_address, iface,
|
||||||
|
payload.get("address"), payload.get("prefix"),
|
||||||
|
_hostnet_operator(request, sessions),
|
||||||
|
)
|
||||||
|
except HostNetUnavailable as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from None
|
||||||
|
except HostNetRefused as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc)) from None
|
||||||
|
except HostNetError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from None
|
||||||
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/hostnet/interfaces/{iface}/addresses/{address}")
|
||||||
|
async def hostnet_remove_address(
|
||||||
|
iface: str, address: str,
|
||||||
|
request: Request,
|
||||||
|
sessions: SessionStore = Depends(get_sessions),
|
||||||
|
) -> dict:
|
||||||
|
"""Remove an address from a whitelisted interface (FR-15.2.4)."""
|
||||||
|
_require_write(request, sessions)
|
||||||
|
service = _hostnet_or_404(request)
|
||||||
|
try:
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
service.remove_address, iface, address,
|
||||||
|
_hostnet_operator(request, sessions),
|
||||||
|
)
|
||||||
|
except HostNetUnavailable as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from None
|
||||||
|
except HostNetRefused as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc)) from None
|
||||||
|
except HostNetError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from None
|
||||||
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/hostnet/interfaces/{iface}/addresses")
|
||||||
|
async def hostnet_change_address(
|
||||||
|
iface: str, payload: dict,
|
||||||
|
request: Request,
|
||||||
|
sessions: SessionStore = Depends(get_sessions),
|
||||||
|
) -> dict:
|
||||||
|
"""Replace an interface's primary address (FR-15.2.4): add-first
|
||||||
|
semantics so the interface is never address-less mid-change."""
|
||||||
|
_require_write(request, sessions)
|
||||||
|
service = _hostnet_or_404(request)
|
||||||
|
try:
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
service.change_address, iface,
|
||||||
|
payload.get("old"), payload.get("new"), payload.get("prefix"),
|
||||||
|
_hostnet_operator(request, sessions),
|
||||||
|
)
|
||||||
|
except HostNetUnavailable as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from None
|
||||||
|
except HostNetRefused as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc)) from None
|
||||||
|
except HostNetError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from None
|
||||||
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/hostnet/interfaces/{iface}/restore")
|
||||||
|
async def hostnet_restore(
|
||||||
|
iface: str,
|
||||||
|
request: Request,
|
||||||
|
sessions: SessionStore = Depends(get_sessions),
|
||||||
|
) -> dict:
|
||||||
|
"""Undo the last change: restore the interface's address snapshot
|
||||||
|
(FR-15.2.5). The agent snapshots before every mutation, so
|
||||||
|
'restore' walks back one change."""
|
||||||
|
_require_write(request, sessions)
|
||||||
|
service = _hostnet_or_404(request)
|
||||||
|
try:
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
service.restore, iface, _hostnet_operator(request, sessions),
|
||||||
|
)
|
||||||
|
except HostNetUnavailable as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from None
|
||||||
|
except HostNetRefused as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc)) from None
|
||||||
|
except HostNetError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from None
|
||||||
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/hostnet/audit")
|
||||||
|
async def hostnet_audit(
|
||||||
|
request: Request,
|
||||||
|
_: None = Depends(require_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Bounded recent audit lines (FR-15.2.5) — mutations AND refusals."""
|
||||||
|
service = _hostnet_or_404(request)
|
||||||
|
try:
|
||||||
|
lines = await asyncio.to_thread(service.audit)
|
||||||
|
except HostNetUnavailable as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from None
|
||||||
|
except HostNetError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from None
|
||||||
|
return {"lines": lines}
|
||||||
|
|
||||||
|
|
||||||
# -- WebSocket live values --------------------------------------------------------
|
# -- WebSocket live values --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ datastore. Static single-page front-end served from `webui/static/`.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
@@ -14,20 +15,43 @@ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from ..app import SimulatorApp
|
from ..app import SimulatorApp
|
||||||
|
from ..hostnet import HostNetClient, HostNetService, hostnet_enabled
|
||||||
from .api import router
|
from .api import router
|
||||||
from .auth import SESSION_COOKIE, SessionStore
|
from .auth import SESSION_COOKIE, SessionStore
|
||||||
|
|
||||||
STATIC_DIR = Path(__file__).parent / "static"
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_hostnet_service() -> HostNetService | None:
|
||||||
|
"""The hostnet service when BOTH gates pass, else None (FR-15.2.1/2).
|
||||||
|
|
||||||
|
Gate evaluation happens ONCE per web-UI build: the flag is a
|
||||||
|
deployment decision (compose file), not a per-request toggle —
|
||||||
|
flipping it requires a restart, exactly like the spec's
|
||||||
|
"deliberate deployment change" wording.
|
||||||
|
"""
|
||||||
|
if not hostnet_enabled():
|
||||||
|
return None
|
||||||
|
socket_path = os.environ.get("WAGO_SIM_HOSTNET_SOCKET",
|
||||||
|
"/run/wago-sim/hostnet.sock")
|
||||||
|
return HostNetService(HostNetClient(socket_path=socket_path))
|
||||||
|
|
||||||
|
|
||||||
def build_webui(
|
def build_webui(
|
||||||
sim: SimulatorApp,
|
sim: SimulatorApp,
|
||||||
*,
|
*,
|
||||||
user: str,
|
user: str,
|
||||||
password: str,
|
password: str,
|
||||||
readonly: bool = False,
|
readonly: bool = False,
|
||||||
|
hostnet_service: object | None = None,
|
||||||
) -> FastAPI:
|
) -> FastAPI:
|
||||||
"""Assemble the FastAPI app: API router + static SPA + auth cookie."""
|
"""Assemble the FastAPI app: API router + static SPA + auth cookie.
|
||||||
|
|
||||||
|
``hostnet_service``: a HostNetService (or a test double with the
|
||||||
|
same surface) mounted on ``app.state.hostnet`` when the §15 gates
|
||||||
|
pass. The production default comes from `_build_hostnet_service()`;
|
||||||
|
tests inject an InProcessClient-backed service directly.
|
||||||
|
"""
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="WAGO 750-362 Simulator",
|
title="WAGO 750-362 Simulator",
|
||||||
docs_url=None,
|
docs_url=None,
|
||||||
@@ -36,6 +60,8 @@ def build_webui(
|
|||||||
)
|
)
|
||||||
app.state.sim = sim
|
app.state.sim = sim
|
||||||
app.state.sessions = SessionStore(user=user, password=password, readonly=readonly)
|
app.state.sessions = SessionStore(user=user, password=password, readonly=readonly)
|
||||||
|
app.state.hostnet = (hostnet_service if hostnet_service is not None
|
||||||
|
else _build_hostnet_service())
|
||||||
|
|
||||||
app.include_router(router)
|
app.include_router(router)
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ async function init() {
|
|||||||
renderChannelList();
|
renderChannelList();
|
||||||
if (diag) renderDiagnostics(diag);
|
if (diag) renderDiagnostics(diag);
|
||||||
refreshEnvCard();
|
refreshEnvCard();
|
||||||
|
probeHostnet();
|
||||||
openWS();
|
openWS();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -794,6 +795,135 @@ function dragEnd() {
|
|||||||
el.classList.remove("dragging", "drop-target"));
|
el.classList.remove("dragging", "drop-target"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------- host network settings (spec §15) ----------------
|
||||||
|
|
||||||
|
let HOSTNET = null; // last /api/hostnet payload (status + interfaces)
|
||||||
|
|
||||||
|
async function probeHostnet() {
|
||||||
|
// Feature probe: 404 = gated off (flag or docker) — keep the tab
|
||||||
|
// hidden; FR-15.2.1/2 "no dormant surface". Other errors keep it
|
||||||
|
// hidden too (a broken agent must not break the UI, FR-15.2.8).
|
||||||
|
HOSTNET = await api("/api/hostnet").catch(() => null);
|
||||||
|
if (!HOSTNET) return;
|
||||||
|
document.getElementById("tab-btn-hostnet").classList.remove("hidden");
|
||||||
|
renderHostnet();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostnetBanner(kind, text) {
|
||||||
|
const banner = document.getElementById("hostnet-banner");
|
||||||
|
document.getElementById("hostnet-banner-text").textContent = text;
|
||||||
|
banner.classList.toggle("hidden", !kind);
|
||||||
|
banner.classList.toggle("hostnet-warn", kind === "warn");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshHostnet() {
|
||||||
|
HOSTNET = await api("/api/hostnet").catch(() => null);
|
||||||
|
if (HOSTNET) renderHostnet();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHostnet() {
|
||||||
|
const body = document.getElementById("hostnet-body");
|
||||||
|
if (!HOSTNET) return;
|
||||||
|
const st = HOSTNET.status || {};
|
||||||
|
if (!st.available) {
|
||||||
|
hostnetBanner("warn",
|
||||||
|
"Host control unavailable: " + (st.error || "agent unreachable") +
|
||||||
|
" — the Modbus server and the rest of the UI are unaffected.");
|
||||||
|
body.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hostnetBanner(false, "");
|
||||||
|
const wl = (st.agent && st.agent.whitelist) || [];
|
||||||
|
const ifaces = HOSTNET.interfaces || [];
|
||||||
|
let html = `<p class="cfg-note">The whitelisted interfaces of the ` +
|
||||||
|
`<b>host OS</b> this container runs on — changes apply at runtime ` +
|
||||||
|
`only (a host reboot restores the deployed addressing). ` +
|
||||||
|
`Whitelist: ${wl.join(", ") || "—"}</p>`;
|
||||||
|
if (!ifaces.length) {
|
||||||
|
html += `<p class="cfg-note">No interfaces reported by the agent.</p>`;
|
||||||
|
}
|
||||||
|
for (const ifc of ifaces) {
|
||||||
|
const allowed = wl.includes(ifc.name);
|
||||||
|
const rows = ifc.addresses.map((a) => `
|
||||||
|
<tr>
|
||||||
|
<td>${a.address}/${a.prefix}</td>
|
||||||
|
<td>${allowed && !READONLY
|
||||||
|
? `<button class="mini" onclick="hostnetRemove('${ifc.name}','${a.address}')">Remove</button>`
|
||||||
|
: ""}</td>
|
||||||
|
</tr>`).join("");
|
||||||
|
html += `
|
||||||
|
<div class="diag-card hostnet-card">
|
||||||
|
<h3>${ifc.name} ${ifc.up ? "" : "(down)"}
|
||||||
|
<span class="hostnet-flag ${allowed ? "ok" : "off"}">${allowed ? "whitelisted" : "view only"}</span></h3>
|
||||||
|
<table class="hostnet-addrs">${rows ||
|
||||||
|
"<tr><td>no IPv4 addresses</td><td></td></tr>"}</table>
|
||||||
|
${allowed && !READONLY ? `
|
||||||
|
<div class="hostnet-form">
|
||||||
|
<input type="text" id="hn-add-${ifc.name}" placeholder="192.168.1.33">
|
||||||
|
<input type="number" id="hn-prefix-${ifc.name}" value="24" min="1" max="32">
|
||||||
|
<button onclick="hostnetAdd('${ifc.name}')">Add address</button>
|
||||||
|
</div>
|
||||||
|
<div class="hostnet-form">
|
||||||
|
<input type="text" id="hn-old-${ifc.name}" placeholder="old address">
|
||||||
|
<input type="text" id="hn-new-${ifc.name}" placeholder="new address">
|
||||||
|
<input type="number" id="hn-nprefix-${ifc.name}" value="24" min="1" max="32">
|
||||||
|
<button onclick="hostnetChange('${ifc.name}')">Change</button>
|
||||||
|
</div>
|
||||||
|
<div class="fault-buttons">
|
||||||
|
<button onclick="hostnetRestore('${ifc.name}')">Restore last change</button>
|
||||||
|
</div>` : ""}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
body.innerHTML = html;
|
||||||
|
refreshHostnetAudit();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hostnetAdd(iface) {
|
||||||
|
await hostnetOp("POST",
|
||||||
|
`/api/hostnet/interfaces/${iface}/addresses`,
|
||||||
|
{address: document.getElementById(`hn-add-${iface}`).value.trim(),
|
||||||
|
prefix: Number(document.getElementById(`hn-prefix-${iface}`).value)});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hostnetRemove(iface, address) {
|
||||||
|
await hostnetOp("DELETE",
|
||||||
|
`/api/hostnet/interfaces/${iface}/addresses/${address}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hostnetChange(iface) {
|
||||||
|
await hostnetOp("PUT",
|
||||||
|
`/api/hostnet/interfaces/${iface}/addresses`,
|
||||||
|
{old: document.getElementById(`hn-old-${iface}`).value.trim(),
|
||||||
|
new: document.getElementById(`hn-new-${iface}`).value.trim(),
|
||||||
|
prefix: Number(document.getElementById(`hn-nprefix-${iface}`).value)});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hostnetRestore(iface) {
|
||||||
|
await hostnetOp("POST",
|
||||||
|
`/api/hostnet/interfaces/${iface}/restore`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hostnetOp(method, path, payload) {
|
||||||
|
if (READONLY) return;
|
||||||
|
try {
|
||||||
|
await api(path, {
|
||||||
|
method,
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: payload ? JSON.stringify(payload) : undefined,
|
||||||
|
});
|
||||||
|
await refreshHostnet();
|
||||||
|
} catch (e) {
|
||||||
|
hostnetBanner("warn", e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshHostnetAudit() {
|
||||||
|
const pre = document.getElementById("hostnet-audit");
|
||||||
|
if (!pre) return;
|
||||||
|
const r = await api("/api/hostnet/audit").catch(() => null);
|
||||||
|
if (r) pre.textContent = r.lines.length ? r.lines.join("\n") : "(empty)";
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------- tabs ----------------
|
// ---------------- tabs ----------------
|
||||||
|
|
||||||
document.querySelectorAll(".tab").forEach((btn) => {
|
document.querySelectorAll(".tab").forEach((btn) => {
|
||||||
@@ -804,6 +934,7 @@ document.querySelectorAll(".tab").forEach((btn) => {
|
|||||||
document.getElementById(`tab-${btn.dataset.tab}`).classList.add("active");
|
document.getElementById(`tab-${btn.dataset.tab}`).classList.add("active");
|
||||||
if (btn.dataset.tab === "diagnostics") refreshDiagnostics();
|
if (btn.dataset.tab === "diagnostics") refreshDiagnostics();
|
||||||
if (btn.dataset.tab === "config") renderConfig();
|
if (btn.dataset.tab === "config") renderConfig();
|
||||||
|
if (btn.dataset.tab === "hostnet") refreshHostnet();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>WAGO 750-362 Simulator</title>
|
<title>WAGO 750-362 Simulator</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="/static/style.css?v=4">
|
<link rel="stylesheet" href="/static/style.css?v=5">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
<button data-tab="overview" class="tab active">Overview</button>
|
<button data-tab="overview" class="tab active">Overview</button>
|
||||||
<button data-tab="config" class="tab">Configuration</button>
|
<button data-tab="config" class="tab">Configuration</button>
|
||||||
<button data-tab="diagnostics" class="tab">Diagnostics</button>
|
<button data-tab="diagnostics" class="tab">Diagnostics</button>
|
||||||
|
<button data-tab="hostnet" class="tab hidden" id="tab-btn-hostnet">Host network</button>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="topbar-right">
|
<div class="topbar-right">
|
||||||
<span id="conn-state" class="conn-dot" title="WebSocket state"></span>
|
<span id="conn-state" class="conn-dot" title="WebSocket state"></span>
|
||||||
@@ -115,8 +116,28 @@
|
|||||||
<div class="diag-card" id="diag-faults"></div>
|
<div class="diag-card" id="diag-faults"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ Host network settings (spec §15) ============ -->
|
||||||
|
<!-- Hidden unless the server exposes the feature (flag + docker
|
||||||
|
gates); the tab button above stays .hidden until then. -->
|
||||||
|
<section id="tab-hostnet" class="tab-page">
|
||||||
|
<h2>Host network settings</h2>
|
||||||
|
<div id="hostnet-banner" class="diag-card hostnet-banner hidden">
|
||||||
|
<p id="hostnet-banner-text"></p>
|
||||||
|
</div>
|
||||||
|
<div id="hostnet-body"></div>
|
||||||
|
<div class="diag-card" id="hostnet-audit-card">
|
||||||
|
<h3>Audit log</h3>
|
||||||
|
<p class="cfg-note">Every mutation and every refusal, newest last.
|
||||||
|
The agent appends the same lines to the service log (journald).</p>
|
||||||
|
<pre id="hostnet-audit" class="hostnet-audit"></pre>
|
||||||
|
<div class="fault-buttons">
|
||||||
|
<button id="hostnet-audit-refresh">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/static/app.js?v=4"></script>
|
<script src="/static/app.js?v=5"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>WAGO Simulator — Login</title>
|
<title>WAGO Simulator — Login</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="/static/style.css?v=4">
|
<link rel="stylesheet" href="/static/style.css?v=5">
|
||||||
</head>
|
</head>
|
||||||
<body class="login-body">
|
<body class="login-body">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
|
|||||||
@@ -376,4 +376,36 @@ button.primary:hover { background: #1b5e20; }
|
|||||||
}
|
}
|
||||||
.range-sep { color: #6b7684; }
|
.range-sep { color: #6b7684; }
|
||||||
.hidden-file { display: none; }
|
.hidden-file { display: none; }
|
||||||
#cfg-io-buttons { display: flex; gap: 8px; margin-top: 8px; }
|
#cfg-io-buttons { display: flex; gap: 8px; margin-top: 8px; }
|
||||||
|
|
||||||
|
/* ---------- host network settings (spec §15) ---------- */
|
||||||
|
|
||||||
|
.hostnet-banner {
|
||||||
|
border-left: 4px solid #e8a23c;
|
||||||
|
background: #fdf6e9;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.hostnet-banner p { margin: 0; color: #7a5b12; font-size: 0.88rem; }
|
||||||
|
.hostnet-card h3 { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.hostnet-flag {
|
||||||
|
font-size: 0.68rem; font-weight: 500; padding: 2px 7px;
|
||||||
|
border-radius: 10px; text-transform: uppercase; letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.hostnet-flag.ok { background: #e2f5e6; color: #1d7a33; }
|
||||||
|
.hostnet-flag.off { background: #e8ecf1; color: #56606c; }
|
||||||
|
.hostnet-addrs { width: 100%; margin-bottom: 8px; }
|
||||||
|
.hostnet-form {
|
||||||
|
display: flex; gap: 8px; align-items: center; margin-top: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.hostnet-form input {
|
||||||
|
padding: 6px 8px; border: 1px solid #c5ccd4; border-radius: 4px;
|
||||||
|
font-size: 0.85rem; font-family: Consolas, monospace;
|
||||||
|
}
|
||||||
|
.hostnet-form input[type="number"] { width: 70px; }
|
||||||
|
.hostnet-audit {
|
||||||
|
max-height: 220px; overflow-y: auto; background: #f4f6f8;
|
||||||
|
border: 1px solid #e3e7ec; border-radius: 4px; padding: 10px 12px;
|
||||||
|
font-family: Consolas, monospace; font-size: 0.78rem;
|
||||||
|
white-space: pre-wrap; margin: 0 0 10px;
|
||||||
|
}
|
||||||
@@ -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
|
assert st["sim_time_ms"] > 0
|
||||||
r = await client.post("/api/env/reset")
|
r = await client.post("/api/env/reset")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
st = (await client.get("/api/env")).json()
|
# the reset RESPONSE carries the post-reset status: asserting
|
||||||
assert st["sim_time_ms"] == 0.0 # deterministic fresh episode
|
# on a follow-up GET would race the running 10 ms ticker
|
||||||
|
# (which legitimately ticks between the two requests)
|
||||||
|
assert r.json()["sim_time_ms"] == 0.0 # deterministic fresh episode
|
||||||
await client.post("/api/env/stop")
|
await client.post("/api/env/stop")
|
||||||
|
|
||||||
async def test_restart_fresh_instance(self, client, app):
|
async def test_restart_fresh_instance(self, client, app):
|
||||||
|
|||||||
Reference in New Issue
Block a user