diff --git a/CMakeLists.txt b/CMakeLists.txt index b7edf1bb..7fa12388 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1080,7 +1080,15 @@ if(BUILD_TOOLS) vms_sysgen # SYSGEN.EXE vms_cnxtrace # CNXTRACE.EXE PARTS # PARTS.EXE + ncp_exe # NCP.EXE ) + # NCP.EXE (rd vms-1e9): the DECnet Phase IV Network Control Program + # (SET/DEFINE/SHOW/CLEAR NODE, SET/SHOW EXECUTOR). Portable C (stdio + the + # node database) with no arch-specific code, so it ships on BOTH arches -- + # the configuration surface a DECnet manager uses to build the node database. + # (The DECNETD.EXE network daemon is deliberately NOT in this aggregate yet: + # its AF_PACKET/PTY path needs the netbsd-vax userspace port + a booted proof + # before it ships -- tracked under vms-30e, not an allowlist gap.) # CNXTRACE.EXE is in the aggregate on PURPOSE, on both arches (E69). It # reads the connection manager's join transition ring out of the executive diff --git a/distro/Dockerfile.bootable b/distro/Dockerfile.bootable index 1ea25650..fd9d4f15 100644 --- a/distro/Dockerfile.bootable +++ b/distro/Dockerfile.bootable @@ -622,6 +622,15 @@ RUN mkdir -p /system-stage/dev /system-stage/proc /system-stage/sys \ # stages build-static/bin/LIBRARIAN.EXE, so the path + presence are proven. cp build-static/bin/LIBRARIAN.EXE /system-stage/vms/SYS0/SYSCOMMON/SYSEXE/ 2>/dev/null ; \ cp build-static/bin/OVMXDUMP /system-stage/vms/SYS0/SYSCOMMON/SYSEXE/ 2>/dev/null ; \ + # DECnet Phase IV: ship the NCP configuration utility on the running system + # so an admin can build the node database (DEFINE NODE) + set the executor. + # NCP.EXE is portable C and ships on BOTH arches (see the ovmx-images vax + # aggregate). The DECNETD.EXE network daemon is NOT staged yet: its AF_PACKET + # datalink + PTY login path need the netbsd-vax userspace port + a booted + # proof before it ships (rd vms-30e); staging it x86-only would be an unbuilt + # parity gap, which the #589 allowlist forbids. Plain `cp ... SYSEXE/` form so + # the image-parity scanner sees it. + cp build-static/bin/NCP.EXE /system-stage/vms/SYS0/SYSCOMMON/SYSEXE/ 2>/dev/null ; \ # TCC.EXE (compiler) + LINK.EXE (linker) -- the self-host toolchain on the # shipped disk (vms-992e). LINK.EXE comes from build-static (BUILD_TOOLS # vmslink target); TCC.EXE from the mk_tcc_static.sh step above. LIBRARIAN.EXE diff --git a/docs/compat/facilities/decnet.yaml b/docs/compat/facilities/decnet.yaml index 0c70ec85..20aaf803 100644 --- a/docs/compat/facilities/decnet.yaml +++ b/docs/compat/facilities/decnet.yaml @@ -6,23 +6,31 @@ vms_ref: "DECnet for OpenVMS Networking Manual; DNA Phase IV Routing + NSP specs scope_1_0: in plan_ref: vms-30e summary: > - No longer greenfield: src/vmsdecnet now carries real, clean-room Phase IV - wire codecs and a routing adjacency engine, several oracle-verified - byte-identical against captures committed in docs/decnet-provenance-register.md - (lab specimens, rd vms-3be/PR #665). Present: the Ethernet Endnode Hello - codec (oracle-verified), the Router Hello codec (spec-derived), the routing - adjacency state machine + hello/listen timers, and the NSP transport codec - (Connect Initiate oracle-verified; other PDUs self-round-trip). NOT yet - wired: a live engine/socket — nothing drives a real logical link, so - task-to-task and a working SET HOST do not exist. SET HOST still honestly - reports unavailability; NODE"acc"::file filespec syntax parses but nothing - downstream acts on it. 1.0 blocker (vms-30e). -last_reviewed: 2026-08-31 + No longer greenfield, and no longer codec-only: src/vmsdecnet now carries + real clean-room Phase IV codecs AND a live engine (DECNETD.EXE) that moves + frames over a real AF_PACKET datalink. Proven on real wires: the Ethernet + Endnode Hello codec (oracle-verified) drives adjacency against the REAL lab + VAX node 1.1 on the lab-2 br0 (OVMX emits faithful hellos and its engine + registers the VAX; tests/lab/tools/decnet_adjacency.sh, capture 2026-09-07), + and an NSP LOGICAL LINK completes end-to-end over a real datalink — + Connect Initiate/Confirm, a byte-identical data segment + ack, and a clean + Disconnect — between two engines (tests/integration/decnet_nsp_live_datalink.sh, + veth). Also present: the Router Hello codec (spec-derived) and the routing + adjacency state machine. STILL a codec-only / not-wired seam above the link: + a real VAX does not yet list OVMX as a reachable endnode (needs the node DB + + reachability wiring; parked on wedged lab consoles), task-to-task is only a + raw data segment (no $QIO/object DB/FAL), SET HOST is still an honest stub, + and NODE"acc"::file syntax parses but nothing downstream acts on it. 1.0 + blocker (vms-30e). +last_reviewed: 2026-09-07 items: - {id: decnet$routing-hello, kind: protocol, status: verified, authenticity: real, vms: "Phase IV Ethernet Endnode Hello codec (encode/decode)", evidence: "src/vmsdecnet/routing/dnet_hello.c", verified_against: "docs/decnet-provenance-register.md sec 4.6 specimen #1 (lab capture, rd vms-3be/PR #665) round-tripped byte-identical by tests/vmsdecnet/test_dnet_hello.c", notes: "Pure byte-layout library, no socket/allocation; field-by-field mapped to the committed capture."} - {id: decnet$router-hello, kind: protocol, status: implemented, authenticity: real, vms: "Phase IV Ethernet Router Hello codec (encode/decode)", evidence: "src/vmsdecnet/routing/dnet_router_hello.c", notes: "SPEC-DERIVED — no committed router-hello wire specimen exists, so not oracle-anchored; self round-trips in tests/vmsdecnet/test_dnet_router_hello.c (rd vms-0aba)."} - {id: decnet$routing-adjacency, kind: feature, status: implemented, authenticity: real, vms: "Phase IV routing adjacency state machine (DOWN/INITIALIZING/UP) + hello cadence / listen timers", evidence: "src/vmsdecnet/routing/dnet_adjacency.c", notes: "Pure clock-injectable SM over the Hello codec; T3=15s oracle-captured (vms-3be), BCT3MULT spec-derived. Tested tests/vmsdecnet/test_dnet_adjacency.c (rd vms-b15)."} - - {id: decnet$nsp-transport, kind: protocol, status: partial, authenticity: real, vms: "NSP transport codec — Connect Initiate/Confirm, Data, Ack, teardown PDUs (logical links)", evidence: "src/vmsdecnet/nsp/dnet_nsp.c", notes: "Connect Initiate is oracle-verified byte-identical against docs/decnet-provenance-register.md sec 4.6 specimen #3 (the lab handshake never completed, so the other PDUs are self-round-trip only, no fabricated specimen bytes). Codec only — no live transport engine drives a logical link yet. Tested tests/vmsdecnet/test_dnet_nsp.c (rd vms-6986)."} - - {id: decnet$task-to-task, kind: feature, status: absent, authenticity: n/a, vms: "Task-to-task DECnet communication (logical-link $QIO / FAL)", evidence: "docs/design-decnet-ovmx.md", scope_1_0: in, notes: "The codecs exist but the engine boundary has not moved — no socket/AF_PACKET engine binds them into a live link."} - - {id: decnet$set-host, kind: command, status: stub, authenticity: real, vms: "SET HOST — DECnet remote-node connection", evidence: "src/vmsdcl/dcl_cmd_set.c", notes: "Honestly reports %SET-I-NOTAVAIL, DECnet is not available on this system — a stub, not a facade."} + - {id: decnet$nsp-transport, kind: protocol, status: implemented, authenticity: real, vms: "NSP transport codec + live logical-link connection service over the datalink", evidence: "src/vmsdecnet/engine/decnetd.c", notes: "The connection FSM (src/vmsdecnet/nsp/dnet_link.c: CI/CC/DATA/ACK/DI/DC + retransmit/give-up) is now DRIVEN OVER A REAL AF_PACKET DATALINK by DECNETD's live loop (--connect/--listen), not only a socketpair: an end-to-end link OPEN -> byte-identical data segment + ack -> clean DISCONNECT is proven over a veth wire in tests/integration/decnet_nsp_live_datalink.sh (rd vms-c23). Oracle scope unchanged: only the Connect Initiate bytes are oracle-verified (register sec 4.6 specimen #3); CC/DATA/ACK/DI/DC are spec-derived and proven by round-trip, no fabricated specimen bytes. Unit: tests/vmsdecnet/test_dnet_nsp.c + test_dnet_link.c (rd vms-6986/vms-c23)."} + - {id: decnet$task-to-task, kind: feature, status: partial, authenticity: real, vms: "Task-to-task DECnet communication (logical-link data; $QIO / FAL)", evidence: "src/vmsdecnet/engine/decnetd.c", scope_1_0: in, notes: "A raw application data segment now round-trips byte-identical over a LIVE NSP logical link between two engines on a real datalink (tests/integration/decnet_nsp_live_datalink.sh; the substrate exists). NOT yet built: the VMS $QIO task-to-task API, a named-object/listener database, and FAL file access — those are later rungs (vms-8c2/vms-230)."} + - {id: decnet$ncp, kind: command, status: partial, authenticity: real, vms: "NCP — Network Control Program (node/executor configuration)", evidence: "src/vmsdecnet/ncp/ncp.c", notes: "Real NCP.EXE driving a persisted database: SET/DEFINE NODE, CLEAR/PURGE NODE, SHOW KNOWN NODES, SHOW NODE, SET/SHOW EXECUTOR (ADDRESS/NAME/STATE), with VMS-faithful SHOW layout and honest errors. Grammar/layout from the public NCP manual (Rule 8). NOT yet: circuits, objects, lines, counters, LOOP, and the SET(volatile)/DEFINE(permanent) split — OVMX keeps one persisted DB today. Config-only; it does not itself start the network."} + - {id: decnet$node-database, kind: feature, status: implemented, authenticity: real, vms: "Persisted remote-node database + node NAME<->address resolution", evidence: "src/vmsdecnet/ncp/dnet_nodedb.c", notes: "The NCP node table: add/update/clear, case-insensitive unique node names, ascending-address SHOW ordering, and persistence (atomic save + tolerant load). Provides the name<->address resolution the SET HOST / NODE:: paths need. On-disk format is a documented OVMX plain-text layout (labelled — NOT VMS NETNODE_REMOTE.DAT binary). Unit: tests/vmsdecnet/test_dnet_nodedb.c (rd vms-1e9)."} + - {id: decnet$cterm-service, kind: feature, status: implemented, authenticity: real, vms: "CTERM terminal service — an interactive $ SET HOST session over a live NSP logical link", evidence: "src/vmsdecnet/engine/decnetd.c", notes: "DECNETD --set-host (CTERM TERMINAL, real stdin/stdout) and --cterm-server (CTERM HOST that openpty+fork+execs a real login-command, default vmsdcl --login) carry a GENUINE interactive program's bidirectional I/O over the live datalink: link to CTERM object 42 -> Bind/Accept -> characteristics -> the spawned program's real output reaches the client AND the client's real keystrokes reach the program (proven both directions, incl. an independent side-channel, in tests/integration/decnet_set_host_live.sh over veth; capture docs/../decnet-set-host-live-20260908). NOT yet: the DCL `SET HOST` command wiring + name resolution (decnet$set-host), the real vmsdcl --login integration + proof (needs a booted OVMX env: SYSUAF/DCL/logicals), CTERM read-solicitation flow (byte-transparent pump today), and multi-link scope. CTERM wire bytes remain SPEC-DERIVED (no oracle specimen; register sec 4.7) -- unchanged by this work."} + - {id: decnet$set-host, kind: command, status: stub, authenticity: real, vms: "SET HOST — DECnet remote-node connection (DCL command)", evidence: "src/vmsdcl/dcl_cmd_set.c", notes: "The DCL `$ SET HOST` command still honestly reports %SET-I-NOTAVAIL — a stub, not a facade. The underlying capability now EXISTS (decnet$cterm-service: DECNETD --set-host carries a real CTERM session over the live link); wiring the DCL command to resolve the node name via the node database (decnet$node-database) and invoke it is the next rung (vms-230/vms-4d2), best completed + proven in a booted OVMX env."} - {id: decnet$node-filespec-syntax, kind: feature, status: partial, authenticity: real, vms: "NODE\"acc\"::dev:[dir]file filespec syntax", evidence: "src/vmsrms/rms_parse.c", notes: "Syntax only — sets NAM$M_NODE, parses/reconstructs the node prefix; nothing downstream (no live DECnet transport) acts on it."} diff --git a/docs/compatibility-surface.md b/docs/compatibility-surface.md index 2613dcdf..ab02c7fb 100644 --- a/docs/compatibility-surface.md +++ b/docs/compatibility-surface.md @@ -1,22 +1,22 @@ # OVMX Compatibility Surface Register -> Generated 2026-09-05 from `docs/compat/` against `origin/main`. Source of truth is the YAML; this file is regenerated. See `docs/design-compat-surface-register.md`. +> Generated 2026-09-08 from `docs/compat/` against `origin/main`. Source of truth is the YAML; this file is regenerated. See `docs/design-compat-surface-register.md`. ## Inventory -**440 surfaces catalogued** across 9 domains, each with a per-surface status. +**443 surfaces catalogued** across 9 domains, each with a per-surface status. > This register is an **inventory, not a percentage.** The total VMS compatibility surface has **no known denominator** — it is not version-scoped and cannot be counted — so no "% compatible" is claimed or computable. The catalogue is **incomplete by construction** and grows as surfaces are identified. Below are absolute counts; V1 progress is tracked separately against the commitment set we define, and is never conflated with the whole surface. | Status | Count | | Authenticity | Count | |---|---|---|---|---| -| ✅ verified | 25 | | real | 294 | -| 🟢 implemented | 256 | | n/a | 93 | -| 🟡 partial | 46 | | advisory | 42 | +| ✅ verified | 25 | | real | 298 | +| 🟢 implemented | 259 | | n/a | 92 | +| 🟡 partial | 47 | | advisory | 42 | | 🟠 stub | 18 | | facade-risk | 11 | | 🔵 designed | 0 | | | | -| ⬜ absent | 95 | | | | +| ⬜ absent | 94 | | | | Legend: ✅ verified · 🟢 implemented · 🟡 partial · 🟠 stub · 🔵 designed · ⬜ absent · ⚠ facade-risk (INV-6/Draper) · ≈ advisory. @@ -24,7 +24,7 @@ Legend: ✅ verified · 🟢 implemented · 🟡 partial · 🟠 stub · 🔵 de Of the surfaces **committed to V1** (`scope_1_0: in` — a set we define, not a measure of the whole surface): -- **396 committed** — **281 met** (implemented/verified), 45 in progress (partial), 70 not started (absent/stub/designed). +- **399 committed** — **284 met** (implemented/verified), 46 in progress (partial), 69 not started (absent/stub/designed). - ⚠ **9 of the committed surfaces carry facade-risk** — they must reach honest behaviour, not just "done". - Not in the V1 commitment set: 8 out · 27 stretch · 9 undecided (incl. the language scope calls, `vms-082`). @@ -1106,24 +1106,27 @@ Universal symbol vectors (position-bound binding), GSMATCH (ALWAYS/EQUAL/LEQUAL, _TCP/IP Services (UCX), DECnet Phase IV, LAT, SSH._ -`✅✅🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟡🟡🟠⬜⬜⬜` — 22 surfaces catalogued (16 met · 2 in progress · 4 not started) · V1: 20 committed, 16 met +`✅✅🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟡🟡🟡🟠⬜⬜` — 25 surfaces catalogued (19 met · 3 in progress · 3 not started) · V1: 23 committed, 19 met ### decnet — DECnet Phase IV (Routing, NSP, Task-to-Task, SET HOST) -scope: in · plan: vms-30e · ref: DECnet for OpenVMS Networking Manual; DNA Phase IV Routing + NSP specs · reviewed 2026-08-31 +scope: in · plan: vms-30e · ref: DECnet for OpenVMS Networking Manual; DNA Phase IV Routing + NSP specs · reviewed 2026-09-07 -No longer greenfield: src/vmsdecnet now carries real, clean-room Phase IV wire codecs and a routing adjacency engine, several oracle-verified byte-identical against captures committed in docs/decnet-provenance-register.md (lab specimens, rd vms-3be/PR #665). Present: the Ethernet Endnode Hello codec (oracle-verified), the Router Hello codec (spec-derived), the routing adjacency state machine + hello/listen timers, and the NSP transport codec (Connect Initiate oracle-verified; other PDUs self-round-trip). NOT yet wired: a live engine/socket — nothing drives a real logical link, so task-to-task and a working SET HOST do not exist. SET HOST still honestly reports unavailability; NODE"acc"::file filespec syntax parses but nothing downstream acts on it. 1.0 blocker (vms-30e). +No longer greenfield, and no longer codec-only: src/vmsdecnet now carries real clean-room Phase IV codecs AND a live engine (DECNETD.EXE) that moves frames over a real AF_PACKET datalink. Proven on real wires: the Ethernet Endnode Hello codec (oracle-verified) drives adjacency against the REAL lab VAX node 1.1 on the lab-2 br0 (OVMX emits faithful hellos and its engine registers the VAX; tests/lab/tools/decnet_adjacency.sh, capture 2026-09-07), and an NSP LOGICAL LINK completes end-to-end over a real datalink — Connect Initiate/Confirm, a byte-identical data segment + ack, and a clean Disconnect — between two engines (tests/integration/decnet_nsp_live_datalink.sh, veth). Also present: the Router Hello codec (spec-derived) and the routing adjacency state machine. STILL a codec-only / not-wired seam above the link: a real VAX does not yet list OVMX as a reachable endnode (needs the node DB + reachability wiring; parked on wedged lab consoles), task-to-task is only a raw data segment (no $QIO/object DB/FAL), SET HOST is still an honest stub, and NODE"acc"::file syntax parses but nothing downstream acts on it. 1.0 blocker (vms-30e). -7 items · 3 met · 2 in progress · 2 not started +10 items · 6 met · 3 in progress · 1 not started | | Surface | Kind | VMS | Status | Auth | Scope | Evidence / notes | |---|---|---|---|---|---|---|---| | ✅ | `decnet$routing-hello` | protocol | Phase IV Ethernet Endnode Hello codec (encode/decode) | verified | real | in | `src/vmsdecnet/routing/dnet_hello.c` — Pure byte-layout library, no socket/allocation; field-by-field mapped to the committed capture. | | 🟢 | `decnet$router-hello` | protocol | Phase IV Ethernet Router Hello codec (encode/decode) | implemented | real | in | `src/vmsdecnet/routing/dnet_router_hello.c` — SPEC-DERIVED — no committed router-hello wire specimen exists, so not oracle-anchored; self round-trips in tests/vmsdecnet/test_dnet_router_hello.c (rd vms-0aba). | | 🟢 | `decnet$routing-adjacency` | feature | Phase IV routing adjacency state machine (DOWN/INITIALIZING/UP) + hello cadence / listen timers | implemented | real | in | `src/vmsdecnet/routing/dnet_adjacency.c` — Pure clock-injectable SM over the Hello codec; T3=15s oracle-captured (vms-3be), BCT3MULT spec-derived. Tested tests/vmsdecnet/test_dnet_adjacency.c (rd vms-b15). | -| 🟡 | `decnet$nsp-transport` | protocol | NSP transport codec — Connect Initiate/Confirm, Data, Ack, teardown PDUs (logical links) | partial | real | in | `src/vmsdecnet/nsp/dnet_nsp.c` — Connect Initiate is oracle-verified byte-identical against docs/decnet-provenance-register.md sec 4.6 specimen #3 (the lab handshake never completed, so the other PDUs are self-round-trip only, no fabricated specimen bytes). Codec only — no live transport engine drives a logical link yet. Tested tests/vmsdecnet/test_dnet_nsp.c (rd vms-6986). | -| ⬜ | `decnet$task-to-task` | feature | Task-to-task DECnet communication (logical-link $QIO / FAL) | absent | n/a | in | `docs/design-decnet-ovmx.md` — The codecs exist but the engine boundary has not moved — no socket/AF_PACKET engine binds them into a live link. | -| 🟠 | `decnet$set-host` | command | SET HOST — DECnet remote-node connection | stub | real | in | `src/vmsdcl/dcl_cmd_set.c` — Honestly reports %SET-I-NOTAVAIL, DECnet is not available on this system — a stub, not a facade. | +| 🟢 | `decnet$nsp-transport` | protocol | NSP transport codec + live logical-link connection service over the datalink | implemented | real | in | `src/vmsdecnet/engine/decnetd.c` — The connection FSM (src/vmsdecnet/nsp/dnet_link.c: CI/CC/DATA/ACK/DI/DC + retransmit/give-up) is now DRIVEN OVER A REAL AF_PACKET DATALINK by DECNETD's live loop (--connect/--listen), not only a socketpair: an end-to-end link OPEN -> byte-identical data segment + ack -> clean DISCONNECT is proven over a veth wire in tests/integration/decnet_nsp_live_datalink.sh (rd vms-c23). Oracle scope unchanged: only the Connect Initiate bytes are oracle-verified (register sec 4.6 specimen #3); CC/DATA/ACK/DI/DC are spec-derived and proven by round-trip, no fabricated specimen bytes. Unit: tests/vmsdecnet/test_dnet_nsp.c + test_dnet_link.c (rd vms-6986/vms-c23). | +| 🟡 | `decnet$task-to-task` | feature | Task-to-task DECnet communication (logical-link data; $QIO / FAL) | partial | real | in | `src/vmsdecnet/engine/decnetd.c` — A raw application data segment now round-trips byte-identical over a LIVE NSP logical link between two engines on a real datalink (tests/integration/decnet_nsp_live_datalink.sh; the substrate exists). NOT yet built: the VMS $QIO task-to-task API, a named-object/listener database, and FAL file access — those are later rungs (vms-8c2/vms-230). | +| 🟡 | `decnet$ncp` | command | NCP — Network Control Program (node/executor configuration) | partial | real | in | `src/vmsdecnet/ncp/ncp.c` — Real NCP.EXE driving a persisted database: SET/DEFINE NODE, CLEAR/PURGE NODE, SHOW KNOWN NODES, SHOW NODE, SET/SHOW EXECUTOR (ADDRESS/NAME/STATE), with VMS-faithful SHOW layout and honest errors. Grammar/layout from the public NCP manual (Rule 8). NOT yet: circuits, objects, lines, counters, LOOP, and the SET(volatile)/DEFINE(permanent) split — OVMX keeps one persisted DB today. Config-only; it does not itself start the network. | +| 🟢 | `decnet$node-database` | feature | Persisted remote-node database + node NAME<->address resolution | implemented | real | in | `src/vmsdecnet/ncp/dnet_nodedb.c` — The NCP node table: add/update/clear, case-insensitive unique node names, ascending-address SHOW ordering, and persistence (atomic save + tolerant load). Provides the name<->address resolution the SET HOST / NODE:: paths need. On-disk format is a documented OVMX plain-text layout (labelled — NOT VMS NETNODE_REMOTE.DAT binary). Unit: tests/vmsdecnet/test_dnet_nodedb.c (rd vms-1e9). | +| 🟢 | `decnet$cterm-service` | feature | CTERM terminal service — an interactive $ SET HOST session over a live NSP logical link | implemented | real | in | `src/vmsdecnet/engine/decnetd.c` — DECNETD --set-host (CTERM TERMINAL, real stdin/stdout) and --cterm-server (CTERM HOST that openpty+fork+execs a real login-command, default vmsdcl --login) carry a GENUINE interactive program's bidirectional I/O over the live datalink: link to CTERM object 42 -> Bind/Accept -> characteristics -> the spawned program's real output reaches the client AND the client's real keystrokes reach the program (proven both directions, incl. an independent side-channel, in tests/integration/decnet_set_host_live.sh over veth; capture docs/../decnet-set-host-live-20260908). NOT yet: the DCL `SET HOST` command wiring + name resolution (decnet$set-host), the real vmsdcl --login integration + proof (needs a booted OVMX env: SYSUAF/DCL/logicals), CTERM read-solicitation flow (byte-transparent pump today), and multi-link scope. CTERM wire bytes remain SPEC-DERIVED (no oracle specimen; register sec 4.7) -- unchanged by this work. | +| 🟠 | `decnet$set-host` | command | SET HOST — DECnet remote-node connection (DCL command) | stub | real | in | `src/vmsdcl/dcl_cmd_set.c` — The DCL `$ SET HOST` command still honestly reports %SET-I-NOTAVAIL — a stub, not a facade. The underlying capability now EXISTS (decnet$cterm-service: DECNETD --set-host carries a real CTERM session over the live link); wiring the DCL command to resolve the node name via the node database (decnet$node-database) and invoke it is the next rung (vms-230/vms-4d2), best completed + proven in a booted OVMX env. | | 🟡 | `decnet$node-filespec-syntax` | feature | NODE"acc"::dev:[dir]file filespec syntax | partial | real | in | `src/vmsrms/rms_parse.c` — Syntax only — sets NAM$M_NODE, parses/reconstructs the node prefix; nothing downstream (no live DECnet transport) acts on it. | ### lat — LAT (Local Area Transport) diff --git a/src/libdatalink/include/scs_datalink.h b/src/libdatalink/include/scs_datalink.h index 57c65f21..fa4c3b4d 100644 --- a/src/libdatalink/include/scs_datalink.h +++ b/src/libdatalink/include/scs_datalink.h @@ -181,6 +181,22 @@ ssize_t scs_datalink_recv(int fd, uint8_t *buf, size_t buf_len); */ int scs_datalink_set_recv_timeout(int fd, int seconds); +/* + * scs_datalink_set_promisc - put the bound interface into promiscuous mode so + * the socket receives unicast frames addressed to a MAC the interface does not + * own (e.g. an OVMX DECnet address on a shared lab bridge). The faithful + * alternative -- programming the NIC's MAC to the DECnet address -- is only + * available on an interface OVMX owns; where it does not, the caller filters + * inbound frames to its address in software. Best-effort: a caller treats a + * -1 return as "unicast rx may be limited", not fatal. + * + * Linux: PACKET_ADD_MEMBERSHIP / PACKET_MR_PROMISC. + * NetBSD: BIOCPROMISC. + * + * Returns 0 on success, -1 with errno set on failure. + */ +int scs_datalink_set_promisc(int fd, const char *ifname); + #ifdef __cplusplus } #endif diff --git a/src/libdatalink/ovmx_datalink.c b/src/libdatalink/ovmx_datalink.c index 6b53a898..e290f0c1 100644 --- a/src/libdatalink/ovmx_datalink.c +++ b/src/libdatalink/ovmx_datalink.c @@ -241,6 +241,17 @@ int scs_datalink_set_recv_timeout(int fd, int seconds) return 0; } +/* Promiscuous reception is a property of the executive's L2 attach, not a + * userspace socket option, in this backend. It is not the DECnet daemon's + * datalink (decnetd links the AF_PACKET probe backend), so this path reports an + * honest "not supported here" rather than silently claiming success. */ +int scs_datalink_set_promisc(int fd, const char *ifname) +{ + (void)fd; (void)ifname; + errno = ENOSYS; + return -1; +} + #else /* !SCS_DATALINK_VIA_EXECUTIVE -- the AF_PACKET PROBE backend */ /* ---- AF_PACKET PROBE backend: a clean-room RE / ORACLE INSTRUMENT ONLY ---- @@ -289,6 +300,28 @@ void scs_datalink_close(int fd) close(fd); } +/* + * scs_datalink_set_promisc - put the bound interface into promiscuous mode so + * the socket receives UNICAST frames addressed to a MAC the interface does not + * own. A faithful DECnet node programs its NIC's MAC to the algorithmic DECnet + * address (AA-00-04-00-) and then receives its unicast natively; on a + * shared NIC OVMX does not own (a lab bridge shared with other nodes), it + * cannot rewrite that MAC, so it goes promiscuous and filters inbound frames to + * its DECnet address in software instead. Wire-identical to peers either way -- + * only local reception differs. Returns 0 on success, -1 (errno set) otherwise. + */ +int scs_datalink_set_promisc(int fd, const char *ifname) +{ + unsigned ifindex = if_nametoindex(ifname); + if (ifindex == 0) + return -1; + struct packet_mreq mr; + memset(&mr, 0, sizeof(mr)); + mr.mr_ifindex = (int)ifindex; + mr.mr_type = PACKET_MR_PROMISC; + return setsockopt(fd, SOL_PACKET, PACKET_ADD_MEMBERSHIP, &mr, sizeof(mr)); +} + /* * datalink_send_linux - THE Linux transport primitive. Kept as its own * named function (rather than inlined into scs_datalink_send() below) so a @@ -665,6 +698,16 @@ int scs_datalink_set_recv_timeout(int fd, int seconds) return ioctl(fd, BIOCSRTIMEOUT, &tv); } +/* BPF: put the attached interface into promiscuous mode (BIOCPROMISC takes no + * argument; the fd is already bound to its interface via BIOCSETIF). Same + * rationale as the Linux path -- receive unicast to a DECnet MAC the shared + * interface does not own, then filter to our address in software. */ +int scs_datalink_set_promisc(int fd, const char *ifname) +{ + (void)ifname; + return ioctl(fd, BIOCPROMISC, NULL); +} + #else #error "scs_datalink: no backend for this platform (only __linux__ and __NetBSD__ are implemented)" #endif diff --git a/src/vmsdecnet/CMakeLists.txt b/src/vmsdecnet/CMakeLists.txt index 0f133237..1002f155 100644 --- a/src/vmsdecnet/CMakeLists.txt +++ b/src/vmsdecnet/CMakeLists.txt @@ -14,6 +14,12 @@ add_subdirectory(routing) add_subdirectory(nsp) +# NCP configuration surface (rd vms-1e9): the persisted node database + the +# NCP.EXE Network Control Program (SET/DEFINE/SHOW/CLEAR NODE, SET/SHOW +# EXECUTOR) -- the "configuration" pillar + the node NAME<->address resolution +# the SET HOST / NODE:: paths resolve through. +add_subdirectory(ncp) + # CTERM layer (rd vms-4d2, rung 3): the Command Terminal protocol behind # $ SET HOST -- the terminal-service layered product that rides on an # established NSP logical link (nsp/dnet_link). Pure codec + session FSM, diff --git a/src/vmsdecnet/engine/CMakeLists.txt b/src/vmsdecnet/engine/CMakeLists.txt index 29dada06..7ede467a 100644 --- a/src/vmsdecnet/engine/CMakeLists.txt +++ b/src/vmsdecnet/engine/CMakeLists.txt @@ -29,20 +29,29 @@ target_link_libraries(vmsdecnet_engine PUBLIC vmsdecnet_routing vmsdecnet_nsp) # touched. It ties the socketless engine core to the live datalink + a monotonic # clock. Links ovmx_datalink for its socket/bpf open + MAC-get + send + recv # primitives. -add_executable(decnetd_exe decnetd.c) -target_include_directories(decnetd_exe PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR}/include - ${CMAKE_SOURCE_DIR}/src/libdatalink/include # scs_datalink.h - ${CMAKE_SOURCE_DIR}/src/vmsdecnet/cterm/include # dnet_cterm.h (--set-host-selftest) - ${CMAKE_SOURCE_DIR}/src/libvms/include # ovmx_identity.h (INV-1 banner SSOT) - ${CMAKE_SOURCE_DIR}/src/vmsfs/include # transitive header of ovmx_identity.h -) -# vmsdecnet_cterm: the CTERM terminal-service protocol (rd vms-4d2) that rides on -# the logical link -- the daemon's --set-host-selftest proves a full $ SET HOST -# terminal session over a socketpair. -target_link_libraries(decnetd_exe PRIVATE vmsdecnet_engine vmsdecnet_cterm ovmx_datalink) -set_target_properties(decnetd_exe PROPERTIES - OUTPUT_NAME "DECNETD" - SUFFIX ".EXE" -) -install(TARGETS decnetd_exe RUNTIME DESTINATION ${VMS_SYSTEM_DIR}) +# The DECNETD.EXE daemon is built on Linux only for now: its CTERM login path +# uses openpty()/forkpty() (libutil / on the BSDs) and the datalink is +# the AF_PACKET probe backend -- the netbsd-vax userspace port of the daemon is +# a tracked later rung (rd vms-30e). The engine + cterm LIBRARIES and the NCP +# utility (ncp_exe) remain portable and build on both arches; only this +# executable is gated. Guarding it keeps the unconditional src/vmsdecnet subtree +# clean under the netbsd-vax toolchain (no /-lutil link surprise). +if(NOT CMAKE_SYSTEM_NAME STREQUAL "NetBSD") + add_executable(decnetd_exe decnetd.c) + target_include_directories(decnetd_exe PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/include + ${CMAKE_SOURCE_DIR}/src/libdatalink/include # scs_datalink.h + ${CMAKE_SOURCE_DIR}/src/vmsdecnet/cterm/include # dnet_cterm.h (--set-host-selftest) + ${CMAKE_SOURCE_DIR}/src/libvms/include # ovmx_identity.h (INV-1 banner SSOT) + ${CMAKE_SOURCE_DIR}/src/vmsfs/include # transitive header of ovmx_identity.h + ) + # vmsdecnet_cterm: the CTERM terminal-service protocol (rd vms-4d2) that rides + # on the logical link -- the daemon's --set-host-selftest proves a full + # $ SET HOST terminal session over a socketpair. + target_link_libraries(decnetd_exe PRIVATE vmsdecnet_engine vmsdecnet_cterm ovmx_datalink) + set_target_properties(decnetd_exe PROPERTIES + OUTPUT_NAME "DECNETD" + SUFFIX ".EXE" + ) + install(TARGETS decnetd_exe RUNTIME DESTINATION ${VMS_SYSTEM_DIR}) +endif() diff --git a/src/vmsdecnet/engine/decnetd.c b/src/vmsdecnet/engine/decnetd.c index b0e00c4f..77364606 100644 --- a/src/vmsdecnet/engine/decnetd.c +++ b/src/vmsdecnet/engine/decnetd.c @@ -32,12 +32,22 @@ * vms-3be lab capture + OVMX's own scsd datalink pattern. No VSI/HPE source. */ #include +#include /* fcntl() */ #include /* if_nametoindex() */ +#include /* poll() -- multiplex the datalink + the aux fd */ +#if defined(__NetBSD__) || defined(__FreeBSD__) || defined(__OpenBSD__) +#include /* openpty()/forkpty() live in libutil on the BSDs */ +#else +#include /* openpty() -- glibc/Linux: the CTERM HOST spawns a real PTY */ +#endif #include #include #include #include +#include /* TIOCSWINSZ, TIOCSCTTY */ #include +#include /* waitpid() -- reap the CTERM login child */ +#include /* raw-mode local terminal (SET HOST client) */ #include #include @@ -528,6 +538,618 @@ static int run_sethost_selftest(void) return 0; } +/* + * ================= LIVE $ SET HOST / CTERM over the wire ================== + * The three selftests above prove the CTERM choreography over a socketpair with + * SCRIPTED payloads. The two modes below carry a REAL interactive session over + * the LIVE NSP logical link: a genuine PTY + a real login-command on the HOST + * side, real stdin/stdout on the TERMINAL (client) side. NOTHING is canned -- + * every screen byte is the spawned program's real output and every keystroke is + * real local input; every CTERM/NSP wire field is read from session/FSM state, + * never copied frame-to-frame (INV-6, executive-backed-not-wire-plumbing). The + * loops poll() BOTH the datalink AND the aux fd (stdin / pty master) on a ~1 s + * timeout so the HELLO cadence + link tick keep firing. + */ + +/* Emit the periodic engine traffic (HELLO on the T3 cadence, adjacency aging, + * and the active link's Connect-Initiate retransmit) -- the same cadence the + * routing loop runs, so a live SET HOST node stays a well-behaved Phase IV + * endnode while the terminal session is up. */ +static void dnet_periodic(struct dnet_engine *eng, int sock, unsigned ifindex, + dnet_tick_t now) +{ + if (dnet_engine_hello_due(eng, now)) { + uint8_t frame[DNET_FRAME_MAX]; + size_t flen = 0; + if (dnet_engine_build_hello_frame(eng, frame, sizeof(frame), &flen) + == DNET_ENGINE_OK && + scs_datalink_send(sock, (int)ifindex, DNET_ETHERTYPE, + DNET_HELLO_MCAST, frame, flen) >= 0) + dnet_engine_hello_emitted(eng, now); + } + dnet_engine_tick(eng, now); + if (eng->link_active) { + uint8_t frame[DNET_FRAME_MAX]; + size_t tlen = 0; + int thas = 0; + if (dnet_engine_link_tick(eng, now, frame, sizeof(frame), &tlen, &thas) + == DNET_ENGINE_OK && thas) { + uint8_t dst[DNET_ADDR_LEN]; + memcpy(dst, frame, DNET_ADDR_LEN); + scs_datalink_send(sock, (int)ifindex, DNET_ETHERTYPE, dst, frame, tlen); + } + } +} + +/* Ship one CTERM PDU as an NSP data segment on the live link: the FSM builds the + * data frame (its own sequence/addressing, executive-backed), then it goes out + * to the peer's DECnet id (frame[0..5], the routing dst the FSM wrote). Returns + * 0 or -1. */ +static int cterm_link_send(struct dnet_engine *eng, int sock, unsigned ifindex, + const uint8_t *pdu, size_t plen, dnet_tick_t now) +{ + uint8_t frame[DNET_FRAME_MAX]; + size_t flen = 0; + if (dnet_engine_link_send(eng, pdu, plen, frame, sizeof(frame), &flen, now) + != DNET_ENGINE_OK) + return -1; + uint8_t dst[DNET_ADDR_LEN]; + memcpy(dst, frame, DNET_ADDR_LEN); + return scs_datalink_send(sock, (int)ifindex, DNET_ETHERTYPE, dst, frame, flen) + < 0 ? -1 : 0; +} + +/* Receive one frame and route it: an NSP long-data frame addressed to us drives + * the logical-link FSM (auto-replies -- data ack / Disconnect Confirm -- are sent + * here), and its higher-layer event is returned (>=0). A HELLO / adjacency frame + * is consumed so the peer's HELLOs are honoured, returning DNET_LINK_EV_NONE. An + * own-echo or a frame addressed elsewhere returns NONE. Returns -1 on a hard recv + * error (caller should stop). On DNET_LINK_EV_DATA the payload is in + * eng->rx_data / eng->rx_datalen. */ +static int dnet_recv_route(struct dnet_engine *eng, int sock, unsigned ifindex, + dnet_tick_t now, uint8_t *rxbuf, size_t rxcap) +{ + ssize_t n = scs_datalink_recv(sock, rxbuf, rxcap); + if (n < 0) { + if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK) + return DNET_LINK_EV_NONE; + return -1; + } + if ((size_t)n >= DNET_ETH_HDRLEN && + memcmp(rxbuf + 6, eng->my_id, DNET_ADDR_LEN) == 0) + return DNET_LINK_EV_NONE; /* our own transmitted frame */ + + int is_nsp = ((size_t)n > (size_t)DNET_ETH_HDRLEN + DNET_DATA_LENPREFIX) && + rxbuf[DNET_ETH_HDRLEN + DNET_DATA_LENPREFIX] == DNET_RFLAG_LONG_DATA; + if (is_nsp) { + if (memcmp(rxbuf, eng->my_id, DNET_ADDR_LEN) != 0) + return DNET_LINK_EV_NONE; /* unicast for another node */ + uint8_t frame[DNET_FRAME_MAX]; + size_t rlen = 0; + int has_reply = 0; + enum dnet_link_event ev = DNET_LINK_EV_NONE; + if (dnet_engine_link_rx(eng, now, rxbuf, (size_t)n, frame, sizeof(frame), + &rlen, &has_reply, &ev) != DNET_ENGINE_OK) + return DNET_LINK_EV_NONE; + if (has_reply) { + uint8_t dst[DNET_ADDR_LEN]; + memcpy(dst, frame, DNET_ADDR_LEN); + scs_datalink_send(sock, (int)ifindex, DNET_ETHERTYPE, dst, frame, rlen); + } + return (int)ev; + } + + uint8_t from[DNET_ADDR_LEN]; + enum dnet_adj_state st = DNET_ADJ_DOWN; + dnet_engine_rx_frame(eng, now, rxbuf, (size_t)n, from, &st); + return DNET_LINK_EV_NONE; +} + +/* --- SET HOST client: local terminal in raw mode, save/restore on exit ----- */ +static struct termios g_saved_tio; +static int g_tio_saved = 0; +static void restore_local_tty(void) +{ + if (g_tio_saved) { + tcsetattr(STDIN_FILENO, TCSANOW, &g_saved_tio); + g_tio_saved = 0; + } +} + +/* + * --set-host AREA.NODE : the CTERM TERMINAL (the $ SET HOST client). Opens the + * logical link to the peer's CTERM object (42), binds a terminal session, + * negotiates characteristics, then bridges the LOCAL terminal to the remote + * session -- real stdin keystrokes -> CTERM Read Data, remote CTERM Write -> + * real stdout -- until the host unbinds, the link drops, or the run ends. It + * poll()s stdin AND the datalink so the HELLO cadence + link tick keep firing. + */ +static int run_set_host_loop(struct dnet_engine *eng, int sock, unsigned ifindex, + const char *peer_s, const char *user) +{ + unsigned parea = 0, pnode = 0; + if (parse_addr(peer_s, &parea, &pnode) != 0) { + fprintf(stderr, "DECNETD-E-BADPEER, --set-host wants AREA.NODE" + " (1..63 . 1..1023)\n"); + return 1; + } + struct dnet_cterm_session term; + if (dnet_cterm_session_init(&term, DNET_CTERM_ROLE_TERMINAL) != 0) { + fprintf(stderr, "DECNETD-E-CTERMINIT, terminal session init failed\n"); + return 1; + } + /* The access-control username is the explicit --user (VMS SET HOST/USERNAME= + * analog), defaulting to SYSTEM (the vms-3be capture observed SYSTEM in the + * connect message). It is never read from the process environment -- see the + * vms-cb5 identity-environment census. */ + if (!user || !*user) + user = "SYSTEM"; + uint8_t sc[128]; + size_t sclen = 0; + if (dnet_cterm_sc_connect_build(DNET_CTERM_OBJECT, user, "", "", + sc, sizeof(sc), &sclen) != 0) { + fprintf(stderr, "DECNETD-E-SCBUILD, CTERM connect-data build failed\n"); + return 1; + } + + uint8_t frame[DNET_FRAME_MAX], rxbuf[DNET_FRAME_MAX], cpdu[DNET_CTERM_MAX_PDU]; + size_t flen = 0, clen = 0; + dnet_tick_t now = monotonic_sec(); + if (dnet_engine_link_open(eng, parea, pnode, 0x2001, sc, sclen, 1459, 1, + DNET_NSP_VER_41, frame, sizeof(frame), &flen, now) + != DNET_ENGINE_OK) { + fprintf(stderr, "DECNETD-E-NOCONNECT, could not open a logical link" + " to %u.%u\n", parea, pnode); + return 1; + } + { + uint8_t dst[DNET_ADDR_LEN]; + dnet_id_from_addr(parea, pnode, dst); + scs_datalink_send(sock, (int)ifindex, DNET_ETHERTYPE, dst, frame, flen); + } + log_ts(stdout); + printf(" DECNETD-I-SETHOST, $ SET HOST %u.%u -- Connect Initiate sent to" + " CTERM object %d on circuit %s\n", + parea, pnode, DNET_CTERM_OBJECT, eng->circuit); + fflush(stdout); + + int raw_on = 0, stdin_eof = 0, done = 0, rc = 0; + + while (!g_stop && !done) { + now = monotonic_sec(); + dnet_periodic(eng, sock, ifindex, now); + + /* Connect-Initiate give-up: the FSM closed the link before we ever + * bound -- the peer never answered. Report honestly and stop. */ + if (eng->link_active && + dnet_link_state_of(&eng->link) == DNET_LINK_CLOSED && + dnet_cterm_state_of(&term) == DNET_CTERM_S_CLOSED) { + log_ts(stdout); + printf(" DECNETD-W-UNREACH, peer %u.%u did not answer -- SET HOST" + " abandoned\n", parea, pnode); + fflush(stdout); + eng->link_active = 0; + rc = 1; + break; + } + + struct pollfd pfd[2]; + int nfd = 0; + pfd[nfd].fd = sock; pfd[nfd].events = POLLIN; pfd[nfd].revents = 0; nfd++; + if (dnet_cterm_is_bound(&term) && !stdin_eof) { + pfd[nfd].fd = STDIN_FILENO; pfd[nfd].events = POLLIN; pfd[nfd].revents = 0; nfd++; + } + int pr = poll(pfd, (nfds_t)nfd, 1000); + if (pr < 0) { + if (errno == EINTR) + continue; + fprintf(stderr, "DECNETD-E-POLL, poll failed: %s\n", strerror(errno)); + rc = 1; + break; + } + if (pr == 0) + continue; + + if (pfd[0].revents & POLLIN) { + int ev = dnet_recv_route(eng, sock, ifindex, now, rxbuf, sizeof(rxbuf)); + if (ev < 0) { + fprintf(stderr, "DECNETD-E-RECVFAIL, recv failed: %s\n", + strerror(errno)); + rc = 1; + break; + } + switch (ev) { + case DNET_LINK_EV_CONNECT_CONF: + log_ts(stdout); + printf(" DECNETD-I-LINKUP, logical link to %u.%u is RUN --" + " sending CTERM Bind\n", parea, pnode); + fflush(stdout); + if (dnet_cterm_bind(&term, "OVMX$RTA1:", cpdu, sizeof(cpdu), &clen) != 0 || + cterm_link_send(eng, sock, ifindex, cpdu, clen, now) != 0) { + fprintf(stderr, "DECNETD-E-BIND, could not send CTERM Bind\n"); + rc = 1; done = 1; + } + break; + case DNET_LINK_EV_DATA: { + enum dnet_cterm_event cev = DNET_CTERM_EV_NONE; + if (dnet_cterm_rx(&term, eng->rx_data, eng->rx_datalen, &cev) + != DNET_CTERM_OK) + break; + if (cev == DNET_CTERM_EV_BOUND) { + log_ts(stdout); + printf(" DECNETD-I-BOUND, CTERM terminal session bound on" + " circuit %s -- terminal is live\n", eng->circuit); + fflush(stdout); + /* Advertise our characteristics (VT100-class, 80x24). */ + if (dnet_cterm_send_characteristics(&term, 4, 80, 24, + DNET_CTERM_CH_ECHO | DNET_CTERM_CH_WRAP, + cpdu, sizeof(cpdu), &clen) == 0) + cterm_link_send(eng, sock, ifindex, cpdu, clen, now); + /* Put the LOCAL terminal into raw mode (save first). On a + * pipe/redirect (no tty) this is a no-op -- the byte pump + * still works. */ + if (isatty(STDIN_FILENO) && + tcgetattr(STDIN_FILENO, &g_saved_tio) == 0) { + struct termios raw = g_saved_tio; + cfmakeraw(&raw); + if (tcsetattr(STDIN_FILENO, TCSANOW, &raw) == 0) { + g_tio_saved = 1; + raw_on = 1; + } + } + } else if (cev == DNET_CTERM_EV_WRITE) { + if (term.last.datalen) { + ssize_t w = write(STDOUT_FILENO, term.last.data, + term.last.datalen); + (void)w; + } + } else if (cev == DNET_CTERM_EV_UNBOUND) { + log_ts(stdout); + printf(" DECNETD-I-UNBOUND, host released the terminal" + " session on circuit %s\n", eng->circuit); + fflush(stdout); + done = 1; + } + break; + } + case DNET_LINK_EV_DISCONNECT: + case DNET_LINK_EV_DISCONNECT_CONF: + log_ts(stdout); + printf(" DECNETD-I-LINKDOWN, logical link closed on circuit %s\n", + eng->circuit); + fflush(stdout); + eng->link_active = 0; + done = 1; + break; + default: + break; + } + } + + if (nfd > 1 && (pfd[1].revents & (POLLIN | POLLHUP))) { + uint8_t inbuf[DNET_CTERM_MAX_DATA]; + ssize_t rn = read(STDIN_FILENO, inbuf, sizeof(inbuf)); + if (rn > 0) { + /* Real keystrokes -> CTERM Read Data (terminator CR). */ + if (dnet_cterm_read_data(&term, inbuf, (size_t)rn, 0x0d, + cpdu, sizeof(cpdu), &clen) == 0) + cterm_link_send(eng, sock, ifindex, cpdu, clen, now); + } else { + /* Local EOF: stop soliciting input but KEEP the link open so the + * host's remaining output drains. The session ends on the host's + * Unbind, a link drop, or --duration. */ + stdin_eof = 1; + log_ts(stdout); + printf(" DECNETD-I-EOF, local input closed -- draining remote" + " output on circuit %s\n", eng->circuit); + fflush(stdout); + } + } + } + + if (raw_on) + restore_local_tty(); + + /* On our way out, release the session + link cleanly if still up. */ + if (dnet_cterm_is_bound(&term)) { + if (dnet_cterm_unbind(&term, DNET_CTERM_UNBIND_NORMAL, + cpdu, sizeof(cpdu), &clen) == 0) + cterm_link_send(eng, sock, ifindex, cpdu, clen, monotonic_sec()); + } + if (eng->link_active && + dnet_link_state_of(&eng->link) != DNET_LINK_CLOSED) { + size_t dl = 0; + if (dnet_engine_link_close(eng, DNET_LINK_REASON_NORMAL, frame, + sizeof(frame), &dl, monotonic_sec()) + == DNET_ENGINE_OK) { + uint8_t dst[DNET_ADDR_LEN]; + memcpy(dst, frame, DNET_ADDR_LEN); + scs_datalink_send(sock, (int)ifindex, DNET_ETHERTYPE, dst, frame, dl); + } + } + log_ts(stdout); + printf(" DECNETD-I-SETHOSTEND, SET HOST session ended: cterm writes_recv=%lu" + " reads_sent=%lu on circuit %s\n", + term.writes_recv, term.reads_sent, eng->circuit); + fflush(stdout); + return rc; +} + +/* Split a login-command string into an argv (in place; whitespace-separated, no + * quoting). Returns the token count. */ +static int tokenize_cmd(char *s, char **argv, int max) +{ + int n = 0; + char *p = s; + while (*p && n < max - 1) { + while (*p == ' ' || *p == '\t') + p++; + if (!*p) + break; + argv[n++] = p; + while (*p && *p != ' ' && *p != '\t') + p++; + if (*p) + *p++ = '\0'; + } + argv[n] = NULL; + return n; +} + +/* + * --cterm-server : the CTERM HOST. Accepts an inbound logical link to the CTERM + * object (42), spawns a REAL PTY + login-command, and bridges the pty to the + * CTERM read/write/OOB messages -- real program output -> CTERM Write, inbound + * CTERM Read Data -> the pty master. It poll()s the pty master AND the datalink + * once the child is up. The login-command defaults to `vmsdcl --login` (the + * booted-env target); the hermetic dev-test overrides it with a stand-in + * interactive program to prove the PTY<->CTERM pump end-to-end. + */ +static int run_cterm_server_loop(struct dnet_engine *eng, int sock, + unsigned ifindex, const char *login_command) +{ + char cmdbuf[512]; + char *cargv[32]; + snprintf(cmdbuf, sizeof(cmdbuf), "%s", login_command); + if (tokenize_cmd(cmdbuf, cargv, (int)(sizeof(cargv) / sizeof(cargv[0]))) < 1) { + fprintf(stderr, "DECNETD-E-NOCMD, --login-command is empty\n"); + return 1; + } + + struct dnet_cterm_session host; + if (dnet_cterm_session_init(&host, DNET_CTERM_ROLE_HOST) != 0) { + fprintf(stderr, "DECNETD-E-CTERMINIT, host session init failed\n"); + return 1; + } + + log_ts(stdout); + printf(" DECNETD-I-CTERMSRV, awaiting an inbound $ SET HOST to CTERM object" + " %d on circuit %s (login-command: %s)\n", + DNET_CTERM_OBJECT, eng->circuit, login_command); + fflush(stdout); + + uint8_t rxbuf[DNET_FRAME_MAX], cpdu[DNET_CTERM_MAX_PDU]; + size_t clen = 0; + int master_fd = -1; + pid_t child = -1; + int done = 0, rc = 0; + uint16_t pty_cols = 80, pty_rows = 24; /* until CHARACTERISTICS arrive */ + + while (!g_stop && !done) { + dnet_tick_t now = monotonic_sec(); + dnet_periodic(eng, sock, ifindex, now); + + /* Only drain the pty once the CTERM session is BOUND: a login-command + * prints its first prompt the instant it is spawned (before the client's + * Bind arrives), and CTERM Write is a BOUND-only operation. The kernel + * pty buffer holds that early output until we are allowed to ship it, so + * nothing the program wrote is lost. */ + int poll_master = (master_fd >= 0 && dnet_cterm_is_bound(&host)); + struct pollfd pfd[2]; + int nfd = 0; + pfd[nfd].fd = sock; pfd[nfd].events = POLLIN; pfd[nfd].revents = 0; nfd++; + if (poll_master) { + pfd[nfd].fd = master_fd; pfd[nfd].events = POLLIN; pfd[nfd].revents = 0; nfd++; + } + int pr = poll(pfd, (nfds_t)nfd, 1000); + if (pr < 0) { + if (errno == EINTR) + continue; + fprintf(stderr, "DECNETD-E-POLL, poll failed: %s\n", strerror(errno)); + rc = 1; + break; + } + + /* Reap the child if it exited; the pty EOF below finalises the session. */ + if (child > 0 && waitpid(child, NULL, WNOHANG) == child) + child = -1; + + if (pr > 0 && (pfd[0].revents & POLLIN)) { + int ev = dnet_recv_route(eng, sock, ifindex, now, rxbuf, sizeof(rxbuf)); + if (ev < 0) { + fprintf(stderr, "DECNETD-E-RECVFAIL, recv failed: %s\n", + strerror(errno)); + rc = 1; + break; + } + switch (ev) { + case DNET_LINK_EV_CONNECT_IND: { + char pbuf[8]; + uint16_t pn = eng->link.remote_node; + int obj = dnet_cterm_sc_connect_object(eng->link.conn_data, + eng->link.conn_len); + log_ts(stdout); + printf(" DECNETD-I-CONNIN, inbound Connect Initiate from %s to" + " object %d on circuit %s\n", + dnet_addr_str(pn, pbuf, sizeof(pbuf)), obj, eng->circuit); + fflush(stdout); + if (obj != DNET_CTERM_OBJECT || master_fd >= 0) { + /* Not the CTERM object (or we already have a session): + * do not accept -- one active link per instance (scope). */ + break; + } + /* Accept the link (CC), then spawn the PTY + login-command. */ + uint8_t frame[DNET_FRAME_MAX]; + size_t alen = 0; + if (dnet_engine_link_accept(eng, 0x2002, frame, sizeof(frame), + &alen, now) != DNET_ENGINE_OK) + break; + { + uint8_t dst[DNET_ADDR_LEN]; + memcpy(dst, frame, DNET_ADDR_LEN); + scs_datalink_send(sock, (int)ifindex, DNET_ETHERTYPE, dst, frame, alen); + } + struct winsize ws; + memset(&ws, 0, sizeof(ws)); + ws.ws_col = pty_cols; + ws.ws_row = pty_rows; + int slave_fd = -1; + if (openpty(&master_fd, &slave_fd, NULL, NULL, &ws) < 0) { + fprintf(stderr, "DECNETD-E-PTYOPEN, openpty failed: %s\n", + strerror(errno)); + master_fd = -1; + break; + } + child = fork(); + if (child < 0) { + fprintf(stderr, "DECNETD-E-FORK, fork failed: %s\n", + strerror(errno)); + close(master_fd); close(slave_fd); + master_fd = -1; + break; + } + if (child == 0) { + /* Child: the PTY slave becomes the controlling terminal and + * stdio; then exec the real login-command. */ + close(master_fd); + close(sock); + if (setsid() < 0) + _exit(127); + if (ioctl(slave_fd, TIOCSCTTY, 0) < 0) { + /* non-fatal on some kernels */ + } + dup2(slave_fd, STDIN_FILENO); + dup2(slave_fd, STDOUT_FILENO); + dup2(slave_fd, STDERR_FILENO); + if (slave_fd > STDERR_FILENO) + close(slave_fd); + execvp(cargv[0], cargv); + _exit(127); /* exec failed */ + } + /* Parent keeps the master; drop the slave. */ + close(slave_fd); + log_ts(stdout); + printf(" DECNETD-I-LINKUP, logical link with %s is RUN --" + " login-command spawned (pid %ld) on circuit %s\n", + dnet_addr_str(pn, pbuf, sizeof(pbuf)), + (long)child, eng->circuit); + fflush(stdout); + break; + } + case DNET_LINK_EV_DATA: { + enum dnet_cterm_event cev = DNET_CTERM_EV_NONE; + if (dnet_cterm_rx(&host, eng->rx_data, eng->rx_datalen, &cev) + != DNET_CTERM_OK) + break; + if (cev == DNET_CTERM_EV_BIND_IND) { + if (dnet_cterm_bind_accept(&host, "OVMX", cpdu, sizeof(cpdu), + &clen) == 0) + cterm_link_send(eng, sock, ifindex, cpdu, clen, now); + log_ts(stdout); + printf(" DECNETD-I-BOUND, CTERM session bound (terminal %s)" + " on circuit %s\n", host.peer_name, eng->circuit); + fflush(stdout); + } else if (cev == DNET_CTERM_EV_CHARACTERISTICS) { + pty_cols = host.width ? host.width : pty_cols; + pty_rows = host.page ? host.page : pty_rows; + if (master_fd >= 0) { + struct winsize ws; + memset(&ws, 0, sizeof(ws)); + ws.ws_col = pty_cols; + ws.ws_row = pty_rows; + ioctl(master_fd, TIOCSWINSZ, &ws); + } + } else if (cev == DNET_CTERM_EV_READ_DATA) { + if (master_fd >= 0 && host.last.datalen) { + ssize_t w = write(master_fd, host.last.data, + host.last.datalen); + (void)w; + } + } else if (cev == DNET_CTERM_EV_OOB) { + if (master_fd >= 0) { + uint8_t oc = host.last.oob_char; + ssize_t w = write(master_fd, &oc, 1); + (void)w; + } + } else if (cev == DNET_CTERM_EV_UNBOUND) { + done = 1; + } + break; + } + case DNET_LINK_EV_DISCONNECT: + case DNET_LINK_EV_DISCONNECT_CONF: + log_ts(stdout); + printf(" DECNETD-I-LINKDOWN, peer disconnected on circuit %s\n", + eng->circuit); + fflush(stdout); + eng->link_active = 0; + done = 1; + break; + default: + break; + } + } + + /* PTY master readable -> real program output -> CTERM Write. */ + if (poll_master && nfd > 1 && + (pfd[1].revents & (POLLIN | POLLHUP | POLLERR))) { + uint8_t obuf[DNET_CTERM_MAX_DATA]; + ssize_t on = read(master_fd, obuf, sizeof(obuf)); + if (on > 0) { + if (dnet_cterm_write(&host, obuf, (size_t)on, + DNET_CTERM_WR_NOFORMAT, + cpdu, sizeof(cpdu), &clen) == 0) + cterm_link_send(eng, sock, ifindex, cpdu, clen, now); + } else { + /* pty EOF: the login-command exited. Unbind + tear the link. */ + log_ts(stdout); + printf(" DECNETD-I-CHILDEXIT, login-command exited -- unbinding" + " the terminal session on circuit %s\n", eng->circuit); + fflush(stdout); + if (dnet_cterm_is_bound(&host) && + dnet_cterm_unbind(&host, DNET_CTERM_UNBIND_NORMAL, + cpdu, sizeof(cpdu), &clen) == 0) + cterm_link_send(eng, sock, ifindex, cpdu, clen, now); + close(master_fd); + master_fd = -1; + size_t dl = 0; + uint8_t frame[DNET_FRAME_MAX]; + if (eng->link_active && + dnet_engine_link_close(eng, DNET_LINK_REASON_NORMAL, frame, + sizeof(frame), &dl, now) == DNET_ENGINE_OK) { + uint8_t dst[DNET_ADDR_LEN]; + memcpy(dst, frame, DNET_ADDR_LEN); + scs_datalink_send(sock, (int)ifindex, DNET_ETHERTYPE, dst, frame, dl); + } + done = 1; + } + } + } + + if (master_fd >= 0) + close(master_fd); + if (child > 0) { + kill(child, SIGHUP); + waitpid(child, NULL, 0); + } + log_ts(stdout); + printf(" DECNETD-I-CTERMSRVEND, CTERM host session ended: writes_sent=%lu" + " reads_recv=%lu on circuit %s\n", + host.writes_sent, host.reads_recv, eng->circuit); + fflush(stdout); + return rc; +} + static void usage(const char *argv0) { fprintf(stderr, @@ -553,7 +1175,27 @@ static void usage(const char *argv0) " and exit (no CAP_NET_RAW -- two engines carry a\n" " whole terminal session: Bind, characteristics,\n" " screen output, keystrokes, out-of-band, Unbind,\n" - " over a socketpair; every payload byte-identical)\n", + " over a socketpair; every payload byte-identical)\n" + " --connect A.N CLIENT: open an NSP logical link to A.N over the\n" + " LIVE datalink (Connect Initiate; needs CAP_NET_RAW)\n" + " --connect-data STR with --connect: send STR as one data segment once\n" + " the link is RUN, then disconnect cleanly\n" + " --listen SERVER: accept an inbound Connect Initiate on the\n" + " live datalink and deliver its data segments\n" + " --set-host A.N $ SET HOST client: open a CTERM terminal session to\n" + " A.N's CTERM object over the LIVE datalink and bridge\n" + " the LOCAL terminal to it (real stdin/stdout; needs\n" + " CAP_NET_RAW)\n" + " --user NAME with --set-host: CTERM access-control username\n" + " (SET HOST/USERNAME= analog; default SYSTEM; never\n" + " read from $USER -- vms-cb5 identity census)\n" + " --cterm-server CTERM HOST: accept an inbound $ SET HOST, spawn a\n" + " real PTY + login-command, and bridge it to the CTERM\n" + " read/write messages (needs CAP_NET_RAW)\n" + " --login-command CMD with --cterm-server: the interactive program to run\n" + " as the session (default \"vmsdcl --login\"; the dev\n" + " bracket overrides it with a stand-in program)\n" + " --object N task/object number (logged; routing is a later rung)\n", argv0, DECNETD_DEFAULT_IFACE, (unsigned)DNET_T3_DEFAULT); } @@ -570,6 +1212,14 @@ int main(int argc, char **argv) int self_test = 0; int nsp_self_test = 0; int sethost_self_test = 0; + const char *connect_to = NULL; /* --connect A.N : open a logical link */ + const char *connect_data = NULL; /* --connect-data STR : one data segment */ + int listen_mode = 0; /* --listen : accept an inbound link */ + int object_num = 0; /* --object N : task/object number (log) */ + const char *set_host_to = NULL; /* --set-host A.N : CTERM terminal client */ + const char *set_host_user = "SYSTEM"; /* --user : CTERM access-control name */ + int cterm_server = 0; /* --cterm-server : CTERM host w/ real PTY */ + const char *login_command = "vmsdcl --login"; /* --login-command CMD */ for (int i = 1; i < argc; i++) { if (!strcmp(argv[i], "--address") && i + 1 < argc) addr_s = argv[++i]; @@ -585,6 +1235,14 @@ int main(int argc, char **argv) else if (!strcmp(argv[i], "--self-test")) self_test = 1; else if (!strcmp(argv[i], "--nsp-selftest")) nsp_self_test = 1; else if (!strcmp(argv[i], "--set-host-selftest")) sethost_self_test = 1; + else if (!strcmp(argv[i], "--connect") && i + 1 < argc) connect_to = argv[++i]; + else if (!strcmp(argv[i], "--connect-data") && i + 1 < argc) connect_data = argv[++i]; + else if (!strcmp(argv[i], "--listen")) listen_mode = 1; + else if (!strcmp(argv[i], "--set-host") && i + 1 < argc) set_host_to = argv[++i]; + else if (!strcmp(argv[i], "--user") && i + 1 < argc) set_host_user = argv[++i]; + else if (!strcmp(argv[i], "--cterm-server")) cterm_server = 1; + else if (!strcmp(argv[i], "--login-command") && i + 1 < argc) login_command = argv[++i]; + else if (!strcmp(argv[i], "--object") && i + 1 < argc) object_num = atoi(argv[++i]); else if (!strcmp(argv[i], "--help") || !strcmp(argv[i], "-h")) { usage(argv[0]); return 0; @@ -665,6 +1323,18 @@ int main(int argc, char **argv) scs_datalink_close(sock); return 1; } + /* Receive unicast NSP frames addressed to our DECnet MAC. A faithful node + * owning its NIC would program that MAC onto the interface; on a shared + * bridge OVMX does not own, we go promiscuous and dst-filter in software + * (see the loop). Best-effort: HELLO/adjacency still works multicast if + * this is refused, so a failure is a warning, not fatal. */ + if (scs_datalink_set_promisc(sock, ifname) != 0) { + log_ts(stdout); + printf(" DECNETD-W-NOPROMISC, could not enable promiscuous reception on" + " %s (%s) -- inbound unicast NSP may not be received\n", + ifname, strerror(errno)); + fflush(stdout); + } struct dnet_engine eng; if (dnet_engine_init(&eng, area, node, name, device, circuit, hw_mac, @@ -689,9 +1359,70 @@ int main(int argc, char **argv) dnet_engine_show_circuit(&eng, stdout); fflush(stdout); + /* --- $ SET HOST / CTERM interactive session over the LIVE datalink ------- + * (rd vms-aac0 live bracket, child of vms-30e / vms-4d2). These modes carry + * a REAL PTY + login-command (host) and real stdin/stdout (client), poll()ing + * both the datalink and the aux fd. They own their own loop and do not fall + * through to the routing/NSP-data loop below. */ + if (set_host_to) { + int r = run_set_host_loop(&eng, sock, ifindex, set_host_to, set_host_user); + scs_datalink_close(sock); + return r; + } + if (cterm_server) { + int r = run_cterm_server_loop(&eng, sock, ifindex, login_command); + scs_datalink_close(sock); + return r; + } + uint8_t frame[DNET_FRAME_MAX]; uint8_t rxbuf[DNET_FRAME_MAX]; + /* --- NSP logical-link mode over the LIVE datalink (rd vms-c23) ---------- + * The socketpair selftest proves the FSM in isolation; here the SAME FSM is + * driven with every PDU crossing the real AF_PACKET datalink. Client + * (--connect A.N) opens a link, optionally sends one data segment, then + * disconnects; server (--listen) accepts an inbound Connect Initiate, + * delivers data, and mirrors the teardown. */ + enum { CL_NONE, CL_CONNECTING, CL_UP, CL_SENT, CL_CLOSING } cl = CL_NONE; + unsigned peer_area = 0, peer_node = 0; + if (connect_to) { + if (parse_addr(connect_to, &peer_area, &peer_node) != 0) { + fprintf(stderr, "DECNETD-E-BADPEER, --connect wants AREA.NODE" + " (1..63 . 1..1023)\n"); + scs_datalink_close(sock); + return 1; + } + size_t flen = 0; + const uint8_t *cd = connect_data ? (const uint8_t *)connect_data : NULL; + size_t cdl = connect_data ? strlen(connect_data) : 0; + if (dnet_engine_link_open(&eng, peer_area, peer_node, 0x2001, cd, cdl, + 1459, 1, DNET_NSP_VER_41, + frame, sizeof(frame), &flen, monotonic_sec()) + == DNET_ENGINE_OK) { + uint8_t peer_id[DNET_ADDR_LEN]; + dnet_id_from_addr(peer_area, peer_node, peer_id); + scs_datalink_send(sock, (int)ifindex, DNET_ETHERTYPE, peer_id, frame, flen); + cl = CL_CONNECTING; + log_ts(stdout); + printf(" DECNETD-I-CONNECTING, Connect Initiate sent to %u.%u" + " (object %d) on circuit %s\n", + peer_area, peer_node, object_num, eng.circuit); + fflush(stdout); + } else { + fprintf(stderr, "DECNETD-E-NOCONNECT, could not open a logical link" + " to %u.%u\n", peer_area, peer_node); + scs_datalink_close(sock); + return 1; + } + } + if (listen_mode) { + log_ts(stdout); + printf(" DECNETD-I-LISTEN, awaiting an inbound Connect Initiate" + " (object %d) on circuit %s\n", object_num, eng.circuit); + fflush(stdout); + } + while (!g_stop) { dnet_tick_t now = monotonic_sec(); @@ -725,6 +1456,32 @@ int main(int argc, char **argv) fflush(stdout); } + /* NSP: drive the active link's Connect-Initiate retransmit / give-up. */ + if (eng.link_active) { + size_t tlen = 0; + int thas = 0; + if (dnet_engine_link_tick(&eng, now, frame, sizeof(frame), &tlen, &thas) + == DNET_ENGINE_OK && thas) { + uint8_t dst[DNET_ADDR_LEN]; + memcpy(dst, frame, DNET_ADDR_LEN); + scs_datalink_send(sock, (int)ifindex, DNET_ETHERTYPE, dst, frame, tlen); + log_ts(stdout); + printf(" DECNETD-I-CIRETRANS, Connect Initiate retransmitted on" + " circuit %s\n", eng.circuit); + fflush(stdout); + } + /* CI give-up (max retransmits) closes the link as unreachable. */ + if (cl == CL_CONNECTING && dnet_link_state_of(&eng.link) == DNET_LINK_CLOSED) { + log_ts(stdout); + printf(" DECNETD-W-UNREACH, peer %u.%u did not answer Connect" + " Initiate -- link abandoned\n", peer_area, peer_node); + fflush(stdout); + eng.link_active = 0; + cl = CL_NONE; + g_stop = 1; + } + } + ssize_t n = scs_datalink_recv(sock, rxbuf, sizeof(rxbuf)); if (n < 0) { if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK) @@ -732,6 +1489,134 @@ int main(int argc, char **argv) fprintf(stderr, "DECNETD-E-RECVFAIL, recv failed: %s\n", strerror(errno)); break; } + /* Drop our own echo (an AF_PACKET SOCK_RAW socket sees frames it sent). */ + if ((size_t)n >= DNET_ETH_HDRLEN && + memcmp(rxbuf + 6, eng.my_id, DNET_ADDR_LEN) == 0) + continue; + + /* Demux by the routing flag: a long-data frame carries an NSP PDU for + * the logical-link service; everything else goes to the HELLO/adjacency + * path. */ + int is_nsp = ((size_t)n > (size_t)DNET_ETH_HDRLEN + DNET_DATA_LENPREFIX) && + rxbuf[DNET_ETH_HDRLEN + DNET_DATA_LENPREFIX] == DNET_RFLAG_LONG_DATA; + if (is_nsp) { + /* Promiscuous mode also surfaces unicast NSP addressed to OTHER + * nodes -- process only frames whose Ethernet destination is our + * own DECnet id (a real MAC-owning node's NIC would filter this). */ + if (memcmp(rxbuf, eng.my_id, DNET_ADDR_LEN) != 0) + continue; + size_t rlen = 0; + int has_reply = 0; + enum dnet_link_event ev = DNET_LINK_EV_NONE; + if (dnet_engine_link_rx(&eng, now, rxbuf, (size_t)n, frame, sizeof(frame), + &rlen, &has_reply, &ev) != DNET_ENGINE_OK) + continue; + if (has_reply) { /* data ACK or Disconnect Confirm */ + uint8_t dst[DNET_ADDR_LEN]; + memcpy(dst, frame, DNET_ADDR_LEN); + scs_datalink_send(sock, (int)ifindex, DNET_ETHERTYPE, dst, frame, rlen); + } + char pbuf[8]; + uint16_t pn = eng.link.remote_node; + switch (ev) { + case DNET_LINK_EV_CONNECT_IND: + log_ts(stdout); + printf(" DECNETD-I-CONNIN, inbound Connect Initiate from %s on" + " circuit %s%s\n", dnet_addr_str(pn, pbuf, sizeof(pbuf)), + eng.circuit, listen_mode ? " -- accepting" : " -- no listener, ignoring"); + fflush(stdout); + if (listen_mode) { + size_t clen = 0; + if (dnet_engine_link_accept(&eng, 0x2002, frame, sizeof(frame), + &clen, now) == DNET_ENGINE_OK) { + uint8_t dst[DNET_ADDR_LEN]; + memcpy(dst, frame, DNET_ADDR_LEN); + scs_datalink_send(sock, (int)ifindex, DNET_ETHERTYPE, dst, frame, clen); + log_ts(stdout); + printf(" DECNETD-I-LINKUP, logical link with %s is RUN on" + " circuit %s\n", dnet_addr_str(pn, pbuf, sizeof(pbuf)), + eng.circuit); + fflush(stdout); + } + } + break; + case DNET_LINK_EV_CONNECT_CONF: + cl = CL_UP; + log_ts(stdout); + printf(" DECNETD-I-LINKUP, logical link to %u.%u is RUN on circuit %s\n", + peer_area, peer_node, eng.circuit); + fflush(stdout); + if (connect_data) { /* client: send one data segment */ + size_t sl = 0; + if (dnet_engine_link_send(&eng, (const uint8_t *)connect_data, + strlen(connect_data), frame, sizeof(frame), + &sl, now) == DNET_ENGINE_OK) { + uint8_t dst[DNET_ADDR_LEN]; + memcpy(dst, frame, DNET_ADDR_LEN); + scs_datalink_send(sock, (int)ifindex, DNET_ETHERTYPE, dst, frame, sl); + cl = CL_SENT; + log_ts(stdout); + printf(" DECNETD-I-DATATX, sent %zu byte(s) on circuit %s\n", + strlen(connect_data), eng.circuit); + fflush(stdout); + } + } else { /* client: nothing to send, close */ + size_t dl = 0; + if (dnet_engine_link_close(&eng, DNET_LINK_REASON_NORMAL, frame, + sizeof(frame), &dl, now) == DNET_ENGINE_OK) { + uint8_t dst[DNET_ADDR_LEN]; + memcpy(dst, frame, DNET_ADDR_LEN); + scs_datalink_send(sock, (int)ifindex, DNET_ETHERTYPE, dst, frame, dl); + cl = CL_CLOSING; + } + } + break; + case DNET_LINK_EV_DATA: + log_ts(stdout); + printf(" DECNETD-I-DATARX, %u byte(s) delivered on circuit %s: \"%.*s\"\n", + (unsigned)eng.rx_datalen, eng.circuit, + (int)eng.rx_datalen, (const char *)eng.rx_data); + fflush(stdout); + break; + case DNET_LINK_EV_ACK: + if (cl == CL_SENT) { /* client: data acked -> disconnect */ + size_t dl = 0; + if (dnet_engine_link_close(&eng, DNET_LINK_REASON_NORMAL, frame, + sizeof(frame), &dl, now) == DNET_ENGINE_OK) { + uint8_t dst[DNET_ADDR_LEN]; + memcpy(dst, frame, DNET_ADDR_LEN); + scs_datalink_send(sock, (int)ifindex, DNET_ETHERTYPE, dst, frame, dl); + cl = CL_CLOSING; + log_ts(stdout); + printf(" DECNETD-I-DISCONN, data acknowledged -- disconnecting\n"); + fflush(stdout); + } + } + break; + case DNET_LINK_EV_DISCONNECT: + log_ts(stdout); + printf(" DECNETD-I-LINKDOWN, peer disconnected on circuit %s\n", eng.circuit); + fflush(stdout); + eng.link_active = 0; + if (connect_to) /* the client run is complete */ + g_stop = 1; + break; + case DNET_LINK_EV_DISCONNECT_CONF: + log_ts(stdout); + printf(" DECNETD-I-LINKDOWN, disconnect confirmed -- link closed on" + " circuit %s\n", eng.circuit); + fflush(stdout); + eng.link_active = 0; + cl = CL_NONE; + if (connect_to) /* the client run is complete */ + g_stop = 1; + break; + default: + break; + } + continue; + } + char afrom[8]; uint8_t from[6]; enum dnet_adj_state st = DNET_ADJ_DOWN; @@ -757,9 +1642,10 @@ int main(int argc, char **argv) printf(" DECNETD-I-STOPPING, shutting down circuit %s\n", eng.circuit); dnet_engine_show_adjacent(&eng, stdout); printf("DECNETD-I-COUNTERS, hello_sent=%lu hello_recv=%lu frames_recv=%lu" - " frames_dropped=%lu adj_up=%lu adj_down=%lu\n", + " frames_dropped=%lu adj_up=%lu adj_down=%lu nsp_recv=%lu nsp_dropped=%lu\n", eng.hello_sent, eng.hello_recv, eng.frames_recv, eng.frames_dropped, - eng.adj_up_events, eng.adj_down_events); + eng.adj_up_events, eng.adj_down_events, + eng.nsp_frames_recv, eng.nsp_frames_dropped); fflush(stdout); scs_datalink_close(sock); diff --git a/src/vmsdecnet/engine/dnet_engine.c b/src/vmsdecnet/engine/dnet_engine.c index 9be18d4d..985a34fa 100644 --- a/src/vmsdecnet/engine/dnet_engine.c +++ b/src/vmsdecnet/engine/dnet_engine.c @@ -414,6 +414,32 @@ int dnet_engine_link_close(struct dnet_engine *e, uint16_t reason, return engine_wrap_pdu(e, &di, frame_out, cap, len_out); } +/* Advance the active link's timers (Connect Initiate retransmit / give-up). If + * the FSM produces a PDU to (re)send, wrap it into a full frame to the peer -- + * the retransmitted CI is the FSM's own, not a template copy (executive-backed + * per the project invariant: every wire field is read from link state). */ +int dnet_engine_link_tick(struct dnet_engine *e, dnet_tick_t now, + uint8_t *frame_out, size_t cap, size_t *len_out, + int *has_out) +{ + if (has_out) + *has_out = 0; + if (!e || !frame_out || !e->link_active) + return DNET_ENGINE_EINVAL; + struct dnet_nsp_msg out; + int ho = 0; + if (dnet_link_tick(&e->link, now, &out, &ho) != DNET_LINK_OK) + return DNET_ENGINE_EINVAL; + if (ho) { + int rc = engine_wrap_pdu(e, &out, frame_out, cap, len_out); + if (rc != DNET_ENGINE_OK) + return rc; + if (has_out) + *has_out = 1; + } + return DNET_ENGINE_OK; +} + int dnet_engine_link_rx(struct dnet_engine *e, dnet_tick_t now, const uint8_t *frame, size_t len, uint8_t *reply_frame, size_t cap, size_t *reply_len, diff --git a/src/vmsdecnet/engine/include/dnet_engine.h b/src/vmsdecnet/engine/include/dnet_engine.h index 8494bf1a..01f175d2 100644 --- a/src/vmsdecnet/engine/include/dnet_engine.h +++ b/src/vmsdecnet/engine/include/dnet_engine.h @@ -283,6 +283,16 @@ int dnet_engine_link_close(struct dnet_engine *e, uint16_t reason, uint8_t *frame_out, size_t cap, size_t *len_out, dnet_tick_t now); +/* + * dnet_engine_link_tick - advance the active link's timers at time `now` + * (Connect Initiate retransmission and its give-up budget). If the FSM decides + * to (re)transmit, builds the frame into frame_out and sets *has_out = 1. The + * PDU is the FSM's own, never a template copy. No active link -> EINVAL. + */ +int dnet_engine_link_tick(struct dnet_engine *e, dnet_tick_t now, + uint8_t *frame_out, size_t cap, size_t *len_out, + int *has_out); + /* * dnet_engine_link_rx - consume a received full NSP data frame at time `now`: * parse the routing header, decode the NSP PDU, and drive the embedded link diff --git a/src/vmsdecnet/ncp/CMakeLists.txt b/src/vmsdecnet/ncp/CMakeLists.txt new file mode 100644 index 00000000..7f4c335f --- /dev/null +++ b/src/vmsdecnet/ncp/CMakeLists.txt @@ -0,0 +1,20 @@ +# vmsdecnet NCP layer (rd vms-1e9, epic vms-30e): the DECnet Phase IV +# CONFIGURATION surface -- the "configuration" pillar of the layered product. +# +# dnet_nodedb: the persisted remote-node database (the NCP node table) + +# name<->address resolution the SET HOST / NODE:: paths need. Pure table logic +# + a documented plain-text on-disk format; no socket, no engine, unit-testable +# in isolation. The NCP command grammar + SHOW layout are public (DECnet for +# OpenVMS Networking Manual); the on-disk format is an OVMX choice (labelled). +add_library(vmsdecnet_nodedb STATIC dnet_nodedb.c) +target_include_directories(vmsdecnet_nodedb PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/include + ${CMAKE_SOURCE_DIR}/src/vmsdecnet/routing/include # dnet_hello.h: area/node helpers +) + +# NCP.EXE -- the Network Control Program utility that drives the database + +# executor config (SET/DEFINE/SHOW/CLEAR NODE, SET/SHOW EXECUTOR). +add_executable(ncp_exe ncp.c) +target_link_libraries(ncp_exe PRIVATE vmsdecnet_nodedb) +set_target_properties(ncp_exe PROPERTIES OUTPUT_NAME "NCP" SUFFIX ".EXE") +install(TARGETS ncp_exe RUNTIME DESTINATION ${VMS_SYSTEM_DIR}) diff --git a/src/vmsdecnet/ncp/dnet_nodedb.c b/src/vmsdecnet/ncp/dnet_nodedb.c new file mode 100644 index 00000000..2f5e753b --- /dev/null +++ b/src/vmsdecnet/ncp/dnet_nodedb.c @@ -0,0 +1,259 @@ +/* + * dnet_nodedb.c - DECnet Phase IV node database (see dnet_nodedb.h). + * + * Pure table logic + a documented plain-text on-disk format. No engine, no + * socket. NCP node names are case-insensitive and held uppercase (DNA/VMS). + */ +#include "dnet_nodedb.h" + +#include +#include +#include +#include + +int dnet_nodedb_parse_addr(const char *s, uint16_t *addr_out) +{ + if (!s || !addr_out) + return DNET_NODEDB_EINVAL; + char *end = NULL; + long area = strtol(s, &end, 10); + if (end == s || !end || *end != '.') + return DNET_NODEDB_EINVAL; + const char *nstr = end + 1; + char *end2 = NULL; + long node = strtol(nstr, &end2, 10); + if (end2 == nstr || !end2 || *end2 != '\0') + return DNET_NODEDB_EINVAL; + if (area < 1 || area > 63 || node < 1 || node > 1023) + return DNET_NODEDB_EINVAL; + *addr_out = (uint16_t)(((unsigned)area << 10) | (unsigned)node); + return DNET_NODEDB_OK; +} + +/* Uppercase-copy a node name (1..6 chars) into dst[NAMEMAX+1]. Empty name ok + * (address-only entry). Returns 0 ok, -1 if too long / bad char. */ +static int name_norm(const char *name, char dst[DNET_NODEDB_NAMEMAX + 1]) +{ + dst[0] = '\0'; + if (!name || name[0] == '\0') + return 0; + size_t n = strlen(name); + if (n > DNET_NODEDB_NAMEMAX) + return -1; + for (size_t i = 0; i < n; i++) { + unsigned char c = (unsigned char)name[i]; + if (!isalnum(c)) + return -1; /* DNA node names are alphanumeric */ + dst[i] = (char)toupper(c); + } + dst[n] = '\0'; + return 0; +} + +void dnet_nodedb_init(struct dnet_nodedb *db) +{ + if (!db) + return; + memset(db, 0, sizeof(*db)); +} + +static struct dnet_node_entry *find_addr(struct dnet_nodedb *db, uint16_t addr) +{ + for (unsigned i = 0; i < DNET_NODEDB_MAX; i++) + if (db->ent[i].in_use && db->ent[i].addr == addr) + return &db->ent[i]; + return NULL; +} + +static struct dnet_node_entry *find_name(struct dnet_nodedb *db, + const char name[DNET_NODEDB_NAMEMAX + 1]) +{ + if (name[0] == '\0') + return NULL; + for (unsigned i = 0; i < DNET_NODEDB_MAX; i++) + if (db->ent[i].in_use && strcmp(db->ent[i].name, name) == 0) + return &db->ent[i]; + return NULL; +} + +int dnet_nodedb_set(struct dnet_nodedb *db, uint16_t addr, const char *name) +{ + if (!db || addr == 0) + return DNET_NODEDB_EINVAL; + char norm[DNET_NODEDB_NAMEMAX + 1]; + if (name_norm(name, norm) != 0) + return DNET_NODEDB_EINVAL; + + /* A name must be unique: reject binding it to a second address. */ + struct dnet_node_entry *byname = find_name(db, norm); + if (byname && byname->addr != addr) + return DNET_NODEDB_EINVAL; + + struct dnet_node_entry *e = find_addr(db, addr); + if (e) { /* update the name of an existing node */ + memcpy(e->name, norm, sizeof(norm)); + return DNET_NODEDB_OK; + } + for (unsigned i = 0; i < DNET_NODEDB_MAX; i++) { + if (!db->ent[i].in_use) { + db->ent[i].in_use = 1; + db->ent[i].addr = addr; + memcpy(db->ent[i].name, norm, sizeof(norm)); + db->count++; + return DNET_NODEDB_OK; + } + } + return DNET_NODEDB_EFULL; +} + +int dnet_nodedb_clear_addr(struct dnet_nodedb *db, uint16_t addr) +{ + if (!db) + return DNET_NODEDB_EINVAL; + struct dnet_node_entry *e = find_addr(db, addr); + if (!e) + return DNET_NODEDB_ENOENT; + memset(e, 0, sizeof(*e)); + db->count--; + return DNET_NODEDB_OK; +} + +int dnet_nodedb_clear_name(struct dnet_nodedb *db, const char *name) +{ + if (!db) + return DNET_NODEDB_EINVAL; + char norm[DNET_NODEDB_NAMEMAX + 1]; + if (name_norm(name, norm) != 0 || norm[0] == '\0') + return DNET_NODEDB_EINVAL; + struct dnet_node_entry *e = find_name(db, norm); + if (!e) + return DNET_NODEDB_ENOENT; + memset(e, 0, sizeof(*e)); + db->count--; + return DNET_NODEDB_OK; +} + +const struct dnet_node_entry *dnet_nodedb_by_name(const struct dnet_nodedb *db, + const char *name) +{ + if (!db) + return NULL; + char norm[DNET_NODEDB_NAMEMAX + 1]; + if (name_norm(name, norm) != 0 || norm[0] == '\0') + return NULL; + return find_name((struct dnet_nodedb *)db, norm); +} + +const struct dnet_node_entry *dnet_nodedb_by_addr(const struct dnet_nodedb *db, + uint16_t addr) +{ + if (!db) + return NULL; + return find_addr((struct dnet_nodedb *)db, addr); +} + +const struct dnet_node_entry *dnet_nodedb_at(const struct dnet_nodedb *db, unsigned i) +{ + if (!db || i >= db->count) + return NULL; + /* Return the i-th in-use entry in ascending-address order (SHOW ordering). + * O(n^2) over a small fixed table; no state, no allocation. */ + uint16_t prev = 0; + int have_prev = 0; + for (unsigned rank = 0; ; rank++) { + const struct dnet_node_entry *best = NULL; + for (unsigned j = 0; j < DNET_NODEDB_MAX; j++) { + const struct dnet_node_entry *c = &db->ent[j]; + if (!c->in_use) + continue; + if (have_prev && (c->addr < prev || (c->addr == prev))) + continue; /* already emitted at-or-before prev */ + if (!best || c->addr < best->addr) + best = c; + } + if (!best) + return NULL; + if (rank == i) + return best; + prev = best->addr; + have_prev = 1; + } +} + +int dnet_nodedb_save(const struct dnet_nodedb *db, const char *path) +{ + if (!db || !path) + return DNET_NODEDB_EINVAL; + char tmp[1024]; + int m = snprintf(tmp, sizeof(tmp), "%s.tmp", path); + if (m <= 0 || (size_t)m >= sizeof(tmp)) + return DNET_NODEDB_EIO; + FILE *f = fopen(tmp, "w"); + if (!f) + return DNET_NODEDB_EIO; + fprintf(f, "# OVMX DECnet Phase IV node database (NCP DEFINE/SET NODE).\n"); + fprintf(f, "# Format: NODE . [NAME ] -- OVMX layout, not" + " VMS NETNODE_REMOTE.DAT.\n"); + for (unsigned i = 0; i < db->count; i++) { + const struct dnet_node_entry *e = dnet_nodedb_at(db, i); + if (!e) + break; + unsigned area = dnet_area_of(e->addr), node = dnet_node_of(e->addr); + if (e->name[0]) + fprintf(f, "NODE %u.%u NAME %s\n", area, node, e->name); + else + fprintf(f, "NODE %u.%u\n", area, node); + } + if (fflush(f) != 0 || ferror(f)) { + fclose(f); + remove(tmp); + return DNET_NODEDB_EIO; + } + fclose(f); + if (rename(tmp, path) != 0) { + remove(tmp); + return DNET_NODEDB_EIO; + } + return DNET_NODEDB_OK; +} + +int dnet_nodedb_load(struct dnet_nodedb *db, const char *path) +{ + if (!db || !path) + return DNET_NODEDB_EINVAL; + dnet_nodedb_init(db); + FILE *f = fopen(path, "r"); + if (!f) + return DNET_NODEDB_OK; /* absent DB == empty, not an error */ + char line[256]; + int rc = DNET_NODEDB_OK; + while (fgets(line, sizeof(line), f)) { + char *p = line; + while (*p == ' ' || *p == '\t') + p++; + if (*p == '#' || *p == '\n' || *p == '\0') + continue; + char kw[16], astr[32], namekw[16], name[64]; + int nf = sscanf(p, "%15s %31s %15s %63s", kw, astr, namekw, name); + if (nf < 2 || strcmp(kw, "NODE") != 0) { + rc = DNET_NODEDB_EIO; + break; + } + uint16_t addr = 0; + if (dnet_nodedb_parse_addr(astr, &addr) != DNET_NODEDB_OK) { + rc = DNET_NODEDB_EIO; + break; + } + const char *nm = ""; + if (nf >= 4 && strcmp(namekw, "NAME") == 0) + nm = name; + if (dnet_nodedb_set(db, addr, nm) != DNET_NODEDB_OK) { + rc = DNET_NODEDB_EIO; + break; + } + } + fclose(f); + if (rc != DNET_NODEDB_OK) + dnet_nodedb_init(db); + return rc; +} diff --git a/src/vmsdecnet/ncp/include/dnet_nodedb.h b/src/vmsdecnet/ncp/include/dnet_nodedb.h new file mode 100644 index 00000000..91846742 --- /dev/null +++ b/src/vmsdecnet/ncp/include/dnet_nodedb.h @@ -0,0 +1,85 @@ +/* + * dnet_nodedb.h - DECnet Phase IV node database (the NCP node table). + * + * The persisted "known nodes" table an NCP user builds with DEFINE/SET NODE and + * reads with SHOW KNOWN NODES / SHOW NODE, plus the name<->address resolution + * the SET HOST / NODE:: filespec paths need to turn a node NAME into the + * area.node address the routing/NSP layers address. Pure logic + a simple + * documented on-disk format; no socket, no engine, no allocation beyond the + * fixed table -- unit-testable in isolation (rd vms-1e9, epic vms-30e). + * + * PROVENANCE (Rule 8): the NCP command grammar and the SHOW output layout are + * public (VSI/DEC DECnet for OpenVMS Networking Manual, NCP chapter). The + * on-disk file format below is an OVMX implementation choice (a documented + * plain-text record per node), NOT a VMS-authentic NETNODE_REMOTE.DAT binary + * layout -- labelled as such, never presented as the VMS on-disk format. + */ +#ifndef DNET_NODEDB_H +#define DNET_NODEDB_H + +#include +#include + +#include "dnet_hello.h" /* dnet_area_of/dnet_node_of + DNET_ADDR_LEN */ + +#ifdef __cplusplus +extern "C" { +#endif + +#define DNET_NODEDB_NAMEMAX 6 /* NCP node name: 1..6 chars (DNA) */ +#define DNET_NODEDB_MAX 256 /* max remote nodes held (OVMX cap) */ + +/* Return codes (distinct namespace). */ +#define DNET_NODEDB_OK 0 +#define DNET_NODEDB_EINVAL (-1) /* null / bad argument */ +#define DNET_NODEDB_EFULL (-2) /* table full */ +#define DNET_NODEDB_ENOENT (-3) /* no such node */ +#define DNET_NODEDB_EIO (-4) /* file I/O / parse error */ + +struct dnet_node_entry { + uint16_t addr; /* DECnet address (area<<10 | node) */ + char name[DNET_NODEDB_NAMEMAX + 1]; /* NCP node name ("" if unnamed) */ + int in_use; +}; + +struct dnet_nodedb { + struct dnet_node_entry ent[DNET_NODEDB_MAX]; + unsigned count; +}; + +/* Parse "area.node" (1..63 . 1..1023) into a 16-bit DECnet address. Returns + * DNET_NODEDB_OK, or DNET_NODEDB_EINVAL on a malformed / out-of-range value. */ +int dnet_nodedb_parse_addr(const char *s, uint16_t *addr_out); + +void dnet_nodedb_init(struct dnet_nodedb *db); + +/* DEFINE/SET NODE: add the node, or update the name of an existing address. + * A name may be "" (address only). A name already bound to a DIFFERENT address + * is rejected (DNET_NODEDB_EINVAL) -- NCP node names are unique. */ +int dnet_nodedb_set(struct dnet_nodedb *db, uint16_t addr, const char *name); + +/* CLEAR/PURGE NODE by address or by name. ENOENT if absent. */ +int dnet_nodedb_clear_addr(struct dnet_nodedb *db, uint16_t addr); +int dnet_nodedb_clear_name(struct dnet_nodedb *db, const char *name); + +/* Resolution (what SET HOST / NODE:: need). NULL if not found. */ +const struct dnet_node_entry *dnet_nodedb_by_name(const struct dnet_nodedb *db, + const char *name); +const struct dnet_node_entry *dnet_nodedb_by_addr(const struct dnet_nodedb *db, + uint16_t addr); + +/* Ordered iteration for SHOW KNOWN NODES (by ascending address). Returns the + * i-th in-use entry (0-based) or NULL when i >= count. */ +const struct dnet_node_entry *dnet_nodedb_at(const struct dnet_nodedb *db, unsigned i); + +/* Persist / restore the whole table to a plain-text file (OVMX format). save + * writes atomically (temp + rename). load tolerates a missing file (empties the + * table, returns OK) but fails EIO on a corrupt one. */ +int dnet_nodedb_save(const struct dnet_nodedb *db, const char *path); +int dnet_nodedb_load(struct dnet_nodedb *db, const char *path); + +#ifdef __cplusplus +} +#endif + +#endif /* DNET_NODEDB_H */ diff --git a/src/vmsdecnet/ncp/ncp.c b/src/vmsdecnet/ncp/ncp.c new file mode 100644 index 00000000..d072f5dd --- /dev/null +++ b/src/vmsdecnet/ncp/ncp.c @@ -0,0 +1,253 @@ +/* + * ncp.c - NCP.EXE: the DECnet Phase IV Network Control Program (configuration). + * + * The VMS-authentic NCP surface a DECnet manager uses to configure the local + * node and the remote-node database: SET/DEFINE NODE, CLEAR/PURGE NODE, + * SHOW KNOWN NODES / SHOW NODE, and SET/SHOW EXECUTOR. It operates on a + * persisted node database (dnet_nodedb) and a small executor configuration + * file. This is the "configuration" pillar of the DECnet layered product + * (design-decnet-ovmx.md Phase 3; rd vms-1e9, epic vms-30e) -- the layer that + * gives node NAMES the SET HOST / NODE:: paths resolve to addresses. + * + * Invoked one command per call, as DCL drives it: `MCR NCP `. + * + * PROVENANCE (Rule 8): the NCP command grammar + SHOW layout are public (DECnet + * for OpenVMS Networking Manual, NCP chapter). HONEST SCOPE, not yet built: + * OVMX keeps ONE persisted database, so SET (volatile) and DEFINE (permanent) + * both act on it -- the volatile/permanent split, circuits, objects, lines, + * counters, and live reachability state are later rungs and are not faked here. + */ +#include +#include +#include +#include + +#include "dnet_nodedb.h" + +/* --- executor configuration (SET/SHOW EXECUTOR) -------------------------- */ +struct ncp_executor { + int have_addr; + uint16_t addr; + char name[DNET_NODEDB_NAMEMAX + 1]; + int state_on; +}; + +static const char *nodedb_path(void) +{ + const char *p = getenv("OVMX_DECNET_NODEDB"); + return (p && p[0]) ? p : "/etc/ovmx/decnet/netnode_remote.dat"; +} +static const char *executor_path(void) +{ + const char *p = getenv("OVMX_DECNET_EXECUTOR"); + return (p && p[0]) ? p : "/etc/ovmx/decnet/executor.dat"; +} + +static void exec_load(struct ncp_executor *x) +{ + memset(x, 0, sizeof(*x)); + FILE *f = fopen(executor_path(), "r"); + if (!f) + return; + char astr[32] = "", name[64] = "", st[16] = ""; + /* Format: "EXECUTOR NAME STATE " */ + if (fscanf(f, "EXECUTOR %31s NAME %63s STATE %15s", astr, name, st) == 3) { + uint16_t a = 0; + if (dnet_nodedb_parse_addr(astr, &a) == DNET_NODEDB_OK) { + x->have_addr = 1; + x->addr = a; + } + if (strcmp(name, "-") != 0) + snprintf(x->name, sizeof(x->name), "%.*s", DNET_NODEDB_NAMEMAX, name); + x->state_on = (strcasecmp(st, "on") == 0); + } + fclose(f); +} + +static int exec_save(const struct ncp_executor *x) +{ + FILE *f = fopen(executor_path(), "w"); + if (!f) + return DNET_NODEDB_EIO; + char astr[16] = "-"; + if (x->have_addr) + snprintf(astr, sizeof(astr), "%u.%u", dnet_area_of(x->addr), dnet_node_of(x->addr)); + fprintf(f, "EXECUTOR %s NAME %s STATE %s\n", + astr, x->name[0] ? x->name : "-", x->state_on ? "on" : "off"); + int ok = (fflush(f) == 0 && !ferror(f)); + fclose(f); + return ok ? DNET_NODEDB_OK : DNET_NODEDB_EIO; +} + +/* --- SHOW output (VMS-faithful shape) ------------------------------------ */ +static void show_executor(const struct ncp_executor *x, int characteristics) +{ + char astr[16] = "not configured"; + if (x->have_addr) + snprintf(astr, sizeof(astr), "%u.%u", dnet_area_of(x->addr), dnet_node_of(x->addr)); + printf("\nNode Volatile %s\n\n", characteristics ? "Characteristics" : "Summary"); + if (x->have_addr && x->name[0]) + printf("Executor node = %s (%s)\n", astr, x->name); + else + printf("Executor node = %s\n", astr); + printf("State = %s\n", x->state_on ? "on" : "off"); + if (characteristics) + printf("Identification = OVMX DECnet-compatible networking\n"); +} + +static void show_node(const struct dnet_node_entry *e) +{ + char addr[16]; + snprintf(addr, sizeof(addr), "%u.%u", dnet_area_of(e->addr), dnet_node_of(e->addr)); + printf("%-12s %s\n", addr, e->name[0] ? e->name : ""); +} + +static void show_known_nodes(const struct dnet_nodedb *db) +{ + printf("\nKnown Node Volatile Summary\n\n"); + printf("Node Name\n\n"); + for (unsigned i = 0; i < db->count; i++) { + const struct dnet_node_entry *e = dnet_nodedb_at(db, i); + if (e) + show_node(e); + } +} + +/* --- helpers ------------------------------------------------------------- */ +static int ieq(const char *a, const char *b) { return strcasecmp(a, b) == 0; } + +static int fail(const char *msg) +{ + fprintf(stderr, "%%NCP-E-%s\n", msg); + return 1; +} + +static void usage(void) +{ + fprintf(stderr, + "usage: NCP \n" + " SET|DEFINE NODE [NAME ]\n" + " CLEAR|PURGE NODE |\n" + " SHOW KNOWN NODES\n" + " SHOW NODE |\n" + " SET EXECUTOR ADDRESS | NAME | STATE ON|OFF\n" + " SHOW EXECUTOR [CHARACTERISTICS]\n"); +} + +int main(int argc, char **argv) +{ + if (argc < 2) { + usage(); + return 1; + } + const char *verb = argv[1]; + const char *ent = (argc >= 3) ? argv[2] : ""; + + /* ---- SHOW ---- */ + if (ieq(verb, "SHOW")) { + if (ieq(ent, "KNOWN") && argc >= 4 && ieq(argv[3], "NODES")) { + struct dnet_nodedb db; + if (dnet_nodedb_load(&db, nodedb_path()) != DNET_NODEDB_OK) + return fail("DBRDERR, node database is corrupt"); + show_known_nodes(&db); + return 0; + } + if (ieq(ent, "NODE") && argc >= 4) { + struct dnet_nodedb db; + if (dnet_nodedb_load(&db, nodedb_path()) != DNET_NODEDB_OK) + return fail("DBRDERR, node database is corrupt"); + const struct dnet_node_entry *e = NULL; + uint16_t a = 0; + if (dnet_nodedb_parse_addr(argv[3], &a) == DNET_NODEDB_OK) + e = dnet_nodedb_by_addr(&db, a); + else + e = dnet_nodedb_by_name(&db, argv[3]); + if (!e) + return fail("UNRNODE, unrecognized node name or address"); + printf("\nNode Volatile Summary\n\nNode Name\n\n"); + show_node(e); + return 0; + } + if (ieq(ent, "EXECUTOR")) { + struct ncp_executor x; + exec_load(&x); + show_executor(&x, argc >= 4 && ieq(argv[3], "CHARACTERISTICS")); + return 0; + } + usage(); + return 1; + } + + /* ---- SET / DEFINE NODE, SET EXECUTOR ---- */ + if (ieq(verb, "SET") || ieq(verb, "DEFINE")) { + if (ieq(ent, "NODE") && argc >= 4) { + uint16_t a = 0; + if (dnet_nodedb_parse_addr(argv[3], &a) != DNET_NODEDB_OK) + return fail("INVADDR, address must be area.node (1..63 . 1..1023)"); + const char *name = ""; + if (argc >= 6 && ieq(argv[4], "NAME")) + name = argv[5]; + struct dnet_nodedb db; + if (dnet_nodedb_load(&db, nodedb_path()) != DNET_NODEDB_OK) + return fail("DBRDERR, node database is corrupt"); + int rc = dnet_nodedb_set(&db, a, name); + if (rc == DNET_NODEDB_EFULL) + return fail("DBFULL, node database is full"); + if (rc != DNET_NODEDB_OK) + return fail("INVNAME, bad node name or name already in use"); + if (dnet_nodedb_save(&db, nodedb_path()) != DNET_NODEDB_OK) + return fail("DBWRERR, could not write the node database"); + return 0; + } + if (ieq(ent, "EXECUTOR") && argc >= 4) { + struct ncp_executor x; + exec_load(&x); + if (ieq(argv[3], "ADDRESS") && argc >= 5) { + uint16_t a = 0; + if (dnet_nodedb_parse_addr(argv[4], &a) != DNET_NODEDB_OK) + return fail("INVADDR, address must be area.node"); + x.have_addr = 1; + x.addr = a; + } else if (ieq(argv[3], "NAME") && argc >= 5) { + snprintf(x.name, sizeof(x.name), "%.*s", DNET_NODEDB_NAMEMAX, argv[4]); + } else if (ieq(argv[3], "STATE") && argc >= 5) { + x.state_on = ieq(argv[4], "ON"); + } else { + usage(); + return 1; + } + if (exec_save(&x) != DNET_NODEDB_OK) + return fail("CFGWRERR, could not write executor config"); + return 0; + } + usage(); + return 1; + } + + /* ---- CLEAR / PURGE NODE ---- */ + if (ieq(verb, "CLEAR") || ieq(verb, "PURGE")) { + if (ieq(ent, "NODE") && argc >= 4) { + struct dnet_nodedb db; + if (dnet_nodedb_load(&db, nodedb_path()) != DNET_NODEDB_OK) + return fail("DBRDERR, node database is corrupt"); + uint16_t a = 0; + int rc; + if (dnet_nodedb_parse_addr(argv[3], &a) == DNET_NODEDB_OK) + rc = dnet_nodedb_clear_addr(&db, a); + else + rc = dnet_nodedb_clear_name(&db, argv[3]); + if (rc == DNET_NODEDB_ENOENT) + return fail("UNRNODE, no such node in the database"); + if (rc != DNET_NODEDB_OK) + return fail("INVNODE, bad node name or address"); + if (dnet_nodedb_save(&db, nodedb_path()) != DNET_NODEDB_OK) + return fail("DBWRERR, could not write the node database"); + return 0; + } + usage(); + return 1; + } + + usage(); + return 1; +} diff --git a/tests/integration/decnet_nsp_live_datalink.sh b/tests/integration/decnet_nsp_live_datalink.sh new file mode 100644 index 00000000..4a0c3523 --- /dev/null +++ b/tests/integration/decnet_nsp_live_datalink.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# decnet_nsp_live_datalink.sh — prove an NSP logical link over a REAL datalink +# (rd vms-c23: "NSP logical-link connection service ... over the live datalink"). +# +# The socketpair selftest (decnetd --nsp-selftest) proves the FSM in isolation; +# it does NOT prove the daemon moves NSP PDUs over a real kernel L2 datalink. +# This test does: it stands up a veth pair (two real Ethernet endpoints; a frame +# transmitted on one end is received on the other), gives each end its faithful +# DECnet MAC (AA-00-04-00-, exactly what a real Phase IV node programs +# onto its NIC), and runs two real DECNETD.EXE engines — a --listen server and a +# --connect client — that carry a full CI -> CC -> DATA+ACK -> DI/DC logical +# link over that wire. Every field is the link FSM's own (executive-backed, no +# frame-template copying); nothing is a socketpair or an in-process shortcut. +# +# Needs CAP_NET_ADMIN (veth + MAC) and CAP_NET_RAW (AF_PACKET). Run it in a +# privileged container or netns; it creates its own veth and cleans up. If it +# cannot create the veth it SKIPS honestly (exit 77) rather than faking a pass. +# +# Build DECNETD.EXE first (static, so it runs anywhere): +# cmake -S -B build -DBUILD_TESTS=OFF -DBUILD_TOOLS=OFF -DOVMX_STATIC=ON +# cmake --build build --target decnetd_exe # -> build/bin/DECNETD.EXE +set -u +REPO="${REPO:-$(cd "$(dirname "$0")/../.." && pwd)}" +BIN="${DECNETD_BIN:-$REPO/build/bin/DECNETD.EXE}" +V0=dnet0 V1=dnet1 # veth ends +MAC0=aa:00:04:00:2b:04 # 1.43 (server) area 1 node 43 +MAC1=aa:00:04:00:2c:04 # 1.44 (client) area 1 node 44 +WORK="$(mktemp -d)" +SRV_LOG="$WORK/server.log"; CLI_LOG="$WORK/client.log"; CAP="$WORK/wire.txt" + +cleanup() { + [ -n "${SRV_PID:-}" ] && kill "$SRV_PID" 2>/dev/null + [ -n "${CAP_PID:-}" ] && kill "$CAP_PID" 2>/dev/null + ip link del "$V0" 2>/dev/null + rm -rf "$WORK" +} +trap cleanup EXIT + +[ -x "$BIN" ] || { echo "SKIP: no DECNETD.EXE at $BIN (build it first)"; exit 77; } + +# Stand up the veth pair with faithful DECnet MACs. If we lack the privilege, +# skip honestly -- never report a pass we did not earn. +if ! ip link add "$V0" type veth peer name "$V1" 2>/dev/null; then + echo "SKIP: cannot create veth (need CAP_NET_ADMIN / privileged container)" + exit 77 +fi +ip link set "$V0" address "$MAC0" 2>/dev/null || { echo "SKIP: cannot set MAC"; exit 77; } +ip link set "$V1" address "$MAC1" 2>/dev/null || { echo "SKIP: cannot set MAC"; exit 77; } +ip link set "$V0" up; ip link set "$V1" up + +command -v tcpdump >/dev/null 2>&1 && \ + tcpdump -i "$V0" -n -e -tt 'ether proto 0x6003' > "$CAP" 2>/dev/null & CAP_PID=$! + +# Server on the 1.43 end; client on the 1.44 end. Each binds its own veth end, +# owns its DECnet MAC, and receives its unicast natively. +"$BIN" --address 1.43 --name OVMXS --iface "$V0" --listen --duration 12 > "$SRV_LOG" 2>&1 & SRV_PID=$! +sleep 1 +"$BIN" --address 1.44 --name OVMXC --iface "$V1" --connect 1.43 --object 42 \ + --connect-data 'OVMX-NSP-LIVE-DATALINK-PROOF' --duration 10 > "$CLI_LOG" 2>&1 +sleep 1 +kill "$SRV_PID" 2>/dev/null; wait "$SRV_PID" 2>/dev/null + +echo "===== server ($MAC0, 1.43) ====="; grep -E 'DECNETD-(I|W)-' "$SRV_LOG" +echo "===== client ($MAC1, 1.44) ====="; grep -E 'DECNETD-(I|W)-' "$CLI_LOG" +[ -s "$CAP" ] && { echo "===== wire (0x6003) ====="; grep -iE 'conn-initiate|conn-confirm|data|ack|disconn' "$CAP" | head -8; } + +# Verdict: BOTH ends must show the full lifecycle over the real datalink. +fail=0 +grep -q 'DECNETD-I-CONNIN' "$SRV_LOG" || { echo "MISSING: server did not receive the Connect Initiate"; fail=1; } +grep -q 'DECNETD-I-LINKUP' "$SRV_LOG" || { echo "MISSING: server link never reached RUN"; fail=1; } +grep -q 'OVMX-NSP-LIVE-DATALINK-PROOF' "$SRV_LOG" || { echo "MISSING: data segment not delivered byte-identical"; fail=1; } +grep -q 'DECNETD-I-LINKUP' "$CLI_LOG" || { echo "MISSING: client link never reached RUN (no Connect Confirm)"; fail=1; } +grep -q 'DECNETD-I-DATATX' "$CLI_LOG" || { echo "MISSING: client never sent data"; fail=1; } +grep -q 'DECNETD-I-LINKDOWN' "$CLI_LOG" || { echo "MISSING: link did not close cleanly"; fail=1; } + +if [ "$fail" -eq 0 ]; then + echo "PASS: NSP logical link OPEN -> DATA (byte-identical) -> DISCONNECT over a REAL veth datalink" + exit 0 +fi +echo "FAIL: NSP did not complete over the live datalink" +exit 1 diff --git a/tests/integration/decnet_set_host_live.sh b/tests/integration/decnet_set_host_live.sh new file mode 100755 index 00000000..1a46bce2 --- /dev/null +++ b/tests/integration/decnet_set_host_live.sh @@ -0,0 +1,112 @@ +#!/bin/bash +# decnet_set_host_live.sh — prove a REAL interactive $ SET HOST / CTERM terminal +# session over a REAL datalink (rd vms-aac0 live bracket, child of vms-30e / +# vms-4d2). +# +# The --set-host-selftest proves the CTERM choreography over a socketpair with +# SCRIPTED payloads. This test proves the daemon carries a GENUINE interactive +# program's I/O over a real kernel L2 datalink: a --cterm-server HOST spawns a +# real PTY + a real login-command, and a --set-host TERMINAL client feeds real +# stdin and prints real stdout. NOTHING is canned — the "Welcome, ." line +# the client receives can ONLY be produced by the spawned program reading the +# client's input and transforming it, and the program independently records the +# input it read to a file we assert on. Both directions are proven. +# +# It stands up a veth pair (two real Ethernet endpoints), gives each end its +# faithful DECnet MAC (AA-00-04-00-), and runs two real DECNETD.EXE +# engines over that wire. Every CTERM/NSP field is the FSM's own (no template +# copying). Needs CAP_NET_ADMIN (veth + MAC) and CAP_NET_RAW (AF_PACKET); if it +# cannot create the veth it SKIPS honestly (exit 77) — never a fake pass. +# +# Build DECNETD.EXE first (static so it runs anywhere), then: +# REPO= DECNETD_BIN= tests/integration/decnet_set_host_live.sh +set -u +REPO="${REPO:-$(cd "$(dirname "$0")/../.." && pwd)}" +BIN="${DECNETD_BIN:-$REPO/build/bin/DECNETD.EXE}" +V0=dneth0 V1=dneth1 # veth ends (distinct from the nsp test) +MAC0=aa:00:04:00:2b:04 # 1.43 (server) area 1 node 43 +MAC1=aa:00:04:00:2c:04 # 1.44 (client) area 1 node 44 +WORK="$(mktemp -d)" +SRV_LOG="$WORK/server.log"; CLI_LOG="$WORK/client.log"; CAP="$WORK/wire.txt" +LOGIN="$WORK/login.sh" # the REAL interactive login-command +GOT="$WORK/got_input.txt" # side-channel proof of input receipt + +cleanup() { + [ -n "${SRV_PID:-}" ] && kill "$SRV_PID" 2>/dev/null + [ -n "${CAP_PID:-}" ] && kill "$CAP_PID" 2>/dev/null + ip link del "$V0" 2>/dev/null + rm -rf "$WORK" +} +trap cleanup EXIT + +[ -x "$BIN" ] || { echo "SKIP: no DECNETD.EXE at $BIN (build it first)"; exit 77; } + +# The login-command: a genuine interactive program. It prints a prompt, READS a +# line from its stdin (the pty, fed by the client's keystrokes over CTERM), and +# prints a line DERIVED from that input, then exits. It also records the line it +# read to $GOT so we can prove input receipt independently of the return path. +cat > "$LOGIN" < "$GOT" +printf 'Welcome, %s.\n' "\$line" +EOF +chmod +x "$LOGIN" + +# Stand up the veth pair with faithful DECnet MACs, or skip honestly. +if ! ip link add "$V0" type veth peer name "$V1" 2>/dev/null; then + echo "SKIP: cannot create veth (need CAP_NET_ADMIN / privileged container)" + exit 77 +fi +ip link set "$V0" address "$MAC0" 2>/dev/null || { echo "SKIP: cannot set MAC"; exit 77; } +ip link set "$V1" address "$MAC1" 2>/dev/null || { echo "SKIP: cannot set MAC"; exit 77; } +ip link set "$V0" up; ip link set "$V1" up + +command -v tcpdump >/dev/null 2>&1 && \ + tcpdump -i "$V0" -n -e -tt 'ether proto 0x6003' > "$CAP" 2>/dev/null & CAP_PID=$! + +# Server (1.43): CTERM HOST spawning the real login-command on a real PTY. +"$BIN" --address 1.43 --name OVMXS --iface "$V0" --cterm-server \ + --login-command "/bin/sh $LOGIN" --duration 15 > "$SRV_LOG" 2>&1 & SRV_PID=$! +sleep 1 + +# Client (1.44): $ SET HOST 1.43, feeding a REAL line of stdin. The client exits +# early when the host unbinds (the login-command exited); --duration bounds it. +printf 'SYSTEM\n' | \ + "$BIN" --address 1.44 --name OVMXC --iface "$V1" --set-host 1.43 \ + --duration 12 > "$CLI_LOG" 2>&1 +sleep 1 +kill "$SRV_PID" 2>/dev/null; wait "$SRV_PID" 2>/dev/null + +echo "===== server ($MAC0, 1.43 — CTERM HOST) ====="; grep -E 'DECNETD-(I|W)-' "$SRV_LOG" +echo "===== client ($MAC1, 1.44 — SET HOST) ====="; cat "$CLI_LOG" +[ -s "$GOT" ] && { echo "===== program read (side-channel) ====="; cat "$GOT"; } +[ -s "$CAP" ] && { echo "===== wire (0x6003) ====="; head -8 "$CAP"; } + +# Verdict: the FULL bidirectional pump must be proven over the real datalink. +fail=0 +# --- Server-side: it accepted the CTERM object and spawned the real program. +grep -q 'DECNETD-I-CONNIN' "$SRV_LOG" || { echo "MISSING: server did not receive the Connect Initiate"; fail=1; } +grep -q 'login-command spawned' "$SRV_LOG" || { echo "MISSING: server did not spawn the login-command"; fail=1; } +grep -q 'DECNETD-I-BOUND' "$SRV_LOG" || { echo "MISSING: CTERM session never bound on the host"; fail=1; } +# --- Client input reached the program (side-channel, independent of the return path). +[ -s "$GOT" ] && grep -q '^SYSTEM$' "$GOT" || { echo "MISSING: the program did not read the client's input"; fail=1; } +# --- Client link came up and bound. +grep -q 'DECNETD-I-LINKUP' "$CLI_LOG" || { echo "MISSING: client link never reached RUN"; fail=1; } +grep -q 'DECNETD-I-BOUND' "$CLI_LOG" || { echo "MISSING: client CTERM session never bound"; fail=1; } +# --- Server program output reached the client's stdout (both the prompt it +# printed AND the input-derived line only the program can produce). +grep -q 'Username: ' "$CLI_LOG" || { echo "MISSING: host prompt did not reach the client"; fail=1; } +grep -q 'Welcome, SYSTEM\.' "$CLI_LOG" || { echo "MISSING: input-derived program output did not reach the client"; fail=1; } +# --- The session tore down honestly on the child exit. +grep -q 'DECNETD-I-CHILDEXIT' "$SRV_LOG" || { echo "MISSING: server did not detect the login-command exit"; fail=1; } + +if [ "$fail" -eq 0 ]; then + echo "PASS: a REAL interactive program's I/O crossed a live \$ SET HOST/CTERM" + echo " session over a REAL veth datalink — client input reached the" + echo " program AND the program's real output reached the client's stdout" + exit 0 +fi +echo "FAIL: the interactive CTERM session did not complete over the live datalink" +exit 1 diff --git a/tests/lab/captures/decnet-aac0-endnode-probe-20260907.decnetd.log b/tests/lab/captures/decnet-aac0-endnode-probe-20260907.decnetd.log new file mode 100644 index 00000000..9956d444 --- /dev/null +++ b/tests/lab/captures/decnet-aac0-endnode-probe-20260907.decnetd.log @@ -0,0 +1,24 @@ +07-Sep-2026 16:14:38 DECNETD-I-STARTED, DECnet Phase IV endnode up on circuit EWA-0 (datalink hidden behind the VMS surface, Rule 1) +Node Volatile Summary +Executor node = 1.42 (OVMX) +State = on +Identification = OVMX DECnet-compatible networking +Type = nonrouting IV (endnode) +Known Circuit Volatile Summary +Circuit = EWA-0 +State = on +Designated router = none +Hello timer = 15 +Adjacent nodes = 0 +07-Sep-2026 16:14:51 DECNETD-I-ADJINIT, heard 1.1 (initializing) on circuit EWA-0 +07-Sep-2026 16:14:53 DECNETD-I-HELLOSENT, circuit EWA-0 seq=1 bytes=60 +07-Sep-2026 16:15:06 DECNETD-I-ADJINIT, heard 1.1 (initializing) on circuit EWA-0 +07-Sep-2026 16:15:08 DECNETD-I-HELLOSENT, circuit EWA-0 seq=2 bytes=60 +07-Sep-2026 16:15:21 DECNETD-I-ADJINIT, heard 1.1 (initializing) on circuit EWA-0 +07-Sep-2026 16:15:23 DECNETD-I-HELLOSENT, circuit EWA-0 seq=3 bytes=60 +07-Sep-2026 16:15:23 DECNETD-I-STOPPING, shutting down circuit EWA-0 +Adjacent Node Volatile Summary +Executor node = 1.42 (OVMX) + Node State Circuit + 1.1 initializing EWA-0 +DECNETD-I-COUNTERS, hello_sent=3 hello_recv=3 frames_recv=3 frames_dropped=0 adj_up=0 adj_down=0 diff --git a/tests/lab/captures/decnet-aac0-endnode-probe-20260907.md b/tests/lab/captures/decnet-aac0-endnode-probe-20260907.md new file mode 100644 index 00000000..117fd3a6 --- /dev/null +++ b/tests/lab/captures/decnet-aac0-endnode-probe-20260907.md @@ -0,0 +1,39 @@ +# DECnet Phase IV — first OVMX ↔ real-VAX wire interaction (vms-aac0) + +**Date:** 2026-09-07 · **Pod:** vaxlab-2 (shared with the cluster lane by agreement; DECnet +0x6003 is protocol-orthogonal to SCS 0x6007) · **Oracle:** lab VAX node **1.1 = VAX1** +(VAX/VMS V7.3), a pure Phase IV **endnode** (`rtr 0.0`, no router on the segment). + +## What ran +Static `DECNETD.EXE` (glibc-static x86_64, built from origin/main `4e9605ff`, md5-verified in +pod) run on `br0` as endnode **1.42** (name OVMX) for 45 s with a `0x6003` capture: + + DECNETD.EXE --address 1.42 --name OVMX --iface br0 --duration 45 + +## Result (see the two sibling artifacts) +- **OVMX emitted faithful endnode-hellos on the real lab wire** — `src 1.42`, MAC + `aa:00:04:00:2a:04`, byte-shaped identically to the oracle's own hello. +- **OVMX's engine heard and registered the real VAX** — `DECNETD-I-ADJINIT, heard 1.1`; + OVMX `SHOW ADJACENT NODES` lists `1.1 initializing`. +- **The VAX did NOT adopt OVMX** — node 1.1's wire `rtr` field stayed `0.0` throughout, and it + kept HELLOing normally. **No crash, no disruption** to the VAX or the co-resident CN=3 cluster + (ovmx-never-crashes-a-peer satisfied for endnode-hellos). + +## Interpretation (DECnet Phase IV semantics) +Two endnodes on a LAN with no router do **not** form a hello-based adjacency with each other — +endnode-hellos go to the all-routers multicast (`ab:00:00:03:00:00`) for *routers* to hear; a +peer endnode ignores them. So the engine's endnode-hello TX (all the live loop does today) can +never, by itself, make an endnode VAX list OVMX. + +The provenance register done-bar (§4.3/4.4) requires the VAX to list OVMX **"as a reachable +endnode"** — not as a router. Getting there faithfully needs (a) OVMX defined in the VAX node +DB (`NCP SET NODE 1.42 NAME OVMX`) and (b) OVMX genuinely *reachable* — i.e. a live NSP/NICE +responder on the datalink, which today exists only socketpair-wired (not bound to the live +AF_PACKET engine). That overlaps the task-to-task work (vms-c23 landed the NSP connection +service but not its live-datalink binding). + +## Bottom line +The **engine half of the Phase-1 proof is met**: OVMX's userspace Phase IV engine runs on a real +lab segment against a real VAX oracle and forms an adjacency entry for it. The **bidirectional +"VAX lists OVMX as reachable"** half needs a design/scope decision (wire the live NSP engine to +the datalink now, vs. stage it) plus reliable VAX-console access. Teed up to the operator. diff --git a/tests/lab/captures/decnet-aac0-endnode-probe-20260907.tcpdump.txt b/tests/lab/captures/decnet-aac0-endnode-probe-20260907.tcpdump.txt new file mode 100644 index 00000000..0cb17e4f --- /dev/null +++ b/tests/lab/captures/decnet-aac0-endnode-probe-20260907.tcpdump.txt @@ -0,0 +1,7 @@ +1788797691.638760 aa:00:04:00:01:04 > ab:00:00:03:00:00, ethertype DN (0x6003), length 60: endnode-hello endnode vers 2 eco 0 ueco 0 src 1.1 blksize 1498 rtr 0.0 hello 15 data 2 +1788797693.719094 aa:00:04:00:2a:04 > ab:00:00:03:00:00, ethertype DN (0x6003), length 60: endnode-hello endnode vers 2 eco 0 ueco 0 src 1.42 blksize 1498 rtr 0.0 hello 15 data 2 +1788797706.628969 aa:00:04:00:01:04 > ab:00:00:03:00:00, ethertype DN (0x6003), length 60: endnode-hello endnode vers 2 eco 0 ueco 0 src 1.1 blksize 1498 rtr 0.0 hello 15 data 2 +1788797708.695103 aa:00:04:00:2a:04 > ab:00:00:03:00:00, ethertype DN (0x6003), length 60: endnode-hello endnode vers 2 eco 0 ueco 0 src 1.42 blksize 1498 rtr 0.0 hello 15 data 2 +1788797721.639004 aa:00:04:00:01:04 > ab:00:00:03:00:00, ethertype DN (0x6003), length 60: endnode-hello endnode vers 2 eco 0 ueco 0 src 1.1 blksize 1498 rtr 0.0 hello 15 data 2 +1788797723.671094 aa:00:04:00:2a:04 > ab:00:00:03:00:00, ethertype DN (0x6003), length 60: endnode-hello endnode vers 2 eco 0 ueco 0 src 1.42 blksize 1498 rtr 0.0 hello 15 data 2 + diff --git a/tests/lab/captures/decnet-nsp-live-datalink-20260907.txt b/tests/lab/captures/decnet-nsp-live-datalink-20260907.txt new file mode 100644 index 00000000..bbe47ce5 --- /dev/null +++ b/tests/lab/captures/decnet-nsp-live-datalink-20260907.txt @@ -0,0 +1,19 @@ +===== server (aa:00:04:00:2b:04, 1.43) ===== +07-Sep-2026 17:53:03 DECNETD-I-STARTED, DECnet Phase IV endnode up on circuit EWA-0 (datalink hidden behind the VMS surface, Rule 1) +07-Sep-2026 17:53:03 DECNETD-I-LISTEN, awaiting an inbound Connect Initiate (object 0) on circuit EWA-0 +07-Sep-2026 17:53:04 DECNETD-I-CONNIN, inbound Connect Initiate from 1.44 on circuit EWA-0 -- accepting +07-Sep-2026 17:53:04 DECNETD-I-LINKUP, logical link with 1.44 is RUN on circuit EWA-0 +07-Sep-2026 17:53:04 DECNETD-I-DATARX, 28 byte(s) delivered on circuit EWA-0: "OVMX-NSP-LIVE-DATALINK-PROOF" +07-Sep-2026 17:53:04 DECNETD-I-LINKDOWN, peer disconnected on circuit EWA-0 +07-Sep-2026 17:53:05 DECNETD-I-STOPPING, shutting down circuit EWA-0 +DECNETD-I-COUNTERS, hello_sent=0 hello_recv=0 frames_recv=0 frames_dropped=0 adj_up=0 adj_down=0 nsp_recv=3 nsp_dropped=0 +===== client (aa:00:04:00:2c:04, 1.44) ===== +07-Sep-2026 17:53:04 DECNETD-I-STARTED, DECnet Phase IV endnode up on circuit EWA-0 (datalink hidden behind the VMS surface, Rule 1) +07-Sep-2026 17:53:04 DECNETD-I-CONNECTING, Connect Initiate sent to 1.43 (object 42) on circuit EWA-0 +07-Sep-2026 17:53:04 DECNETD-I-LINKUP, logical link to 1.43 is RUN on circuit EWA-0 +07-Sep-2026 17:53:04 DECNETD-I-DATATX, sent 28 byte(s) on circuit EWA-0 +07-Sep-2026 17:53:04 DECNETD-I-DISCONN, data acknowledged -- disconnecting +07-Sep-2026 17:53:04 DECNETD-I-LINKDOWN, disconnect confirmed -- link closed on circuit EWA-0 +07-Sep-2026 17:53:04 DECNETD-I-STOPPING, shutting down circuit EWA-0 +DECNETD-I-COUNTERS, hello_sent=0 hello_recv=0 frames_recv=0 frames_dropped=0 adj_up=0 adj_down=0 nsp_recv=3 nsp_dropped=0 +PASS: NSP logical link OPEN -> DATA (byte-identical) -> DISCONNECT over a REAL veth datalink diff --git a/tests/lab/captures/decnet-set-host-live-20260908.txt b/tests/lab/captures/decnet-set-host-live-20260908.txt new file mode 100644 index 00000000..0cd7f8ea --- /dev/null +++ b/tests/lab/captures/decnet-set-host-live-20260908.txt @@ -0,0 +1,34 @@ +===== server (aa:00:04:00:2b:04, 1.43 — CTERM HOST) ===== +08-Sep-2026 13:20:27 DECNETD-I-STARTED, DECnet Phase IV endnode up on circuit EWA-0 (datalink hidden behind the VMS surface, Rule 1) +08-Sep-2026 13:20:27 DECNETD-I-CTERMSRV, awaiting an inbound $ SET HOST to CTERM object 42 on circuit EWA-0 (login-command: /bin/sh /tmp/tmp.eccc4uZJZk/login.sh) +08-Sep-2026 13:20:28 DECNETD-I-CONNIN, inbound Connect Initiate from 1.44 to object 42 on circuit EWA-0 +08-Sep-2026 13:20:28 DECNETD-I-LINKUP, logical link with 1.44 is RUN -- login-command spawned (pid 20) on circuit EWA-0 +08-Sep-2026 13:20:28 DECNETD-I-BOUND, CTERM session bound (terminal OVMX$RTA1:) on circuit EWA-0 +08-Sep-2026 13:20:28 DECNETD-I-CHILDEXIT, login-command exited -- unbinding the terminal session on circuit EWA-0 +08-Sep-2026 13:20:28 DECNETD-I-CTERMSRVEND, CTERM host session ended: writes_sent=3 reads_recv=1 on circuit EWA-0 +===== client (aa:00:04:00:2c:04, 1.44 — SET HOST) ===== +08-Sep-2026 13:20:28 DECNETD-I-STARTED, DECnet Phase IV endnode up on circuit EWA-0 (datalink hidden behind the VMS surface, Rule 1) +Node Volatile Summary +Executor node = 1.44 (OVMXC) +State = on +Identification = OVMX DECnet-compatible networking +Type = nonrouting IV (endnode) +Known Circuit Volatile Summary +Circuit = EWA-0 +State = on +Designated router = none +Hello timer = 15 +Adjacent nodes = 0 +08-Sep-2026 13:20:28 DECNETD-I-SETHOST, $ SET HOST 1.43 -- Connect Initiate sent to CTERM object 42 on circuit EWA-0 +08-Sep-2026 13:20:28 DECNETD-I-LINKUP, logical link to 1.43 is RUN -- sending CTERM Bind +08-Sep-2026 13:20:28 DECNETD-I-BOUND, CTERM terminal session bound on circuit EWA-0 -- terminal is live +08-Sep-2026 13:20:28 DECNETD-I-EOF, local input closed -- draining remote output on circuit EWA-0 +SYSTEM +Username: Welcome, SYSTEM. +08-Sep-2026 13:20:28 DECNETD-I-UNBOUND, host released the terminal session on circuit EWA-0 +08-Sep-2026 13:20:28 DECNETD-I-SETHOSTEND, SET HOST session ended: cterm writes_recv=3 reads_sent=1 on circuit EWA-0 +===== program read (side-channel) ===== +SYSTEM +PASS: a REAL interactive program's I/O crossed a live $ SET HOST/CTERM + session over a REAL veth datalink — client input reached the + program AND the program's real output reached the client's stdout diff --git a/tests/lab/tools/decnet_adjacency.sh b/tests/lab/tools/decnet_adjacency.sh new file mode 100755 index 00000000..a2366c93 --- /dev/null +++ b/tests/lab/tools/decnet_adjacency.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# decnet_adjacency.sh [OVMX_ADDR] [OVMX_NAME] +# +# DECnet Phase IV LIVE-ADJACENCY probe against a lab VAX oracle (rd vms-aac0, +# epic vms-30e; design-decnet-ovmx.md §5 Phase 1). Stages the userspace DECnet +# engine DECNETD.EXE onto a lab-2-style pod's br0 -- the SAME L2 segment a lab +# VAX runs DECnet Phase IV on -- runs it as an endnode for seconds, +# and captures raw 0x6003 (Phase IV ethertype cannot traverse SLIRP; needs the +# pod br0 the lab VAX taps into). Reports, from GROUND TRUTH on the wire + the +# engine's own adjacency table: +# - OVMX emitted faithful endnode-HELLOs on the real segment (src OVMX_ADDR), +# - which real VAX node(s) OVMX HEARD and registered (engine SHOW ADJACENT), +# - each observed node's routing state (the endnode-HELLO `rtr` field): a lab +# endnode with `rtr 0.0` has no router and will NOT form a hello-only +# adjacency back to an OVMX endnode (Phase IV: endnodes peer with routers, +# not each other) -- this probe surfaces exactly that, honestly. +# +# WHAT THIS PROVES / DOES NOT PROVE. It proves the userspace engine runs on a +# REAL lab wire against the REAL oracle and forms an adjacency entry for it, +# with zero disruption (it emits only the oracle-verified endnode-HELLO, the +# most benign Phase IV frame -- ovmx-never-crashes-a-peer). It does NOT by +# itself make the VAX list OVMX ("reachable endnode", the vms-aac0 done-bar): +# that needs OVMX reachable via the live NSP logical link (the NSP service is +# landed but socketpair-only; wiring it into decnetd's live loop is the next +# rung) and/or a node definition on the VAX. See +# tests/lab/captures/decnet-aac0-endnode-probe-20260907.md. +# +# ⚠ SHARED POD. If the pod also runs a VMScluster, DECnet (0x6003) is protocol- +# orthogonal to SCS (0x6007): this probe never touches cluster membership. But +# coordinate: one 0x6003 talker at a time, and never restart the pod. +# +# Build DECNETD.EXE (static, portable into the glibc pod): +# cmake -S -B /tmp/b -DBUILD_TESTS=OFF -DBUILD_TOOLS=OFF -DOVMX_STATIC=ON +# cmake --build /tmp/b --target decnetd_exe +# -> /tmp/b/bin/DECNETD.EXE (override via DECNETD_BIN=) +set -u +POD="${1:?usage: decnet_adjacency.sh [OVMX_ADDR] [OVMX_NAME]}" +TAG="${2:?tag required (unique per run)}" +DUR="${3:?duration seconds required}" +ADDR="${4:-1.42}" +NAME="${5:-OVMX}" + +NS="${NS:-ovmx-lab}" +L="${L:-/lab/k8s-labs/$POD/logs}" +REPO="${REPO:-$(cd "$(dirname "$0")/../../.." && pwd)}" +DECNETD_BIN="${DECNETD_BIN:-$REPO/build/bin/DECNETD.EXE}" +IFACE="${IFACE:-br0}" +RDIR="/lab/run-$TAG" + +log(){ echo "[$(date +%T)] $*"; } +kx(){ kubectl -n "$NS" exec "$POD" -- "$@"; } + +kubectl -n "$NS" get pod "$POD" >/dev/null 2>&1 || { echo "FATAL: no pod $POD" >&2; exit 2; } +[ -x "$DECNETD_BIN" ] || { echo "FATAL: no DECNETD.EXE at $DECNETD_BIN (build it -- see header)" >&2; exit 2; } + +# Stage per-run (never a shared path), md5-verified before and after. +LOCAL_MD5=$(md5sum "$DECNETD_BIN" | awk '{print $1}') +kx mkdir -p "$RDIR" +kubectl -n "$NS" cp "$DECNETD_BIN" "$POD:$RDIR/DECNETD.EXE" || { echo "FATAL: cp failed" >&2; exit 2; } +POD_MD5=$(kx md5sum "$RDIR/DECNETD.EXE" 2>/dev/null | awk '{print $1}') +[ "$LOCAL_MD5" = "$POD_MD5" ] || { echo "FATAL: staged md5 mismatch local=$LOCAL_MD5 pod=$POD_MD5" >&2; exit 2; } +kx chmod +x "$RDIR/DECNETD.EXE" +log "staged DECNETD.EXE md5=$LOCAL_MD5 (verified in-pod) at $POD:$RDIR" +log "RUN $TAG pod=$POD iface=$IFACE addr=$ADDR name=$NAME dur=${DUR}s" + +# One held-open exec for the whole window (a backgrounded tcpdump does NOT +# survive exec teardown -- lab2run.sh learned this the hard way). +kx sh -c " + cd $RDIR + timeout $((DUR+8)) tcpdump -i $IFACE -n -e -tt 'ether proto 0x6003' > $RDIR/$TAG.tcpdump.txt 2>/dev/null & + TCPD=\$! + sleep 2 + timeout $((DUR+2)) ./DECNETD.EXE --address $ADDR --name $NAME --iface $IFACE --duration $DUR > $RDIR/$TAG.decnetd.log 2>&1 + wait \$TCPD 2>/dev/null + true # tcpdump was killed by its timeout wrapper (expected) -- do not leak 124 +" + +POST_MD5=$(kx md5sum "$RDIR/DECNETD.EXE" 2>/dev/null | awk '{print $1}') +[ "$LOCAL_MD5" = "$POST_MD5" ] || { echo "FATAL: DECNETD.EXE changed mid-run ($LOCAL_MD5 -> $POST_MD5) -- results UNATTRIBUTABLE" >&2; exit 2; } + +echo "===== DECNETD engine log =====" +kx cat "$RDIR/$TAG.decnetd.log" +echo "===== wire (0x6003): per-source endnode-hello src + rtr field =====" +kx sh -c "sed -E 's/.*src (1\\.[0-9]+).*rtr ([0-9.]+).*/\\1 rtr=\\2/; t; d' $RDIR/$TAG.tcpdump.txt | sort | uniq -c" + +# Verdict from ground truth: OVMX must have emitted its own hellos AND heard the oracle. +SENT=$(kx grep -c 'HELLOSENT' "$RDIR/$TAG.decnetd.log" 2>/dev/null) +HEARD=$(kx grep -aoE 'heard 1\.[0-9]+' "$RDIR/$TAG.decnetd.log" 2>/dev/null | sort -u | tr '\n' ' ') +OWN=$(kx sh -c "grep -c 'src $ADDR ' $RDIR/$TAG.tcpdump.txt" 2>/dev/null) +echo "===== VERDICT =====" +echo "OVMX hellos on the wire (src $ADDR): $OWN engine HELLOSENT: $SENT" +echo "OVMX heard oracle node(s): ${HEARD:-}" +if [ "${OWN:-0}" -gt 0 ] && [ -n "$HEARD" ]; then + echo "PASS(wire-half): OVMX engine ran on the real segment and formed an adjacency entry for a real VAX." +else + echo "FAIL(wire-half): OVMX did not both emit hellos and hear a real VAX -- check iface/CAP_NET_RAW/segment." +fi diff --git a/tests/vmsdecnet/CMakeLists.txt b/tests/vmsdecnet/CMakeLists.txt index 807ce4b4..7715cb4e 100644 --- a/tests/vmsdecnet/CMakeLists.txt +++ b/tests/vmsdecnet/CMakeLists.txt @@ -57,3 +57,12 @@ add_executable(test_dnet_engine test_dnet_engine.c) target_link_libraries(test_dnet_engine PRIVATE vmsdecnet_engine) target_compile_options(test_dnet_engine PRIVATE -Werror) add_test(NAME vmsdecnet_engine_unit COMMAND test_dnet_engine) + +# rd vms-1e9: the NCP node database (the configuration surface's persisted node +# table) + name<->address resolution. Address parse/range, DEFINE/SET add + +# update, node-name uniqueness, case-insensitive resolution, SHOW ordering, +# persistence round-trip + corrupt-file handling. Warning-clean under -Werror. +add_executable(test_dnet_nodedb test_dnet_nodedb.c) +target_link_libraries(test_dnet_nodedb PRIVATE vmsdecnet_nodedb) +target_compile_options(test_dnet_nodedb PRIVATE -Werror) +add_test(NAME vmsdecnet_nodedb_unit COMMAND test_dnet_nodedb) diff --git a/tests/vmsdecnet/test_dnet_link.c b/tests/vmsdecnet/test_dnet_link.c index 5242b1cf..217b67a7 100644 --- a/tests/vmsdecnet/test_dnet_link.c +++ b/tests/vmsdecnet/test_dnet_link.c @@ -37,6 +37,7 @@ #include "dnet_link.h" #include "dnet_engine.h" +#include "dnet_nsp.h" static int failures = 0; static void check(int cond, const char *what) @@ -259,12 +260,47 @@ static void test_engine_e2e(void) close(sv[0]); close(sv[1]); } +/* rd vms-c23: dnet_engine_link_tick drives the CI retransmit at the engine + * boundary (what DECNETD's live loop calls). The retransmitted Connect Initiate + * must be the link FSM's own PDU wrapped into a real data frame -- never a + * template copy -- so decode it back and confirm it is a CI. */ +static void test_engine_link_tick(void) +{ + printf("[engine] link_tick retransmits the Connect Initiate on the CI timer\n"); + const uint8_t hw[6] = { 0x02,0,0,0,0,0x0a }; + struct dnet_engine e; + check(dnet_engine_init(&e, 1, 10, "OVMXL", "EWA0", NULL, hw, 0, 0, 0) == DNET_ENGINE_OK, + "engine init"); + uint8_t frame[DNET_FRAME_MAX]; + size_t flen = 0; + check(dnet_engine_link_open(&e, 1, 11, 0x2001, NULL, 0, 1459, 1, DNET_NSP_VER_41, + frame, sizeof(frame), &flen, 0) == DNET_ENGINE_OK, + "link_open builds the initial CI at t=0"); + + int has_out = 1; + size_t tlen = 0; + check(dnet_engine_link_tick(&e, 1, frame, sizeof(frame), &tlen, &has_out) == DNET_ENGINE_OK + && has_out == 0, "no retransmit before the CI interval"); + check(dnet_engine_link_tick(&e, DNET_LINK_CI_RETRANS_SECS, frame, sizeof(frame), + &tlen, &has_out) == DNET_ENGINE_OK && has_out == 1, + "link_tick retransmits the CI at the interval"); + + const uint8_t *pdu = NULL; + size_t plen = 0; + check(dnet_engine_parse_data_frame(frame, tlen, NULL, NULL, &pdu, &plen) == DNET_ENGINE_OK, + "the retransmit is a well-formed long-data frame"); + struct dnet_nsp_msg m; + check(dnet_nsp_decode(pdu, plen, &m, NULL) == DNET_NSP_OK && m.type == DNET_NSP_T_CI, + "the retransmitted PDU is a Connect Initiate (the FSM's own, not a template)"); +} + int main(void) { printf("test_dnet_link: DECnet Phase IV NSP logical-link connection service\n"); test_fsm(); test_retransmit(); test_engine_e2e(); + test_engine_link_tick(); if (failures == 0) { printf("test_dnet_link: ALL CHECKS PASSED\n"); return 0; } printf("test_dnet_link: %d CHECK(S) FAILED\n", failures); return 1; diff --git a/tests/vmsdecnet/test_dnet_nodedb.c b/tests/vmsdecnet/test_dnet_nodedb.c new file mode 100644 index 00000000..8e79db58 --- /dev/null +++ b/tests/vmsdecnet/test_dnet_nodedb.c @@ -0,0 +1,114 @@ +/* + * test_dnet_nodedb.c - DECnet Phase IV node database unit tests (rd vms-1e9). + * + * Covers address parsing + range checks, DEFINE/SET NODE add + update, node- + * name uniqueness, name<->address resolution (case-insensitive), CLEAR/PURGE, + * SHOW ordering (ascending address), and persistence round-trip / corrupt-file + * handling. Pure logic + a temp file; no socket, no privilege. + */ +#include +#include +#include +#include + +#include "dnet_nodedb.h" + +static int failures = 0; +static void check(int cond, const char *what) +{ + printf(" [%s] %s\n", cond ? "PASS" : "FAIL", what); + if (!cond) + failures++; +} + +int main(void) +{ + printf("test_dnet_nodedb: DECnet Phase IV node database\n"); + + /* --- address parsing --- */ + uint16_t a = 0; + check(dnet_nodedb_parse_addr("1.1", &a) == DNET_NODEDB_OK && a == ((1u << 10) | 1u), + "parse 1.1"); + check(dnet_nodedb_parse_addr("1.42", &a) == DNET_NODEDB_OK && a == ((1u << 10) | 42u), + "parse 1.42"); + check(dnet_nodedb_parse_addr("63.1023", &a) == DNET_NODEDB_OK, "parse max 63.1023"); + check(dnet_nodedb_parse_addr("0.1", &a) == DNET_NODEDB_EINVAL, "reject area 0"); + check(dnet_nodedb_parse_addr("64.1", &a) == DNET_NODEDB_EINVAL, "reject area 64"); + check(dnet_nodedb_parse_addr("1.1024", &a) == DNET_NODEDB_EINVAL, "reject node 1024"); + check(dnet_nodedb_parse_addr("1", &a) == DNET_NODEDB_EINVAL, "reject bare 1"); + check(dnet_nodedb_parse_addr("1.x", &a) == DNET_NODEDB_EINVAL, "reject 1.x"); + check(dnet_nodedb_parse_addr("1.2.3", &a) == DNET_NODEDB_EINVAL, "reject 1.2.3"); + + /* --- set / update / uniqueness --- */ + struct dnet_nodedb db; + dnet_nodedb_init(&db); + uint16_t vax1 = (1u << 10) | 1u, ovmx = (1u << 10) | 42u, vax2 = (1u << 10) | 2u; + check(dnet_nodedb_set(&db, vax1, "VAX1") == DNET_NODEDB_OK, "SET NODE 1.1 NAME VAX1"); + check(dnet_nodedb_set(&db, ovmx, "OVMX") == DNET_NODEDB_OK, "SET NODE 1.42 NAME OVMX"); + check(dnet_nodedb_set(&db, vax2, "") == DNET_NODEDB_OK, "SET NODE 1.2 (no name)"); + check(db.count == 3, "count == 3"); + /* Re-SET an existing address updates its name (no dup). */ + check(dnet_nodedb_set(&db, vax2, "VAX2") == DNET_NODEDB_OK, "update 1.2 -> NAME VAX2"); + check(db.count == 3, "count still 3 after update"); + /* A name already bound to a different address is rejected. */ + check(dnet_nodedb_set(&db, (1u << 10) | 3u, "VAX1") == DNET_NODEDB_EINVAL, + "reject VAX1 on a second address"); + /* Bad names. */ + check(dnet_nodedb_set(&db, (1u << 10) | 4u, "TOOLONGNAME") == DNET_NODEDB_EINVAL, + "reject 7+ char name"); + check(dnet_nodedb_set(&db, (1u << 10) | 4u, "BAD-NM") == DNET_NODEDB_EINVAL, + "reject non-alnum name"); + + /* --- resolution (case-insensitive) --- */ + const struct dnet_node_entry *e = dnet_nodedb_by_name(&db, "vax1"); + check(e && e->addr == vax1, "resolve name 'vax1' (case-insensitive) -> 1.1"); + e = dnet_nodedb_by_name(&db, "OVMX"); + check(e && e->addr == ovmx, "resolve name 'OVMX' -> 1.42"); + e = dnet_nodedb_by_addr(&db, vax2); + check(e && strcmp(e->name, "VAX2") == 0, "resolve addr 1.2 -> VAX2"); + check(dnet_nodedb_by_name(&db, "NOPE") == NULL, "unknown name -> NULL"); + + /* --- SHOW ordering (ascending address) --- */ + const struct dnet_node_entry *e0 = dnet_nodedb_at(&db, 0); + const struct dnet_node_entry *e1 = dnet_nodedb_at(&db, 1); + const struct dnet_node_entry *e2 = dnet_nodedb_at(&db, 2); + check(e0 && e1 && e2 && e0->addr < e1->addr && e1->addr < e2->addr, + "dnet_nodedb_at iterates in ascending address order"); + check(e0->addr == vax1 && e1->addr == vax2 && e2->addr == ovmx, "order 1.1 < 1.2 < 1.42"); + check(dnet_nodedb_at(&db, 3) == NULL, "at(count) == NULL"); + + /* --- persistence round-trip --- */ + char path[] = "/tmp/ovmx_nodedb_test_XXXXXX"; + int fd = mkstemp(path); + check(fd >= 0, "temp DB path created"); + if (fd >= 0) + close(fd); + check(dnet_nodedb_save(&db, path) == DNET_NODEDB_OK, "save DB"); + struct dnet_nodedb db2; + check(dnet_nodedb_load(&db2, path) == DNET_NODEDB_OK, "load DB"); + check(db2.count == 3, "reloaded count == 3"); + e = dnet_nodedb_by_name(&db2, "VAX1"); + check(e && e->addr == vax1, "reloaded resolves VAX1 -> 1.1"); + e = dnet_nodedb_by_addr(&db2, vax2); + check(e && strcmp(e->name, "VAX2") == 0, "reloaded 1.2 name VAX2 persisted"); + + /* --- clear --- */ + check(dnet_nodedb_clear_name(&db2, "VAX1") == DNET_NODEDB_OK, "CLEAR NODE VAX1"); + check(dnet_nodedb_by_addr(&db2, vax1) == NULL, "1.1 gone after clear"); + check(db2.count == 2, "count 2 after clear"); + check(dnet_nodedb_clear_addr(&db2, ovmx) == DNET_NODEDB_OK, "CLEAR NODE 1.42"); + check(dnet_nodedb_clear_addr(&db2, ovmx) == DNET_NODEDB_ENOENT, "clear again -> ENOENT"); + + /* --- missing file == empty (not an error); corrupt file == EIO --- */ + struct dnet_nodedb db3; + check(dnet_nodedb_load(&db3, "/tmp/ovmx_nodedb_does_not_exist_XYZ") == DNET_NODEDB_OK + && db3.count == 0, "missing DB loads as empty"); + FILE *bad = fopen(path, "w"); + if (bad) { fprintf(bad, "GARBAGE not a node line\n"); fclose(bad); } + check(dnet_nodedb_load(&db3, path) == DNET_NODEDB_EIO, "corrupt DB -> EIO"); + remove(path); + + if (failures == 0) { printf("test_dnet_nodedb: ALL CHECKS PASSED\n"); return 0; } + printf("test_dnet_nodedb: %d CHECK(S) FAILED\n", failures); + return 1; +}