diff --git a/Cargo.lock b/Cargo.lock index cd2def29..99b3c920 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -334,6 +334,12 @@ dependencies = [ "syn", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -495,6 +501,7 @@ dependencies = [ "serde", "serde-big-array", "serde_json", + "smoltcp", "thread-priority", "toml", "wasmtime", @@ -734,6 +741,46 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "defmt" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" +dependencies = [ + "defmt 1.1.1", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1102,6 +1149,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1138,6 +1194,16 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -1465,6 +1531,12 @@ dependencies = [ "libc", ] +[[package]] +name = "managed" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" + [[package]] name = "memchr" version = "2.8.1" @@ -2690,6 +2762,20 @@ dependencies = [ "serde", ] +[[package]] +name = "smoltcp" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad095989c1533c1c266d9b1e8d70a1329dd3723c3edac6d03bbd67e7bf6f4bb" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "cfg-if", + "defmt 0.3.100", + "heapless", + "managed", +] + [[package]] name = "spirv" version = "0.4.0+sdk-1.4.341.0" diff --git a/Cargo.toml b/Cargo.toml index ad04ae17..aad185ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ publish = false default-run = "copperline" [features] -default = ["midi", "frontend", "wasm-boards", "control", "ctl-bin"] +default = ["midi", "frontend", "wasm-boards", "control", "ctl-bin", "net-nat"] display-plan-trace = [] # Local investigation switches that deliberately alter timing or rendering. # Normal builds keep these environment-variable overrides inert so release @@ -54,6 +54,10 @@ bench-bin = [] # (`--control` headless, `--control-gui` windowed). Uses std::net + threads, # so browser builds (--no-default-features) compile it out. control = ["dep:serde_json"] +# User-mode NAT backend for emulated NICs (`[a2065] net = "nat"`, src/net/nat/): +# a slirp-style virtual gateway giving the guest outbound IPv4 with no host +# privileges. Uses std::net + threads, so browser builds compile it out. +net-nat = ["dep:smoltcp"] # The `copperline-ctl` command-line client for the control protocol. ctl-bin = ["dep:serde_json"] @@ -92,6 +96,16 @@ arboard = { version = "3", default-features = false, features = ["wayland-data-c # Config in wasmboard.rs). Pin the version: save-state replay of a plugin's # linear-memory snapshot is only guaranteed within one wasmtime build. wasmtime = { version = "=27.0.0", default-features = false, features = ["cranelift", "runtime"], optional = true } +# Userspace TCP/IP stack for the NAT backend (src/net/nat/): terminates the +# guest's ARP and TCP on the virtual gateway so flows can be spliced onto host +# sockets. Pure Rust, 0BSD, lean feature set: Ethernet + IPv4 + TCP sockets +# only (DHCP/DNS/UDP/ICMP are handled at frame level with the wire parsers). +smoltcp = { version = "0.12", default-features = false, features = [ + "alloc", + "medium-ethernet", + "proto-ipv4", + "socket-tcp", +], optional = true } # Already pulled in transitively; used directly only for localtime_r (local # time-zone filename stamps) and, on macOS, the pthread QoS class used for the # optional realtime-priority feature (see src/priority.rs). diff --git a/copperline.example.toml b/copperline.example.toml index d0e4902a..e62b3125 100644 --- a/copperline.example.toml +++ b/copperline.example.toml @@ -199,12 +199,15 @@ slow = "512K" # A2065 Ethernet board (Am7990 LANCE, driven by the SANA-II a2065.device). -# net picks the host network backend: "loopback" echoes transmitted frames -# back (self-contained), "none" leaves the NIC isolated. Omit the section -# for no board. Host networking is non-deterministic, so a fitted NIC -# breaks byte-identical replay/save-state determinism while traffic flows. +# net picks the host network backend: "nat" is a slirp-style userspace NAT +# giving the guest outbound IPv4 with no host privileges (guest 10.0.2.15/24, +# gateway 10.0.2.2, DNS 10.0.2.3, DHCP built in); "loopback" echoes +# transmitted frames back (self-contained); "none" leaves the NIC isolated. +# Omit the section for no board. Host networking is non-deterministic, so a +# fitted NIC breaks byte-identical replay/save-state determinism while +# traffic flows. # [a2065] -# net = "loopback" +# net = "nat" # SCSI bus with up to seven drives, on any machine model. Preferred over [ide] diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 90aba538..18b726b8 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -60,11 +60,11 @@ machine; the other flags then override individual values on top of it, just as explicit `[cpu]`/`[chipset]`/`[memory]` sections override a `[machine]` profile in a config file. -The audio, serial, and parallel surface has matching per-run flags too -- -`--audio-device`, `--audio-channel-mode`, `--audio-stereo-separation`, +The audio, serial, parallel, and network surface has matching per-run flags +too -- `--audio-device`, `--audio-channel-mode`, `--audio-stereo-separation`, `--serial`, `--midi-in`, `--midi-out`, `--parallel`, `--sampler-audio-input`, -`--sampler-input-gain` -- described with their `[audio]`, `[serial]`, and -`[parallel]` keys below. +`--sampler-input-gain`, `--a2065-net` -- described with their `[audio]`, +`[serial]`, `[parallel]`, and `[a2065]` keys below. ## Top level @@ -855,17 +855,27 @@ assigns addresses. ```toml [a2065] -net = "loopback" # or "none" for an isolated NIC +net = "nat" # or "loopback"; "none" for an isolated NIC ``` -Fits a Commodore A2065 Ethernet board (Am7990 LANCE) on the Zorro chain. -`net` selects the host network backend: `"loopback"` echoes transmitted -frames back (self-contained, useful for driver bring-up), `"none"` leaves -the NIC isolated. Omit the section entirely for no board. Note that host -networking is inherently non-deterministic: inbound frames arrive on the -host's schedule, not the emulated clock, so a NIC board breaks -byte-identical replay and save-state determinism while traffic flows. See -[](../zorro) for the board details. +Fits a Commodore A2065 Ethernet board (Am7990 LANCE) on the Zorro chain; +`--a2065-net BACKEND` is the matching per-run flag. `net` selects the host +network backend: + +- `"nat"` -- userspace NAT: the guest gets outbound IPv4 internet through a + virtual gateway with no host privileges or setup, identically on Linux, + macOS, and Windows. Configure the guest's TCP/IP stack with IP + `10.0.2.15`, netmask `255.255.255.0`, gateway `10.0.2.2`, DNS `10.0.2.3` + (or let it BOOTP/DHCP). Outbound only, IPv4 only. +- `"loopback"` -- echoes transmitted frames back (self-contained, useful + for driver bring-up). +- `"none"` -- the NIC is fitted but isolated. + +Omit the section entirely for no board. Note that host networking is +inherently non-deterministic: inbound frames arrive on the host's +schedule, not the emulated clock, so a NIC board breaks byte-identical +replay and save-state determinism while traffic flows. See [](../zorro) +for the board details and the NAT's limitations. ## `[debug]` -- diagnostics diff --git a/docs/internals/peripherals.md b/docs/internals/peripherals.md index aacdb282..6e088168 100644 --- a/docs/internals/peripherals.md +++ b/docs/internals/peripherals.md @@ -164,12 +164,27 @@ Am7990 LANCE and 32 KiB of on-board RAM, driven by the AmigaOS SANA-II `a2065.device`. Unlike the DMAC boards the LANCE never masters the Amiga bus: its init block, descriptor rings, and packet buffers all live in the board's own RAM, which the CPU reaches through the board window, so the -board is self-contained and owns a host `NetBackend` (`net.rs`) for real -frames. Networking is inherently non-deterministic, so a fitted NIC +board is self-contained and owns a host `NetBackend` (`net/`) for real +frames. The LANCE engine models the Am7990 programming surface a real +driver exercises: TX and RX buffer chaining (STP..ENP spans across +descriptors), the stored FCS trailer (MCNT counts it; drivers read the +payload as `MCNT - 4`), the init-block MODE gates (DTX/DRX and the LOOP +internal-loopback self-test SANA-II drivers run at power-up), and MISS on +an RX ring overrun. + +The `nat` backend (`net/nat/`, `net-nat` build feature) is a slirp-style +userspace NAT: a dedicated `a2065-nat` thread owns a smoltcp interface +that terminates ARP and the guest's TCP on the virtual gateway +(10.0.2.2, DNS forwarder 10.0.2.3, guest 10.0.2.15/24), splices each TCP +flow onto a non-blocking host socket, NATs UDP per flow, resolves DNS +through the host's own resolver, and answers BOOTP/DHCP and ICMP echo at +frame level. Frames cross to the emulated NIC over bounded channels that +drop on overflow, so the emulator thread never blocks on the host +network. Networking is inherently non-deterministic, so a fitted NIC breaks byte-identical replay while traffic flows; save states record only -the chosen backend and bring up a fresh one on load. The board and -backend story, including the WASM plugin `net` capability, is covered in -[](../zorro). +the chosen backend and bring up a fresh one on load (flows die; the +guest's TCP retransmits). The board and backend story, including the WASM +plugin `net` capability, is covered in [](../zorro). ## CDTV (`cdtv.rs`, `cdrom.rs`) diff --git a/docs/zorro.md b/docs/zorro.md index 56d92de9..863e83a7 100644 --- a/docs/zorro.md +++ b/docs/zorro.md @@ -183,19 +183,44 @@ config: ```toml [a2065] -net = "loopback" # host network backend; "none" for an isolated NIC +net = "nat" # host network backend; "loopback", or "none" for isolation ``` +(`--a2065-net BACKEND` is the matching per-run flag.) + Unlike the DMAC boards, the LANCE does not master the Amiga bus: its init block, descriptor rings, and packet buffers live in the board's own 32 KiB RAM (which the CPU reaches through the board window), so the board is self-contained and owns its host network backend directly. -Host network backends live in `src/net.rs` behind the `NetBackend` trait. Built -in today is **loopback** (transmitted frames are queued straight back -- useful -for a self-contained demo and for tests); a userspace NAT (libslirp/smoltcp) and -a host TAP bridge are planned and will slot in behind `make_backend` under build -features. TAP will require host privileges and interface setup; NAT will not. +Host network backends live in `src/net/` behind the `NetBackend` trait. Two are +built in: + +- **`nat`** -- userspace NAT (`src/net/nat/`, behind the default-on `net-nat` + build feature): a slirp-style virtual gateway that NATs the guest's outbound + IPv4 onto ordinary host sockets. No host privileges, drivers, or setup, and + identical behavior on Linux, macOS, and Windows. The guest sees the QEMU/slirp + segment -- configure its TCP/IP stack with: + + | Setting | Value | + |---|---| + | IP address | `10.0.2.15` (or use the built-in BOOTP/DHCP server) | + | Netmask | `255.255.255.0` | + | Gateway | `10.0.2.2` | + | DNS server | `10.0.2.3` | + + DNS is answered through the host's own resolver; TCP and UDP to the gateway + address reach the host's `127.0.0.1`, so guest software can talk to services + on the host. Limitations: outbound only (no inbound connections or port + forwards yet), IPv4 only, no IP fragmentation, and ICMP echo is answered + locally by the gateway for any destination -- a ping "succeeding" proves the + NAT is up, not that the target is reachable. +- **`loopback`** -- transmitted frames are queued straight back; useful for a + self-contained demo, driver bring-up, and tests. + +A host TAP bridge would slot in behind `make_backend` the same way but is not +implemented; TAP would require host privileges and per-OS interface setup, +which is exactly what `nat` avoids. **Networking is non-deterministic.** Inbound frames arrive on the host's schedule, not the emulated clock, so a fitted A2065 (or any `net`-capable WASM diff --git a/packaging/flatpak/cargo-sources.json b/packaging/flatpak/cargo-sources.json index 2cd1a19f..0971245e 100644 --- a/packaging/flatpak/cargo-sources.json +++ b/packaging/flatpak/cargo-sources.json @@ -480,6 +480,19 @@ "dest": "cargo/vendor/bytemuck_derive-1.10.2", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/byteorder/byteorder-1.5.0.crate", + "sha256": "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b", + "dest": "cargo/vendor/byteorder-1.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b\", \"files\": {}}", + "dest": "cargo/vendor/byteorder-1.5.0", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -961,6 +974,58 @@ "dest": "cargo/vendor/dasp_sample-0.11.0", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/defmt/defmt-0.3.100.crate", + "sha256": "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad", + "dest": "cargo/vendor/defmt-0.3.100" + }, + { + "type": "inline", + "contents": "{\"package\": \"f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad\", \"files\": {}}", + "dest": "cargo/vendor/defmt-0.3.100", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/defmt/defmt-1.1.1.crate", + "sha256": "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1", + "dest": "cargo/vendor/defmt-1.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1\", \"files\": {}}", + "dest": "cargo/vendor/defmt-1.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/defmt-macros/defmt-macros-1.1.1.crate", + "sha256": "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8", + "dest": "cargo/vendor/defmt-macros-1.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8\", \"files\": {}}", + "dest": "cargo/vendor/defmt-macros-1.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/defmt-parser/defmt-parser-1.0.0.crate", + "sha256": "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e", + "dest": "cargo/vendor/defmt-parser-1.0.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e\", \"files\": {}}", + "dest": "cargo/vendor/defmt-parser-1.0.0", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -1494,6 +1559,19 @@ "dest": "cargo/vendor/half-2.7.1", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hash32/hash32-0.3.1.crate", + "sha256": "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606", + "dest": "cargo/vendor/hash32-0.3.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606\", \"files\": {}}", + "dest": "cargo/vendor/hash32-0.3.1", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -1546,6 +1624,19 @@ "dest": "cargo/vendor/hashbrown-0.17.1", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/heapless/heapless-0.8.0.crate", + "sha256": "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad", + "dest": "cargo/vendor/heapless-0.8.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad\", \"files\": {}}", + "dest": "cargo/vendor/heapless-0.8.0", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -2027,6 +2118,19 @@ "dest": "cargo/vendor/mach2-0.4.3", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/managed/managed-0.8.0.crate", + "sha256": "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d", + "dest": "cargo/vendor/managed-0.8.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d\", \"files\": {}}", + "dest": "cargo/vendor/managed-0.8.0", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -3613,6 +3717,19 @@ "dest": "cargo/vendor/smol_str-0.2.2", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/smoltcp/smoltcp-0.12.0.crate", + "sha256": "dad095989c1533c1c266d9b1e8d70a1329dd3723c3edac6d03bbd67e7bf6f4bb", + "dest": "cargo/vendor/smoltcp-0.12.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"dad095989c1533c1c266d9b1e8d70a1329dd3723c3edac6d03bbd67e7bf6f4bb\", \"files\": {}}", + "dest": "cargo/vendor/smoltcp-0.12.0", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", diff --git a/src/a2065.rs b/src/a2065.rs index 7062226c..b0c0990d 100644 --- a/src/a2065.rs +++ b/src/a2065.rs @@ -25,10 +25,13 @@ //! //! Word fields in the init block and descriptors are accessed big-endian, the //! layout a 68000 driver writes with the LANCE's byte-swap (CSR3 BSWP) set; -//! packet payloads are byte streams and are not swapped. Note: this models the -//! documented LANCE behaviour and is unit-tested against the programming model, -//! but has not yet been validated end-to-end against `a2065.device` plus a guest -//! TCP/IP stack -- see the tests and the Zorro chapter in the docs. +//! packet payloads are byte streams and are not swapped. The engine models the +//! Am7990 features a real driver exercises -- TX/RX buffer chaining across +//! descriptors, the stored FCS trailer counted in MCNT, the init-block MODE +//! gates (DTX/DRX and the LOOP internal-loopback self-test), and MISS on an RX +//! ring overrun -- and has been validated end-to-end against the AmigaOS +//! SANA-II `a2065.device` driving AmiTCP over the userspace NAT backend +//! (`crate::net::nat`). See the tests and the Zorro chapter in the docs. use crate::net::{make_backend, NetBackend, NetConfig}; use crate::zorro_device::{DeviceHost, ZorroDevice}; @@ -51,18 +54,37 @@ const CSR0_INTR: u16 = 1 << 7; const CSR0_IDON: u16 = 1 << 8; const CSR0_TINT: u16 = 1 << 9; const CSR0_RINT: u16 = 1 << 10; +const CSR0_MISS: u16 = 1 << 12; const CSR0_ERR: u16 = 1 << 15; /// Status bits the host clears by writing 1 (the rest of CSR0 is control). const CSR0_RC_BITS: u16 = CSR0_IDON | CSR0_TINT | CSR0_RINT | (0xF << 11) | CSR0_ERR; +// Init-block MODE word bits (Am7990). PROM (bit 15) and the LADRF hash are +// not modelled: the receiver accepts every frame the backend delivers, which +// behaves like PROM permanently set. A NAT/loopback backend only ever hands +// the board frames addressed to it (or broadcasts), so the filter would be +// a no-op anyway. +const MODE_DRX: u16 = 1 << 0; +const MODE_DTX: u16 = 1 << 1; +const MODE_LOOP: u16 = 1 << 2; + // Descriptor status byte (high byte of the second word). const DESC_OWN: u8 = 1 << 7; +const DESC_ERR: u8 = 1 << 6; +const DESC_BUFF: u8 = 1 << 2; const DESC_STP: u8 = 1 << 1; const DESC_ENP: u8 = 1 << 0; /// Largest Ethernet frame the chip will move (no jumbo frames). const MAX_FRAME: usize = 1518; +/// The frame check sequence the chip stores after a received payload. The +/// receiver writes the frame as it came off the wire, FCS included, and MCNT +/// counts it; drivers recover the payload length as `MCNT - 4`. No CRC is +/// modelled (drivers strip the FCS, they never verify it), so zeros are +/// stored. +const FCS_LEN: usize = 4; + #[derive(Serialize, Deserialize)] pub struct A2065 { /// Synthesized station MAC (locally administered). @@ -81,6 +103,7 @@ pub struct A2065 { csr3: u16, // Ring state derived from the init block on INIT. + mode: u16, rx_ring: u32, rx_len: u32, rx_mask: u32, @@ -113,6 +136,7 @@ impl A2065 { csr1: 0, csr2: 0, csr3: 0, + mode: 0, rx_ring: 0, rx_len: 0, rx_mask: 0, @@ -164,7 +188,7 @@ impl A2065 { fn lance_init(&mut self) { let iadr = self.init_block_addr(); // +0 MODE, +2..+8 PADR (MAC), +8..+10 LADRF (filter, ignored). - let _mode = self.ram_word(iadr); + self.mode = self.ram_word(iadr); for i in 0..3 { let w = self.ram_word(iadr + 2 + i * 2); // PADR is stored low-byte-first per word in LANCE order. @@ -213,57 +237,134 @@ impl A2065 { self.set_ram_word(dbase + 2, (u16::from(status) << 8) | (w & 0xFF)); } - /// Walk the TX ring, sending every chip-owned descriptor. + /// Walk the TX ring, sending every complete chip-owned frame. A frame may + /// span several descriptors (Am7990 buffer chaining: STP marks the first + /// buffer, ENP the last); the chip only takes a frame once the whole + /// STP..ENP span is chip-owned, so a frame the driver is still handing + /// over stays untouched until the ENP descriptor's OWN bit lands. fn poll_tx(&mut self) { - if !self.running || self.tx_len == 0 { + if !self.running || self.tx_len == 0 || self.mode & MODE_DTX != 0 { return; } - for _ in 0..self.tx_len { - let dbase = Self::desc_addr(self.tx_ring, self.tx_cur); - let status = self.desc_status(dbase); - if status & DESC_OWN == 0 { - break; // host still owns it: nothing to send + 'frames: loop { + // Scan the frame without consuming anything first. + let mut span: Vec<(u32, u32, usize)> = Vec::new(); // (desc, buf, len) + let mut desc = self.tx_cur; + loop { + if span.len() as u32 >= self.tx_len { + // The whole ring is chip-owned with no ENP anywhere: a + // malformed ring the real chip would flag BUFF on. Stall + // rather than spin. + break 'frames; + } + let dbase = Self::desc_addr(self.tx_ring, desc); + let status = self.desc_status(dbase); + if status & DESC_OWN == 0 { + break 'frames; // frame not fully handed over yet + } + if span.is_empty() && status & DESC_STP == 0 { + // Chip-owned buffer with no STP: the chip skips it while + // hunting for a start of packet, handing it back. + self.set_desc_status(dbase, status & !DESC_OWN); + self.tx_cur = (self.tx_cur + 1) & self.tx_mask; + continue 'frames; + } + let (buf_addr, len) = self.desc_buffer(dbase); + span.push((dbase, buf_addr, len)); + if status & DESC_ENP != 0 { + break; + } + desc = (desc + 1) & self.tx_mask; } - let (buf_addr, len) = self.desc_buffer(dbase); - let len = len.min(MAX_FRAME); - let mut frame = vec![0u8; len]; - for (i, b) in frame.iter_mut().enumerate() { - let a = (buf_addr as usize + i) & (RAM_SIZE - 1); - *b = self.ram[a]; + let mut frame = Vec::new(); + for &(_, buf_addr, len) in &span { + for i in 0..len { + if frame.len() >= MAX_FRAME { + break; + } + frame.push(self.ram[(buf_addr as usize + i) & (RAM_SIZE - 1)]); + } } - if let Some(net) = self.net.as_mut() { + if self.mode & MODE_LOOP != 0 { + // Internal loopback: the frame goes straight to our own + // receiver and never reaches the wire. SANA-II drivers run a + // power-up self-test this way before going online. + self.receive_frame(&frame); + } else if let Some(net) = self.net.as_mut() { net.send(&frame); } self.activity = true; - // Hand the descriptor back to the host, clearing OWN. - self.set_desc_status(dbase, status & !DESC_OWN); + for &(dbase, _, _) in &span { + let status = self.desc_status(dbase); + self.set_desc_status(dbase, status & !DESC_OWN); + } + self.tx_cur = (desc + 1) & self.tx_mask; self.csr0 |= CSR0_TINT; - self.tx_cur = (self.tx_cur + 1) & self.tx_mask; } } - /// Deliver one inbound frame into the next chip-owned RX descriptor. + /// Deliver one inbound frame into chip-owned RX descriptors, chaining + /// across buffers when the frame is longer than one buffer (Am7990: STP + /// set in the first descriptor, ENP and MCNT written in the last). The + /// stored message is payload plus [`FCS_LEN`] trailer bytes, as received + /// off the wire. fn receive_frame(&mut self, frame: &[u8]) -> bool { - if !self.running || self.rx_len == 0 { + if !self.running || self.rx_len == 0 || self.mode & MODE_DRX != 0 { return false; } - let dbase = Self::desc_addr(self.rx_ring, self.rx_cur); - let status = self.desc_status(dbase); - if status & DESC_OWN == 0 { - return false; // no free descriptor: drop (MISS would be set on HW) - } - let (buf_addr, cap) = self.desc_buffer(dbase); - let n = frame.len().min(cap).min(MAX_FRAME); - for (i, b) in frame.iter().take(n).enumerate() { - let a = (buf_addr as usize + i) & (RAM_SIZE - 1); - self.ram[a] = *b; + let total = (frame.len() + FCS_LEN).min(MAX_FRAME); + let mut remaining = total; + let mut copied = 0usize; + let mut desc = self.rx_cur; + let mut first = true; + loop { + let dbase = Self::desc_addr(self.rx_ring, desc); + let status = self.desc_status(dbase); + if status & DESC_OWN == 0 { + if first { + // No descriptor to start the frame in: missed frame. + self.csr0 |= CSR0_MISS; + return false; + } + // Ran out of chained buffers mid-frame: the last buffer the + // chip filled gets BUFF, the frame is truncated, no ENP. + let prev = (desc + self.rx_mask) & self.rx_mask; + let pbase = Self::desc_addr(self.rx_ring, prev); + let pstatus = self.desc_status(pbase); + self.set_desc_status(pbase, (pstatus & !DESC_ENP) | DESC_BUFF | DESC_ERR); + self.rx_cur = desc; + self.activity = true; + self.csr0 |= CSR0_RINT; + return true; + } + let (buf_addr, cap) = self.desc_buffer(dbase); + let n = remaining.min(cap); + for i in 0..n { + let a = (buf_addr as usize + i) & (RAM_SIZE - 1); + self.ram[a] = *frame.get(copied + i).unwrap_or(&0); + } + copied += n; + remaining -= n; + let mut st = status & !(DESC_OWN | DESC_ERR | DESC_BUFF | DESC_STP | DESC_ENP); + if first { + st |= DESC_STP; + } + if remaining == 0 { + // MCNT (message byte count, FCS included) is only valid in + // the descriptor carrying ENP. + st |= DESC_ENP; + self.set_ram_word(dbase + 6, total as u16); + } + self.set_desc_status(dbase, st); + desc = (desc + 1) & self.rx_mask; + first = false; + if remaining == 0 { + break; + } } - // MCNT (message byte count) at +6, plus STP|ENP and OWN cleared. - self.set_ram_word(dbase + 6, n as u16); - self.set_desc_status(dbase, (status & !DESC_OWN) | DESC_STP | DESC_ENP); + self.rx_cur = desc; self.activity = true; self.csr0 |= CSR0_RINT; - self.rx_cur = (self.rx_cur + 1) & self.rx_mask; true } @@ -370,8 +471,8 @@ impl ZorroDevice for A2065 { None => break, }; if !self.receive_frame(&frame) { - break; // ring full: leave the frame for next time would need a - // queue; for now it is dropped (a real chip sets MISS) + break; // no RX descriptor (MISS latched) or receiver off: + // the frame is lost, as on the real chip } } } @@ -394,6 +495,7 @@ impl ZorroDevice for A2065 { self.csr1 = 0; self.csr2 = 0; self.csr3 = 0; + self.mode = 0; self.running = false; self.ram.fill(0); self.net = make_backend(self.net_config); @@ -638,6 +740,294 @@ mod tests { assert!(!board.running); } + /// Lay an init block with the given MODE word plus multi-entry rings into + /// board RAM: RX ring at 0x100 (2^rx_log2 entries, chip-owned, each with a + /// rx_cap-byte buffer at 0x1000 + n*rx_cap), TX ring at 0x200 (2^tx_log2 + /// entries, host-owned, buffers at 0x3000 + n*0x200). + fn set_up_rings_geom( + board: &mut A2065, + mem: &mut crate::memory::Memory, + mode: u16, + rx_log2: u16, + rx_cap: u16, + tx_log2: u16, + ) { + let iadr = 0x000u32; + let rx_ring = 0x100u32; + let tx_ring = 0x200u32; + let put = |board: &mut A2065, mem: &mut crate::memory::Memory, addr: u32, w: u16| { + let mut host = DeviceHost::new(mem); + board.write(RAM_BASE + addr, 2, u32::from(w), &mut host); + }; + put(board, mem, iadr, mode); + put(board, mem, iadr + 2, 0x0201); + put(board, mem, iadr + 4, 0x0403); + put(board, mem, iadr + 6, 0x0605); + put(board, mem, iadr + 0x10, rx_ring as u16); + put( + board, + mem, + iadr + 0x12, + (rx_log2 << 13) | ((rx_ring >> 16) as u16 & 0xFF), + ); + put(board, mem, iadr + 0x14, tx_ring as u16); + put( + board, + mem, + iadr + 0x16, + (tx_log2 << 13) | ((tx_ring >> 16) as u16 & 0xFF), + ); + for n in 0..(1u32 << rx_log2) { + let dbase = rx_ring + n * 8; + let buf = 0x1000 + n * u32::from(rx_cap); + put(board, mem, dbase, buf as u16); + put( + board, + mem, + dbase + 2, + (u16::from(DESC_OWN) << 8) | ((buf >> 16) as u16 & 0xFF), + ); + put( + board, + mem, + dbase + 4, + 0xF000 | ((!rx_cap).wrapping_add(1) & 0x0FFF), + ); + } + for n in 0..(1u32 << tx_log2) { + let dbase = tx_ring + n * 8; + let buf = 0x3000 + n * 0x200; + put(board, mem, dbase, buf as u16); + put(board, mem, dbase + 2, (buf >> 16) as u16 & 0xFF); + } + write_csr(board, mem, 1, iadr as u16); + write_csr(board, mem, 2, (iadr >> 16) as u16); + } + + /// Hand TX descriptor `n` to the chip with a byte count and status bits. + fn arm_tx_desc( + board: &mut A2065, + mem: &mut crate::memory::Memory, + n: u32, + len: u16, + status: u8, + ) { + let dbase = 0x200 + n * 8; + let mut host = DeviceHost::new(mem); + board.write( + RAM_BASE + dbase + 4, + 2, + u32::from(0xF000 | ((!len).wrapping_add(1) & 0x0FFF)), + &mut host, + ); + board.write( + RAM_BASE + dbase + 2, + 2, + u32::from(u16::from(status) << 8), + &mut host, + ); + } + + fn rx_desc_status(board: &mut A2065, mem: &mut crate::memory::Memory, n: u32) -> u8 { + let mut host = DeviceHost::new(mem); + (board.read(RAM_BASE + 0x100 + n * 8 + 2, 2, &mut host) >> 8) as u8 + } + + fn fill_tx_buf(board: &mut A2065, mem: &mut crate::memory::Memory, n: u32, data: &[u8]) { + let mut host = DeviceHost::new(mem); + for (i, b) in data.iter().enumerate() { + board.write( + RAM_BASE + 0x3000 + n * 0x200 + i as u32, + 1, + u32::from(*b), + &mut host, + ); + } + } + + #[test] + fn tx_frame_chains_across_descriptors() { + let mut board = A2065::new(NetConfig::Loopback); + let mut mem = host_mem(); + set_up_rings_geom(&mut board, &mut mem, 0, 0, 256, 1); + write_csr(&mut board, &mut mem, 0, CSR0_INIT); + write_csr(&mut board, &mut mem, 0, CSR0_STRT | CSR0_INEA); + + let part0: Vec = (0..40u8).collect(); + let part1: Vec = (40..64u8).collect(); + fill_tx_buf(&mut board, &mut mem, 0, &part0); + fill_tx_buf(&mut board, &mut mem, 1, &part1); + + // Hand over only the STP half: the chip must not touch the frame yet. + arm_tx_desc(&mut board, &mut mem, 0, 40, DESC_OWN | DESC_STP); + write_csr(&mut board, &mut mem, 0, CSR0_TDMD); + assert_eq!(read_csr(&mut board, &mut mem, 0) & CSR0_TINT, 0); + assert!(board.net.as_mut().unwrap().poll().is_none()); + + // Complete the span with the ENP half: one 64-byte frame goes out. + arm_tx_desc(&mut board, &mut mem, 1, 24, DESC_OWN | DESC_ENP); + write_csr(&mut board, &mut mem, 0, CSR0_TDMD); + assert_ne!(read_csr(&mut board, &mut mem, 0) & CSR0_TINT, 0); + let frame = board.net.as_mut().unwrap().poll().expect("chained frame"); + assert_eq!(frame.len(), 64); + assert!(frame.iter().enumerate().all(|(i, b)| *b == i as u8)); + // Both descriptors handed back to the host. + let mut host = DeviceHost::new(&mut mem); + assert_eq!(board.read(RAM_BASE + 0x202, 2, &mut host) >> 8 & 0x80, 0); + assert_eq!(board.read(RAM_BASE + 0x20A, 2, &mut host) >> 8 & 0x80, 0); + } + + #[test] + fn rx_frame_chains_across_buffers() { + let mut board = A2065::new(NetConfig::Loopback); + let mut mem = host_mem(); + set_up_rings_geom(&mut board, &mut mem, 0, 2, 128, 0); + write_csr(&mut board, &mut mem, 0, CSR0_INIT); + write_csr(&mut board, &mut mem, 0, CSR0_STRT | CSR0_INEA); + + let frame: Vec = (0..300u16).map(|i| i as u8).collect(); + board.net.as_mut().unwrap().send(&frame); + { + let mut host = DeviceHost::new(&mut mem); + board.tick(1, &mut host); + } + + // 300 + 4 FCS = 304 bytes across three 128-byte buffers. + let d0 = rx_desc_status(&mut board, &mut mem, 0); + let d1 = rx_desc_status(&mut board, &mut mem, 1); + let d2 = rx_desc_status(&mut board, &mut mem, 2); + let d3 = rx_desc_status(&mut board, &mut mem, 3); + assert_eq!(d0 & (DESC_OWN | DESC_STP | DESC_ENP), DESC_STP); + assert_eq!(d1 & (DESC_OWN | DESC_STP | DESC_ENP), 0); + assert_eq!(d2 & (DESC_OWN | DESC_STP | DESC_ENP), DESC_ENP); + assert_ne!(d3 & DESC_OWN, 0, "fourth descriptor stays chip-owned"); + let mut host = DeviceHost::new(&mut mem); + // MCNT lives in the ENP descriptor and counts the FCS. + assert_eq!( + board.read(RAM_BASE + 0x100 + 2 * 8 + 6, 2, &mut host) & 0xFFF, + 304 + ); + // Payload spans the buffers; the FCS trailer bytes are zeros. + assert_eq!(board.read(RAM_BASE + 0x1000, 1, &mut host), 0); + assert_eq!(board.read(RAM_BASE + 0x1080, 1, &mut host), 128); + assert_eq!( + board.read(RAM_BASE + 0x1100 + 43, 1, &mut host), + 299u32 & 0xFF + ); + assert_eq!(board.read(RAM_BASE + 0x1100 + 44, 1, &mut host), 0); + assert_ne!(read_csr(&mut board, &mut mem, 0) & CSR0_RINT, 0); + } + + #[test] + fn rx_without_free_descriptor_sets_miss() { + let mut board = A2065::new(NetConfig::Loopback); + let mut mem = host_mem(); + set_up_rings_geom(&mut board, &mut mem, 0, 0, 256, 0); + write_csr(&mut board, &mut mem, 0, CSR0_INIT); + write_csr(&mut board, &mut mem, 0, CSR0_STRT | CSR0_INEA); + // Take the only RX descriptor away from the chip. + { + let mut host = DeviceHost::new(&mut mem); + board.write(RAM_BASE + 0x102, 2, 0, &mut host); + } + board.net.as_mut().unwrap().send(&[1, 2, 3]); + { + let mut host = DeviceHost::new(&mut mem); + board.tick(1, &mut host); + } + let csr0 = read_csr(&mut board, &mut mem, 0); + assert_ne!(csr0 & CSR0_MISS, 0); + assert_ne!(csr0 & CSR0_ERR, 0, "MISS rolls up into ERR"); + assert_eq!(csr0 & CSR0_RINT, 0); + } + + #[test] + fn rx_out_of_chained_buffers_sets_buff() { + let mut board = A2065::new(NetConfig::Loopback); + let mut mem = host_mem(); + set_up_rings_geom(&mut board, &mut mem, 0, 0, 128, 0); + write_csr(&mut board, &mut mem, 0, CSR0_INIT); + write_csr(&mut board, &mut mem, 0, CSR0_STRT | CSR0_INEA); + board.net.as_mut().unwrap().send(&[0x55u8; 200]); + { + let mut host = DeviceHost::new(&mut mem); + board.tick(1, &mut host); + } + let d0 = rx_desc_status(&mut board, &mut mem, 0); + assert_eq!(d0 & DESC_OWN, 0); + assert_ne!(d0 & DESC_STP, 0); + assert_eq!(d0 & DESC_ENP, 0, "truncated frame has no end of packet"); + assert_ne!(d0 & DESC_BUFF, 0); + assert_ne!(d0 & DESC_ERR, 0); + assert_ne!(read_csr(&mut board, &mut mem, 0) & CSR0_RINT, 0); + } + + #[test] + fn mode_loop_returns_tx_frames_to_own_receiver() { + // No backend at all: internal loopback never reaches the wire. + let mut board = A2065::new(NetConfig::None); + let mut mem = host_mem(); + set_up_rings_geom(&mut board, &mut mem, MODE_LOOP, 0, 256, 0); + write_csr(&mut board, &mut mem, 0, CSR0_INIT); + write_csr(&mut board, &mut mem, 0, CSR0_STRT | CSR0_INEA); + + let data: Vec = (0..32u8).map(|i| i ^ 0x5A).collect(); + fill_tx_buf(&mut board, &mut mem, 0, &data); + arm_tx_desc(&mut board, &mut mem, 0, 32, DESC_OWN | DESC_STP | DESC_ENP); + write_csr(&mut board, &mut mem, 0, CSR0_TDMD); + + let csr0 = read_csr(&mut board, &mut mem, 0); + assert_ne!(csr0 & CSR0_TINT, 0); + assert_ne!(csr0 & CSR0_RINT, 0, "looped frame lands in the RX ring"); + let mut host = DeviceHost::new(&mut mem); + assert_eq!(board.read(RAM_BASE + 0x1000, 1, &mut host), 0x5A); + assert_eq!(board.read(RAM_BASE + 0x100 + 6, 2, &mut host) & 0xFFF, 36); + } + + #[test] + fn mode_dtx_drx_disable_the_engines() { + let mut board = A2065::new(NetConfig::Loopback); + let mut mem = host_mem(); + set_up_rings_geom(&mut board, &mut mem, MODE_DTX | MODE_DRX, 0, 256, 0); + write_csr(&mut board, &mut mem, 0, CSR0_INIT); + write_csr(&mut board, &mut mem, 0, CSR0_STRT | CSR0_INEA); + + fill_tx_buf(&mut board, &mut mem, 0, &[1, 2, 3, 4]); + arm_tx_desc(&mut board, &mut mem, 0, 4, DESC_OWN | DESC_STP | DESC_ENP); + write_csr(&mut board, &mut mem, 0, CSR0_TDMD); + assert_eq!(read_csr(&mut board, &mut mem, 0) & CSR0_TINT, 0); + assert!(board.net.as_mut().unwrap().poll().is_none()); + + board.net.as_mut().unwrap().send(&[9, 9, 9]); + { + let mut host = DeviceHost::new(&mut mem); + board.tick(1, &mut host); + } + assert_eq!(read_csr(&mut board, &mut mem, 0) & CSR0_RINT, 0); + assert_ne!(rx_desc_status(&mut board, &mut mem, 0) & DESC_OWN, 0); + } + + #[test] + fn rx_appends_fcs_to_short_frame() { + let mut board = A2065::new(NetConfig::Loopback); + let mut mem = host_mem(); + set_up_rings_geom(&mut board, &mut mem, 0, 0, 256, 0); + write_csr(&mut board, &mut mem, 0, CSR0_INIT); + write_csr(&mut board, &mut mem, 0, CSR0_STRT | CSR0_INEA); + board.net.as_mut().unwrap().send(&[0xAA, 0xBB, 0xCC, 0xDD]); + { + let mut host = DeviceHost::new(&mut mem); + board.tick(1, &mut host); + } + let mut host = DeviceHost::new(&mut mem); + assert_eq!(board.read(RAM_BASE + 0x100 + 6, 2, &mut host) & 0xFFF, 8); + assert_eq!(board.read(RAM_BASE + 0x1000 + 3, 1, &mut host), 0xDD); + assert_eq!(board.read(RAM_BASE + 0x1000 + 4, 1, &mut host), 0); + assert_eq!(board.read(RAM_BASE + 0x1000 + 7, 1, &mut host), 0); + let d0 = rx_desc_status(&mut board, &mut mem, 0); + assert_eq!(d0 & (DESC_STP | DESC_ENP), DESC_STP | DESC_ENP); + } + #[test] fn mac_prom_is_readable_at_even_bytes() { let mut board = A2065::new(NetConfig::None); diff --git a/src/config.rs b/src/config.rs index 177d8788..385c6a5a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1277,6 +1277,9 @@ pub struct ConfigOverrides { /// Freeze the seeded RTC (`--rtc-frozen`). Same as /// `[machine] rtc_frozen`. pub rtc_frozen: Option, + /// A2065 Ethernet backend (`--a2065-net`): "none", "loopback", or "nat". + /// Same parser as `[a2065] net`; setting it fits the board. + pub a2065_net: Option, } impl ConfigOverrides { @@ -1306,6 +1309,7 @@ impl ConfigOverrides { && self.audio_stereo_separation.is_none() && self.rtc_time.is_none() && self.rtc_frozen.is_none() + && self.a2065_net.is_none() } /// Inject the set overrides into the raw config, replacing the values @@ -1403,6 +1407,9 @@ impl ConfigOverrides { if let Some(frozen) = self.rtc_frozen { raw.machine.rtc_frozen = Some(frozen); } + if let Some(net) = &self.a2065_net { + raw.a2065.net = Some(net.clone()); + } } } @@ -2311,7 +2318,7 @@ impl TryFrom for Config { None => None, Some(s) => Some(crate::net::parse_net_config(s).ok_or_else(|| { anyhow::anyhow!( - "[a2065] net = {:?} is not a known backend (expected \"none\" or \"loopback\")", + "[a2065] net = {:?} is not a known backend (expected \"none\", \"loopback\", or \"nat\")", s ) })?), diff --git a/src/main.rs b/src/main.rs index 147f9ce9..32983d94 100644 --- a/src/main.rs +++ b/src/main.rs @@ -432,6 +432,11 @@ where anyhow!("--serial-connect requires an address (host:port)") })?); } + "--a2065-net" => { + overrides.a2065_net = Some(args.next().ok_or_else(|| { + anyhow!("--a2065-net requires a backend (none/loopback/nat)") + })?); + } "--midi-out" => { overrides.midi_out = Some( args.next() @@ -965,6 +970,8 @@ fn print_help() { \x20 tcp-connect, or pty\n \ --serial-connect HOST:PORT dial a remote TCP service (a telnet BBS) with the\n \ \x20 serial port (implies --serial tcp-connect)\n \ + --a2065-net BACKEND fit an A2065 Ethernet board: none, loopback, or\n \ + \x20 nat (user-mode NAT, no host privileges)\n \ --parallel DEVICE parallel port: none, printer, or sampler\n \ --sampler-audio-input NAME sampler host capture device (implies --parallel sampler)\n \ --sampler-input-gain DB sampler input gain in dB (implies --parallel sampler)\n \ diff --git a/src/net.rs b/src/net/mod.rs similarity index 71% rename from src/net.rs rename to src/net/mod.rs index 19028858..2d1fdcf0 100644 --- a/src/net.rs +++ b/src/net/mod.rs @@ -15,16 +15,22 @@ //! only the board's chosen backend ([`NetConfig`]) and brings up a fresh //! backend on load (in-flight frames are dropped; the guest's TCP retransmits). //! -//! One backend is built in: [`LoopbackBackend`] (frames echo back to the -//! sender, for tests and a self-contained two-station demo). [`NetConfig::None`] -//! brings up no backend at all, which is how an isolated NIC (one with the -//! capability but no host connectivity) is expressed. Userspace NAT -//! (libslirp/smoltcp) and a host TAP bridge are planned and will slot in behind -//! [`make_backend`] under build features. +//! Two backends are built in: [`LoopbackBackend`] (frames echo back to the +//! sender, for tests and a self-contained two-station demo) and, behind the +//! `net-nat` build feature, the userspace NAT in [`nat`] -- a slirp-style +//! virtual gateway (guest 10.0.2.15, gateway 10.0.2.2, DNS 10.0.2.3) that +//! gives the guest outbound IPv4 through ordinary host sockets, with no host +//! privileges. [`NetConfig::None`] brings up no backend at all, which is how +//! an isolated NIC (one with the capability but no host connectivity) is +//! expressed. A host TAP bridge would also slot in behind [`make_backend`] +//! under a build feature, but is not implemented. use serde::{Deserialize, Serialize}; use std::collections::VecDeque; +#[cfg(all(feature = "net-nat", not(target_arch = "wasm32")))] +pub mod nat; + /// A host networking backend an emulated NIC sends and receives frames through. /// `Send` so it can live in a wasmtime store's data (which the bus owns). pub trait NetBackend: Send { @@ -47,6 +53,12 @@ pub enum NetConfig { /// guest see its own broadcasts and supports a self-contained demo without /// touching the host network; also the deterministic backend for tests. Loopback, + /// Userspace NAT: a virtual gateway NATs the guest's IPv4 onto ordinary + /// host sockets (no privileges, outbound only). Needs the `net-nat` build + /// feature; without it the variant still parses and saves, but no backend + /// comes up. The variant stays a unit (no per-flow options) so the config + /// remains `Copy`; host port-forwards would need a richer shape. + Nat, } /// Bring up the live backend a [`NetConfig`] names. `None` means the board has @@ -55,6 +67,13 @@ pub fn make_backend(cfg: NetConfig) -> Option> { match cfg { NetConfig::None => None, NetConfig::Loopback => Some(Box::new(LoopbackBackend::default())), + #[cfg(all(feature = "net-nat", not(target_arch = "wasm32")))] + NetConfig::Nat => Some(Box::new(nat::NatBackend::new())), + #[cfg(not(all(feature = "net-nat", not(target_arch = "wasm32"))))] + NetConfig::Nat => { + log::warn!("net = \"nat\" needs the net-nat build feature; NIC left isolated"); + None + } } } @@ -79,6 +98,7 @@ pub fn parse_net_config(s: &str) -> Option { match s.trim().to_ascii_lowercase().as_str() { "none" | "off" | "" => Some(NetConfig::None), "loopback" | "loop" => Some(NetConfig::Loopback), + "nat" => Some(NetConfig::Nat), _ => None, } } @@ -103,6 +123,7 @@ mod tests { assert_eq!(parse_net_config("loopback"), Some(NetConfig::Loopback)); assert_eq!(parse_net_config("None"), Some(NetConfig::None)); assert_eq!(parse_net_config(""), Some(NetConfig::None)); + assert_eq!(parse_net_config("nat"), Some(NetConfig::Nat)); assert_eq!(parse_net_config("tap0"), None); } diff --git a/src/net/nat/dhcp.rs b/src/net/nat/dhcp.rs new file mode 100644 index 00000000..e35e9265 --- /dev/null +++ b/src/net/nat/dhcp.rs @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! BOOTP/DHCP responder for the virtual segment. Answers both real DHCP +//! (DISCOVER -> OFFER, REQUEST/INFORM -> ACK) and plain BOOTP requests -- +//! AmiTCP-era guest stacks often speak the older protocol -- always handing +//! out the one fixed lease: guest 10.0.2.15/24, router 10.0.2.2, DNS +//! 10.0.2.3. Pure frame-in/frame-out, no state. + +use smoltcp::wire::{EthernetAddress, EthernetFrame, Ipv4Address, Ipv4Packet, UdpPacket}; + +use super::{frames, DNS_IP, GATEWAY_IP, GUEST_IP}; + +pub const SERVER_PORT: u16 = 67; +const CLIENT_PORT: u16 = 68; +/// RFC 1048 vendor-extension / DHCP option magic. The same cookie covers +/// plain BOOTP replies, whose clients read the same option format. +const OPTION_MAGIC: [u8; 4] = [0x63, 0x82, 0x53, 0x63]; +const BOOTP_HDR: usize = 236; + +/// Handle one guest frame already known to be UDP to port 67. Returns the +/// complete reply frame, if the request deserves one. +pub fn handle_frame(frame: &[u8]) -> Option> { + let eth = EthernetFrame::new_checked(frame).ok()?; + let ip = Ipv4Packet::new_checked(eth.payload()).ok()?; + let udp = UdpPacket::new_checked(ip.payload()).ok()?; + let p = udp.payload(); + if p.len() < BOOTP_HDR { + return None; + } + // BOOTREQUEST over Ethernet with a 6-byte hardware address. + if p[0] != 1 || p[1] != 1 || p[2] != 6 { + return None; + } + let xid: [u8; 4] = p[4..8].try_into().ok()?; + let flags = u16::from_be_bytes([p[10], p[11]]); + let chaddr: [u8; 6] = p[28..34].try_into().ok()?; + let msg_type = if p.len() > BOOTP_HDR + 4 && p[BOOTP_HDR..BOOTP_HDR + 4] == OPTION_MAGIC { + find_option(&p[BOOTP_HDR + 4..], 53).and_then(|v| v.first().copied()) + } else { + None + }; + let reply_type = match msg_type { + None => None, // plain BOOTP: a BOOTREPLY, no option 53 + Some(1) => Some(2u8), // DISCOVER -> OFFER + Some(3) | Some(8) => Some(5), // REQUEST / INFORM -> ACK + Some(_) => return None, // DECLINE, RELEASE, ...: nothing to say + }; + + let mut b = vec![0u8; BOOTP_HDR]; + b[0] = 2; // BOOTREPLY + b[1] = 1; + b[2] = 6; + b[4..8].copy_from_slice(&xid); + b[10..12].copy_from_slice(&flags.to_be_bytes()); + b[16..20].copy_from_slice(&GUEST_IP.octets()); // yiaddr: the one lease + b[20..24].copy_from_slice(&GATEWAY_IP.octets()); // siaddr + b[28..34].copy_from_slice(&chaddr); + b.extend_from_slice(&OPTION_MAGIC); + if let Some(t) = reply_type { + b.extend_from_slice(&[53, 1, t]); + b.extend_from_slice(&[54, 4]); // server identifier + b.extend_from_slice(&GATEWAY_IP.octets()); + b.extend_from_slice(&[51, 4]); // lease time + b.extend_from_slice(&86_400u32.to_be_bytes()); + } + b.extend_from_slice(&[1, 4, 255, 255, 255, 0]); // subnet mask + b.extend_from_slice(&[3, 4]); // router + b.extend_from_slice(&GATEWAY_IP.octets()); + b.extend_from_slice(&[6, 4]); // DNS server + b.extend_from_slice(&DNS_IP.octets()); + b.push(255); + + // Unicast to the offered address unless the client set the broadcast + // flag (it does not have an IP to receive unicast on yet). + let broadcast = flags & 0x8000 != 0; + let dst_mac = if broadcast { + EthernetAddress::BROADCAST + } else { + EthernetAddress(chaddr) + }; + let dst_ip = if broadcast { + Ipv4Address::BROADCAST + } else { + GUEST_IP + }; + Some(frames::build_udp( + dst_mac, + GATEWAY_IP, + SERVER_PORT, + dst_ip, + CLIENT_PORT, + &b, + )) +} + +fn find_option(mut opts: &[u8], code: u8) -> Option<&[u8]> { + loop { + match *opts.first()? { + 0 => opts = &opts[1..], // pad + 255 => return None, // end + c => { + let len = *opts.get(1)? as usize; + let val = opts.get(2..2 + len)?; + if c == code { + return Some(val); + } + opts = &opts[2 + len..]; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request(msg_type: Option, flags: u16) -> Vec { + let mut p = vec![0u8; BOOTP_HDR]; + p[0] = 1; // BOOTREQUEST + p[1] = 1; + p[2] = 6; + p[4..8].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]); + p[10..12].copy_from_slice(&flags.to_be_bytes()); + p[28..34].copy_from_slice(&[2, 0, 0x10, 0, 0, 1]); + if let Some(t) = msg_type { + p.extend_from_slice(&OPTION_MAGIC); + p.extend_from_slice(&[53, 1, t, 255]); + } + frames::build_udp( + EthernetAddress::BROADCAST, + Ipv4Address::UNSPECIFIED, + CLIENT_PORT, + Ipv4Address::BROADCAST, + SERVER_PORT, + &p, + ) + } + + fn reply_payload(frame: &[u8]) -> Vec { + let eth = EthernetFrame::new_checked(frame).unwrap(); + let ip = Ipv4Packet::new_checked(eth.payload()).unwrap(); + let udp = UdpPacket::new_checked(ip.payload()).unwrap(); + udp.payload().to_vec() + } + + #[test] + fn discover_gets_offer_with_the_fixed_lease() { + let reply = handle_frame(&request(Some(1), 0)).expect("offer"); + let p = reply_payload(&reply); + assert_eq!(p[0], 2, "BOOTREPLY"); + assert_eq!(&p[4..8], &[0xDE, 0xAD, 0xBE, 0xEF], "xid echoed"); + assert_eq!(&p[16..20], &GUEST_IP.octets(), "yiaddr"); + let opts = &p[BOOTP_HDR + 4..]; + assert_eq!(find_option(opts, 53), Some(&[2u8][..]), "OFFER"); + assert_eq!(find_option(opts, 3), Some(&GATEWAY_IP.octets()[..])); + assert_eq!(find_option(opts, 6), Some(&DNS_IP.octets()[..])); + assert_eq!(find_option(opts, 1), Some(&[255, 255, 255, 0][..])); + } + + #[test] + fn request_gets_ack_and_bootp_gets_plain_reply() { + let ack = reply_payload(&handle_frame(&request(Some(3), 0)).unwrap()); + assert_eq!( + find_option(&ack[BOOTP_HDR + 4..], 53), + Some(&[5u8][..]), + "ACK" + ); + let bootp = reply_payload(&handle_frame(&request(None, 0)).unwrap()); + assert_eq!(find_option(&bootp[BOOTP_HDR + 4..], 53), None); + assert_eq!(&bootp[16..20], &GUEST_IP.octets()); + } + + #[test] + fn broadcast_flag_addresses_the_reply_to_broadcast() { + let reply = handle_frame(&request(Some(1), 0x8000)).unwrap(); + let eth = EthernetFrame::new_checked(&reply[..]).unwrap(); + assert_eq!(eth.dst_addr(), EthernetAddress::BROADCAST); + let ip = Ipv4Packet::new_checked(eth.payload()).unwrap(); + assert_eq!(ip.dst_addr(), Ipv4Address::BROADCAST); + } +} diff --git a/src/net/nat/dns.rs b/src/net/nat/dns.rs new file mode 100644 index 00000000..8c5efb7a --- /dev/null +++ b/src/net/nat/dns.rs @@ -0,0 +1,337 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! DNS forwarder at 10.0.2.3: answers the guest's A queries through the +//! host's own resolver (getaddrinfo via `ToSocketAddrs`), so whatever the +//! host uses -- resolv.conf, VPN, DoH -- just works, with no per-OS resolver +//! discovery. Synthesized answers hold a single A record and stay far under +//! 512 bytes, so the guest never falls back to DNS over TCP. +//! +//! getaddrinfo blocks, so each lookup runs on a short-lived worker thread +//! (capped); results come back over a channel and are turned into reply +//! frames by the engine. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{mpsc, Arc}; + +pub const PORT: u16 = 53; +/// Lookups in flight before the forwarder answers SERVFAIL (the guest +/// resolver retries). +const MAX_OUTSTANDING: usize = 8; +const TTL_SECS: u32 = 60; + +const QTYPE_A: u16 = 1; +const QTYPE_PTR: u16 = 12; +#[cfg(test)] +const QTYPE_AAAA: u16 = 28; + +const RCODE_OK: u8 = 0; +const RCODE_SERVFAIL: u8 = 2; +const RCODE_NXDOMAIN: u8 = 3; + +/// Reverse names for the virtual segment's own addresses. Vintage BSD +/// resolvers treat NOTIMP/SERVFAIL as "this server is unusable" and stop +/// querying it altogether (AmiTCP's kernel resolver caches that verdict), +/// so every PTR gets a well-formed answer: a name for our addresses, +/// NXDOMAIN for everything else, never a server error. +const PTR_NAMES: [(&str, &str); 3] = [ + ("15.2.0.10.in-addr.arpa", "amiga.local"), + ("2.2.0.10.in-addr.arpa", "gateway.local"), + ("3.2.0.10.in-addr.arpa", "dns.local"), +]; + +pub struct DnsResolver { + results_tx: mpsc::Sender<(u16, Vec)>, + results_rx: mpsc::Receiver<(u16, Vec)>, + outstanding: Arc, +} + +impl Default for DnsResolver { + fn default() -> Self { + let (results_tx, results_rx) = mpsc::channel(); + Self { + results_tx, + results_rx, + outstanding: Arc::new(AtomicUsize::new(0)), + } + } +} + +impl DnsResolver { + /// Ingest one query datagram from the guest (UDP payload only). + /// Unparseable queries are dropped silently, like a dead server. + pub fn handle_query(&mut self, guest_port: u16, payload: &[u8]) { + let Some(q) = Query::parse(payload) else { + log::debug!( + "nat dns: unparseable query ({} bytes), dropped", + payload.len() + ); + return; + }; + log::debug!( + "nat dns: query id={} qtype={} name={:?}", + q.id, + q.qtype, + q.name + ); + match q.qtype { + QTYPE_A => { + if self.outstanding.load(Ordering::Relaxed) >= MAX_OUTSTANDING { + let _ = self + .results_tx + .send((guest_port, q.response(None, RCODE_SERVFAIL))); + return; + } + self.outstanding.fetch_add(1, Ordering::Relaxed); + let tx = self.results_tx.clone(); + let outstanding = Arc::clone(&self.outstanding); + // Built before the closure consumes `q`, so the spawn-failure + // path can still answer (a fast SERVFAIL beats a DNS timeout). + let servfail = q.response(None, RCODE_SERVFAIL); + let spawned = std::thread::Builder::new() + .name("a2065-nat-dns".into()) + .spawn(move || { + let addr = resolve_a(&q.name); + let rcode = if addr.is_some() { + RCODE_OK + } else { + RCODE_NXDOMAIN + }; + log::debug!( + "nat dns: answer name={:?} addr={addr:?} rcode={rcode}", + q.name + ); + let answer = addr.map(|a| (QTYPE_A, a.octets().to_vec())); + let _ = tx.send((guest_port, q.response(answer, rcode))); + outstanding.fetch_sub(1, Ordering::Relaxed); + }); + if spawned.is_err() { + self.outstanding.fetch_sub(1, Ordering::Relaxed); + let _ = self.results_tx.send((guest_port, servfail)); + } + } + QTYPE_PTR => { + let lname = q.name.to_ascii_lowercase(); + let hit = PTR_NAMES.iter().find(|(rev, _)| *rev == lname); + let reply = match hit { + Some((_, host)) => q.response(Some((QTYPE_PTR, encode_name(host))), RCODE_OK), + None => q.response(None, RCODE_NXDOMAIN), + }; + let _ = self.results_tx.send((guest_port, reply)); + } + // No IPv6 on the segment (empty NOERROR steers dual-stack + // resolvers straight to the A record), and unknown types get + // the same NODATA shape rather than a server error. + _ => { + let _ = self + .results_tx + .send((guest_port, q.response(None, RCODE_OK))); + } + } + } + + /// Completed responses as (guest source port, DNS message payload). + pub fn poll_results(&mut self) -> Vec<(u16, Vec)> { + let mut out = Vec::new(); + while let Ok(r) = self.results_rx.try_recv() { + out.push(r); + } + out + } +} + +struct Query { + id: u16, + /// The raw question section, echoed verbatim into the response. + question: Vec, + name: String, + qtype: u16, +} + +impl Query { + fn parse(p: &[u8]) -> Option { + if p.len() < 12 { + return None; + } + let id = u16::from_be_bytes([p[0], p[1]]); + let flags = u16::from_be_bytes([p[2], p[3]]); + if flags & 0x8000 != 0 { + return None; // a response, not a query + } + let qdcount = u16::from_be_bytes([p[4], p[5]]); + if qdcount == 0 { + return None; + } + let mut name = String::new(); + let mut i = 12usize; + loop { + let len = *p.get(i)? as usize; + if len == 0 { + i += 1; + break; + } + if len & 0xC0 != 0 { + return None; // compressed qname: nobody sends this + } + let label = p.get(i + 1..i + 1 + len)?; + if !name.is_empty() { + name.push('.'); + } + name.push_str(std::str::from_utf8(label).ok()?); + if name.len() > 253 { + return None; + } + i += 1 + len; + } + let qtype = u16::from_be_bytes([*p.get(i)?, *p.get(i + 1)?]); + let question = p.get(12..i + 4)?.to_vec(); + Some(Self { + id, + question, + name, + qtype, + }) + } + + /// Build the full response message for this query; `answer` is one + /// record as (rr type, rdata) under the query name. + fn response(&self, answer: Option<(u16, Vec)>, rcode: u8) -> Vec { + let mut r = Vec::with_capacity(12 + self.question.len() + 16); + r.extend_from_slice(&self.id.to_be_bytes()); + // QR + RD + RA, plus the rcode. + r.extend_from_slice(&(0x8180u16 | u16::from(rcode)).to_be_bytes()); + r.extend_from_slice(&1u16.to_be_bytes()); // qdcount + r.extend_from_slice(&u16::from(answer.is_some()).to_be_bytes()); // ancount + r.extend_from_slice(&0u16.to_be_bytes()); // nscount + r.extend_from_slice(&0u16.to_be_bytes()); // arcount + r.extend_from_slice(&self.question); + if let Some((rrtype, rdata)) = answer { + r.extend_from_slice(&[0xC0, 0x0C]); // pointer to the qname + r.extend_from_slice(&rrtype.to_be_bytes()); + r.extend_from_slice(&1u16.to_be_bytes()); // class IN + r.extend_from_slice(&TTL_SECS.to_be_bytes()); + r.extend_from_slice(&(rdata.len() as u16).to_be_bytes()); + r.extend_from_slice(&rdata); + } + r + } +} + +/// DNS wire encoding of a dotted name (labels + terminator). +fn encode_name(name: &str) -> Vec { + let mut out = Vec::with_capacity(name.len() + 2); + for label in name.split('.') { + out.push(label.len() as u8); + out.extend_from_slice(label.as_bytes()); + } + out.push(0); + out +} + +fn resolve_a(name: &str) -> Option { + // Only hostname-shaped strings reach getaddrinfo. + let ok = !name.is_empty() + && name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.' || b == b'_'); + if !ok { + return None; + } + use std::net::ToSocketAddrs; + (name, 80u16) + .to_socket_addrs() + .ok()? + .find_map(|sa| match sa { + std::net::SocketAddr::V4(v4) => Some(*v4.ip()), + _ => None, + }) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + pub(crate) fn build_query(id: u16, name: &str, qtype: u16) -> Vec { + let mut q = Vec::new(); + q.extend_from_slice(&id.to_be_bytes()); + q.extend_from_slice(&0x0100u16.to_be_bytes()); // RD + q.extend_from_slice(&1u16.to_be_bytes()); + q.extend_from_slice(&[0, 0, 0, 0, 0, 0]); + for label in name.split('.') { + q.push(label.len() as u8); + q.extend_from_slice(label.as_bytes()); + } + q.push(0); + q.extend_from_slice(&qtype.to_be_bytes()); + q.extend_from_slice(&1u16.to_be_bytes()); + q + } + + fn wait_result(r: &mut DnsResolver) -> (u16, Vec) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + if let Some(res) = r.poll_results().pop() { + return res; + } + assert!(std::time::Instant::now() < deadline, "no DNS result"); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } + + #[test] + fn a_query_for_localhost_resolves_offline() { + let mut r = DnsResolver::default(); + r.handle_query(4242, &build_query(7, "localhost", QTYPE_A)); + let (port, resp) = wait_result(&mut r); + assert_eq!(port, 4242); + assert_eq!(u16::from_be_bytes([resp[0], resp[1]]), 7, "id echoed"); + assert_eq!(resp[3] & 0x0F, RCODE_OK); + let ancount = u16::from_be_bytes([resp[6], resp[7]]); + assert_eq!(ancount, 1); + assert_eq!(&resp[resp.len() - 4..], &[127, 0, 0, 1], "A 127.0.0.1"); + } + + #[test] + #[ignore = "needs internet: resolves a real external name"] + fn a_query_for_an_external_name_resolves() { + let mut r = DnsResolver::default(); + r.handle_query(9999, &build_query(21, "frogfind.com", QTYPE_A)); + let (_, resp) = wait_result(&mut r); + assert_eq!(resp[3] & 0x0F, RCODE_OK, "rcode: {:02x?}", &resp[..12]); + assert_eq!(u16::from_be_bytes([resp[6], resp[7]]), 1, "one A answer"); + } + + #[test] + fn aaaa_and_unknown_types_are_answered_nodata() { + let mut r = DnsResolver::default(); + r.handle_query(1, &build_query(8, "localhost", QTYPE_AAAA)); + let (_, resp) = wait_result(&mut r); + assert_eq!(resp[3] & 0x0F, RCODE_OK); + assert_eq!(u16::from_be_bytes([resp[6], resp[7]]), 0, "no answers"); + + // Never a server error: AmiTCP's resolver caches those as a dead + // server and stops querying entirely. + r.handle_query(2, &build_query(9, "localhost", 15 /* MX */)); + let (_, resp) = wait_result(&mut r); + assert_eq!(resp[3] & 0x0F, RCODE_OK); + assert_eq!(u16::from_be_bytes([resp[6], resp[7]]), 0, "no answers"); + } + + #[test] + fn ptr_for_segment_addresses_answers_and_others_nxdomain() { + let mut r = DnsResolver::default(); + r.handle_query(3, &build_query(10, "15.2.0.10.in-addr.arpa", QTYPE_PTR)); + let (_, resp) = wait_result(&mut r); + assert_eq!(resp[3] & 0x0F, RCODE_OK); + assert_eq!(u16::from_be_bytes([resp[6], resp[7]]), 1); + let tail = &resp[resp.len() - encode_name("amiga.local").len()..]; + assert_eq!(tail, &encode_name("amiga.local")[..]); + + r.handle_query( + 4, + &build_query(11, "255.255.255.255.in-addr.arpa", QTYPE_PTR), + ); + let (_, resp) = wait_result(&mut r); + assert_eq!(resp[3] & 0x0F, RCODE_NXDOMAIN); + assert_eq!(u16::from_be_bytes([resp[6], resp[7]]), 0); + } +} diff --git a/src/net/nat/engine.rs b/src/net/nat/engine.rs new file mode 100644 index 00000000..74b93d99 --- /dev/null +++ b/src/net/nat/engine.rs @@ -0,0 +1,601 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The NAT engine: classifies guest frames, runs the smoltcp interface that +//! terminates ARP and TCP on the virtual gateway, and pumps the frame-level +//! responders (DHCP, DNS, UDP NAT, ICMP echo). +//! +//! The engine is a plain synchronous struct with no thread of its own -- +//! `handle_guest_frame` ingests one frame, `step` advances everything once, +//! `pop_output` drains guest-bound frames -- so tests drive it directly and +//! the `a2065-nat` thread in the parent module is a thin pump around it. + +use smoltcp::iface::{Config, Interface, SocketSet}; +use smoltcp::phy::{Device, DeviceCapabilities, Medium, RxToken, TxToken}; +use smoltcp::time::Instant; +use smoltcp::wire::{ + ArpOperation, ArpPacket, ArpRepr, EthernetAddress, EthernetFrame, EthernetProtocol, + HardwareAddress, IpAddress, IpCidr, IpProtocol, Ipv4Packet, UdpPacket, +}; +use std::collections::VecDeque; + +use super::{dhcp, dns, frames, tcp, udp}; +use super::{DNS_IP, GATEWAY_IP, GATEWAY_MAC, PREFIX_LEN}; + +/// A guest-bound Ethernet frame shorter than this is padded with zeros, as +/// the wire minimum (64 with FCS) guarantees to a real receiver. +const MIN_FRAME: usize = 60; + +/// Largest UDP payload that fits one un-fragmented frame on the segment +/// (1500 MTU minus 20-byte IPv4 and 8-byte UDP headers). There is no IP +/// fragmentation, so a larger host reply is dropped rather than framed into +/// a length-inconsistent packet the guest would discard anyway. +const MAX_UDP_PAYLOAD: usize = 1500 - 20 - 8; + +/// Cap on guest-bound frames staged ahead of the (bounded) output channel. +/// A guest that stops draining its RX ring while UDP keeps arriving would +/// otherwise grow this without limit; dropping the oldest matches a real +/// NIC dropping frames on a full ring. +const MAX_OUT_QUEUED: usize = 1024; + +/// Zero-copy-ish frame pipe between the classifier and the smoltcp +/// interface: `rx` holds guest frames routed into the stack, `tx` collects +/// what the stack emits toward the guest. +#[derive(Default)] +pub struct PipeDevice { + pub rx: VecDeque>, + pub tx: VecDeque>, +} + +pub struct PipeRxToken(Vec); + +impl RxToken for PipeRxToken { + fn consume(self, f: F) -> R + where + F: FnOnce(&[u8]) -> R, + { + f(&self.0) + } +} + +pub struct PipeTxToken<'a>(&'a mut VecDeque>); + +impl TxToken for PipeTxToken<'_> { + fn consume(self, len: usize, f: F) -> R + where + F: FnOnce(&mut [u8]) -> R, + { + let mut buf = vec![0u8; len]; + let r = f(&mut buf); + self.0.push_back(buf); + r + } +} + +impl Device for PipeDevice { + type RxToken<'a> = PipeRxToken; + type TxToken<'a> = PipeTxToken<'a>; + + fn receive(&mut self, _now: Instant) -> Option<(Self::RxToken<'_>, Self::TxToken<'_>)> { + let frame = self.rx.pop_front()?; + Some((PipeRxToken(frame), PipeTxToken(&mut self.tx))) + } + + fn transmit(&mut self, _now: Instant) -> Option> { + Some(PipeTxToken(&mut self.tx)) + } + + fn capabilities(&self) -> DeviceCapabilities { + let mut caps = DeviceCapabilities::default(); + caps.medium = Medium::Ethernet; + caps.max_transmission_unit = 1514; + caps + } +} + +pub struct Engine { + device: PipeDevice, + iface: Interface, + sockets: SocketSet<'static>, + tcp: tcp::TcpNat, + udp: udp::UdpNat, + dns: dns::DnsResolver, + /// Learned from guest traffic; broadcast until the first frame arrives. + guest_mac: EthernetAddress, + /// Guest-bound frames ready for the emulated NIC. + out: VecDeque>, + epoch: std::time::Instant, +} + +impl Engine { + pub fn new() -> Self { + let mut device = PipeDevice::default(); + let mut config = Config::new(HardwareAddress::Ethernet(GATEWAY_MAC)); + // Fixed seed: the ISN sequence does not need host entropy, and a + // stable engine helps when diffing NAT traces. + config.random_seed = 0x0a00_0202; + let mut iface = Interface::new(config, &mut device, Instant::ZERO); + iface.update_ip_addrs(|addrs| { + // The gateway owns both on-segment service addresses, so ARP for + // either resolves to the gateway MAC. + let _ = addrs.push(IpCidr::new(IpAddress::Ipv4(GATEWAY_IP), PREFIX_LEN)); + let _ = addrs.push(IpCidr::new(IpAddress::Ipv4(DNS_IP), 32)); + }); + // Accept IPv4 for arbitrary external destinations: outbound TCP flows + // are terminated on whatever address the guest dialed. AnyIP only + // accepts destinations covered by a route whose gateway is one of the + // interface's own addresses, so a default route through the gateway + // covers the whole IPv4 space. + iface.set_any_ip(true); + iface + .routes_mut() + .add_default_ipv4_route(GATEWAY_IP) + .expect("fresh route table takes a default route"); + Self { + device, + iface, + sockets: SocketSet::new(vec![]), + tcp: tcp::TcpNat::default(), + udp: udp::UdpNat::default(), + dns: dns::DnsResolver::default(), + guest_mac: EthernetAddress::BROADCAST, + out: VecDeque::new(), + epoch: std::time::Instant::now(), + } + } + + fn now(&self) -> Instant { + Instant::from_micros(self.epoch.elapsed().as_micros() as i64) + } + + /// Classify and ingest one frame transmitted by the guest. + pub fn handle_guest_frame(&mut self, frame: &[u8]) { + let Ok(eth) = EthernetFrame::new_checked(frame) else { + return; + }; + let src = eth.src_addr(); + if src.is_unicast() { + self.guest_mac = src; + } + match eth.ethertype() { + // ARP is the interface's business (it owns 10.0.2.2 and .3) -- + // but only for those two addresses. With any_ip enabled the + // stack would answer ARP for ANY address, including the guest's + // own duplicate-address probe at configuration time, which makes + // vintage guest stacks log "duplicate IP address" and abort + // their interface setup. + EthernetProtocol::Arp => { + let Ok(arp) = ArpPacket::new_checked(eth.payload()) else { + return; + }; + let forward = match ArpRepr::parse(&arp) { + Ok(ArpRepr::EthernetIpv4 { + operation: ArpOperation::Request, + target_protocol_addr, + .. + }) => target_protocol_addr == GATEWAY_IP || target_protocol_addr == DNS_IP, + // Replies feed the neighbor cache. + Ok(_) => true, + Err(_) => false, + }; + if forward { + self.device.rx.push_back(frame.to_vec()); + } + } + EthernetProtocol::Ipv4 => { + let Ok(ip) = Ipv4Packet::new_checked(eth.payload()) else { + return; + }; + match ip.next_header() { + IpProtocol::Udp => { + let Ok(udp) = UdpPacket::new_checked(ip.payload()) else { + return; + }; + if udp.dst_port() == dhcp::SERVER_PORT { + if let Some(reply) = dhcp::handle_frame(frame) { + self.push_out(reply); + } + } else if ip.dst_addr() == DNS_IP && udp.dst_port() == dns::PORT { + self.dns.handle_query(udp.src_port(), udp.payload()); + } else { + self.udp.handle_datagram( + udp.src_port(), + ip.dst_addr(), + udp.dst_port(), + udp.payload(), + ); + } + } + IpProtocol::Icmp => { + if let Some(reply) = frames::icmp_echo_reply(frame) { + self.push_out(reply); + } + } + IpProtocol::Tcp => { + // A new SYN grows the flow table (and its listening + // socket) before the segment enters the stack. + log::trace!( + "nat engine: tcp segment {} -> {}", + ip.src_addr(), + ip.dst_addr() + ); + self.tcp.maybe_open(&ip, &mut self.sockets); + self.device.rx.push_back(frame.to_vec()); + } + _ => {} + } + } + // No IPv6 on this segment. + _ => {} + } + } + + /// Advance the stack and every host-socket pump once. + pub fn step(&mut self) { + let now = self.now(); + self.iface.poll(now, &mut self.device, &mut self.sockets); + self.tcp.pump(&mut self.sockets); + // Pumping moved bytes in and out of socket buffers; poll again so + // segments and window updates leave in the same step. + self.iface.poll(now, &mut self.device, &mut self.sockets); + for (guest_port, remote_ip, remote_port, payload) in self.udp.poll() { + if payload.len() > MAX_UDP_PAYLOAD { + // No IP fragmentation: an oversized reply is dropped whole. + continue; + } + let f = frames::build_udp( + self.guest_mac, + remote_ip, + remote_port, + super::GUEST_IP, + guest_port, + &payload, + ); + self.push_out(f); + } + for (guest_port, payload) in self.dns.poll_results() { + let f = frames::build_udp( + self.guest_mac, + DNS_IP, + dns::PORT, + super::GUEST_IP, + guest_port, + &payload, + ); + self.push_out(f); + } + while let Some(frame) = self.device.tx.pop_front() { + self.push_out_unpadded(frame); + } + } + + pub fn pop_output(&mut self) -> Option> { + self.out.pop_front() + } + + fn push_out(&mut self, frame: Vec) { + self.push_out_unpadded(frame); + } + + fn push_out_unpadded(&mut self, mut frame: Vec) { + // The Am7990 does not pad short frames on either side; a real wire + // would never carry one under the Ethernet minimum, so pad here. + if frame.len() < MIN_FRAME { + frame.resize(MIN_FRAME, 0); + } + if self.out.len() >= MAX_OUT_QUEUED { + self.out.pop_front(); + } + self.out.push_back(frame); + } +} + +#[cfg(test)] +mod tests { + use super::super::{frames, DNS_IP, GATEWAY_IP, GATEWAY_MAC, GUEST_IP}; + use super::*; + use smoltcp::iface::SocketHandle; + use smoltcp::phy::ChecksumCapabilities; + use smoltcp::socket::tcp as tsock; + use smoltcp::wire::{ + ArpOperation, ArpPacket, ArpRepr, Icmpv4Packet, Icmpv4Repr, Ipv4Address, Ipv4Repr, + }; + use std::time::Duration; + + const GUEST_MAC: EthernetAddress = EthernetAddress([0x02, 0x00, 0x10, 0x00, 0x00, 0x01]); + + #[test] + fn arp_for_the_gateway_is_answered() { + let repr = ArpRepr::EthernetIpv4 { + operation: ArpOperation::Request, + source_hardware_addr: GUEST_MAC, + source_protocol_addr: GUEST_IP, + target_hardware_addr: EthernetAddress([0; 6]), + target_protocol_addr: GATEWAY_IP, + }; + let mut buf = vec![0u8; 14 + repr.buffer_len()]; + let mut eth = EthernetFrame::new_unchecked(&mut buf[..]); + eth.set_src_addr(GUEST_MAC); + eth.set_dst_addr(EthernetAddress::BROADCAST); + eth.set_ethertype(EthernetProtocol::Arp); + repr.emit(&mut ArpPacket::new_unchecked(eth.payload_mut())); + + let mut e = Engine::new(); + e.handle_guest_frame(&buf); + e.step(); + let reply = e.pop_output().expect("ARP reply"); + let eth = EthernetFrame::new_checked(&reply[..]).unwrap(); + assert_eq!(eth.ethertype(), EthernetProtocol::Arp); + let arp = ArpPacket::new_checked(eth.payload()).unwrap(); + match ArpRepr::parse(&arp).unwrap() { + ArpRepr::EthernetIpv4 { + operation: ArpOperation::Reply, + source_hardware_addr, + source_protocol_addr, + .. + } => { + assert_eq!(source_hardware_addr, GATEWAY_MAC); + assert_eq!(source_protocol_addr, GATEWAY_IP); + } + other => panic!("unexpected ARP: {other:?}"), + } + } + + #[test] + fn arp_probe_for_the_guests_own_address_is_not_answered() { + // A duplicate-address probe (ARP request for the guest's own IP) + // must stay unanswered or the guest thinks its address is taken. + let repr = ArpRepr::EthernetIpv4 { + operation: ArpOperation::Request, + source_hardware_addr: GUEST_MAC, + source_protocol_addr: GUEST_IP, + target_hardware_addr: EthernetAddress([0; 6]), + target_protocol_addr: GUEST_IP, + }; + let mut buf = vec![0u8; 14 + repr.buffer_len()]; + let mut eth = EthernetFrame::new_unchecked(&mut buf[..]); + eth.set_src_addr(GUEST_MAC); + eth.set_dst_addr(EthernetAddress::BROADCAST); + eth.set_ethertype(EthernetProtocol::Arp); + repr.emit(&mut ArpPacket::new_unchecked(eth.payload_mut())); + + let mut e = Engine::new(); + e.handle_guest_frame(&buf); + e.step(); + assert!(e.pop_output().is_none(), "probe must go unanswered"); + } + + #[test] + fn icmp_echo_to_an_external_address_is_answered_locally() { + let checksum = ChecksumCapabilities::default(); + let external = Ipv4Address::new(93, 184, 216, 34); + let echo = Icmpv4Repr::EchoRequest { + ident: 7, + seq_no: 3, + data: b"ping", + }; + let mut buf = vec![0u8; 14 + 20 + echo.buffer_len()]; + let mut eth = EthernetFrame::new_unchecked(&mut buf[..]); + eth.set_src_addr(GUEST_MAC); + eth.set_dst_addr(GATEWAY_MAC); + eth.set_ethertype(EthernetProtocol::Ipv4); + let ip_repr = Ipv4Repr { + src_addr: GUEST_IP, + dst_addr: external, + next_header: IpProtocol::Icmp, + payload_len: echo.buffer_len(), + hop_limit: 64, + }; + let mut ip = Ipv4Packet::new_unchecked(eth.payload_mut()); + ip_repr.emit(&mut ip, &checksum); + echo.emit( + &mut Icmpv4Packet::new_unchecked(ip.payload_mut()), + &checksum, + ); + + let mut e = Engine::new(); + e.handle_guest_frame(&buf); + e.step(); + let reply = e.pop_output().expect("echo reply"); + let eth = EthernetFrame::new_checked(&reply[..]).unwrap(); + let ip = Ipv4Packet::new_checked(eth.payload()).unwrap(); + assert_eq!(ip.src_addr(), external, "answered as the target"); + assert_eq!(ip.dst_addr(), GUEST_IP); + let icmp = Icmpv4Packet::new_checked(ip.payload()).unwrap(); + match Icmpv4Repr::parse(&icmp, &checksum).unwrap() { + Icmpv4Repr::EchoReply { + ident, + seq_no, + data, + } => { + assert_eq!((ident, seq_no), (7, 3)); + assert_eq!(data, b"ping"); + } + other => panic!("unexpected ICMP: {other:?}"), + } + } + + #[test] + fn dns_query_through_the_engine_returns_an_a_record() { + let query = crate::net::nat::dns::tests::build_query(0x77, "localhost", 1); + let frame = frames::build_udp(GATEWAY_MAC, GUEST_IP, 3333, DNS_IP, dns::PORT, &query); + let mut e = Engine::new(); + e.handle_guest_frame(&frame); + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + e.step(); + if let Some(reply) = e.pop_output() { + let eth = EthernetFrame::new_checked(&reply[..]).unwrap(); + let ip = Ipv4Packet::new_checked(eth.payload()).unwrap(); + assert_eq!(ip.src_addr(), DNS_IP); + let udp = UdpPacket::new_checked(ip.payload()).unwrap(); + assert_eq!(udp.src_port(), dns::PORT); + assert_eq!(udp.dst_port(), 3333); + let p = udp.payload(); + assert_eq!(&p[p.len() - 4..], &[127, 0, 0, 1]); + return; + } + assert!(std::time::Instant::now() < deadline, "no DNS reply"); + std::thread::sleep(Duration::from_millis(5)); + } + } + + #[test] + fn dhcp_discover_through_the_engine_is_offered() { + // Classifier wiring only; the responder itself is tested in dhcp.rs. + let mut p = vec![0u8; 236]; + p[0] = 1; + p[1] = 1; + p[2] = 6; + p[28..34].copy_from_slice(&GUEST_MAC.0); + p.extend_from_slice(&[0x63, 0x82, 0x53, 0x63, 53, 1, 1, 255]); + let frame = frames::build_udp( + EthernetAddress::BROADCAST, + Ipv4Address::UNSPECIFIED, + 68, + Ipv4Address::BROADCAST, + dhcp::SERVER_PORT, + &p, + ); + let mut e = Engine::new(); + e.handle_guest_frame(&frame); + e.step(); + let reply = e.pop_output().expect("DHCP offer"); + let eth = EthernetFrame::new_checked(&reply[..]).unwrap(); + let ip = Ipv4Packet::new_checked(eth.payload()).unwrap(); + let udp = UdpPacket::new_checked(ip.payload()).unwrap(); + assert_eq!(udp.dst_port(), 68); + assert_eq!(&udp.payload()[16..20], &GUEST_IP.octets()); + } + + /// A second smoltcp stack standing in for the guest machine, so the TCP + /// splice is exercised with a real TCP state machine on both ends. + struct GuestStack { + device: PipeDevice, + iface: Interface, + sockets: SocketSet<'static>, + handle: SocketHandle, + epoch: std::time::Instant, + } + + impl GuestStack { + fn new() -> Self { + let mut device = PipeDevice::default(); + let mut config = Config::new(HardwareAddress::Ethernet(GUEST_MAC)); + config.random_seed = 42; + let mut iface = Interface::new(config, &mut device, Instant::ZERO); + iface.update_ip_addrs(|addrs| { + let _ = addrs.push(IpCidr::new(IpAddress::Ipv4(GUEST_IP), PREFIX_LEN)); + }); + iface + .routes_mut() + .add_default_ipv4_route(GATEWAY_IP) + .unwrap(); + let mut sockets = SocketSet::new(vec![]); + let sock = tsock::Socket::new( + tsock::SocketBuffer::new(vec![0u8; 16384]), + tsock::SocketBuffer::new(vec![0u8; 16384]), + ); + let handle = sockets.add(sock); + Self { + device, + iface, + sockets, + handle, + epoch: std::time::Instant::now(), + } + } + + fn sock(&mut self) -> &mut tsock::Socket<'static> { + self.sockets.get_mut::(self.handle) + } + + /// One full frame exchange in both directions with the engine. + fn exchange(&mut self, engine: &mut Engine) { + let now = Instant::from_micros(self.epoch.elapsed().as_micros() as i64); + self.iface.poll(now, &mut self.device, &mut self.sockets); + while let Some(f) = self.device.tx.pop_front() { + engine.handle_guest_frame(&f); + } + engine.step(); + while let Some(f) = engine.pop_output() { + self.device.rx.push_back(f); + } + self.iface.poll(now, &mut self.device, &mut self.sockets); + } + } + + #[test] + fn tcp_flow_splices_to_a_localhost_server() { + // The gateway maps to the host loopback, so a local server stands in + // for the internet and the test needs no network access. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = std::thread::spawn(move || { + use std::io::{Read, Write}; + let (mut s, _) = listener.accept().unwrap(); + s.write_all(b"hello from host").unwrap(); + let mut got = Vec::new(); + let mut buf = [0u8; 64]; + while !got.ends_with(b"hi") { + let n = s.read(&mut buf).unwrap(); + if n == 0 { + break; + } + got.extend_from_slice(&buf[..n]); + } + got + }); + + let mut engine = Engine::new(); + let mut guest = GuestStack::new(); + { + let GuestStack { + iface, + sockets, + handle, + .. + } = &mut guest; + let cx = iface.context(); + sockets + .get_mut::(*handle) + .connect(cx, (IpAddress::Ipv4(GATEWAY_IP), port), 49152) + .unwrap(); + } + + let deadline = std::time::Instant::now() + Duration::from_secs(20); + let mut received = Vec::new(); + let mut sent_reply = false; + loop { + guest.exchange(&mut engine); + let sock = guest.sock(); + if sock.can_recv() { + sock.recv(|d| { + received.extend_from_slice(d); + (d.len(), ()) + }) + .unwrap(); + } + if !sent_reply && received == b"hello from host" && sock.can_send() { + sock.send_slice(b"hi").unwrap(); + sent_reply = true; + } + if sent_reply && sock.send_queue() == 0 { + break; + } + assert!( + std::time::Instant::now() < deadline, + "splice stalled: received {received:?}" + ); + std::thread::sleep(Duration::from_millis(2)); + } + // A few extra exchanges flush the last ACKs, then close cleanly. + guest.sock().close(); + for _ in 0..20 { + guest.exchange(&mut engine); + std::thread::sleep(Duration::from_millis(2)); + } + let got = server.join().unwrap(); + assert_eq!(got, b"hi"); + assert_eq!(received, b"hello from host"); + } +} diff --git a/src/net/nat/frames.rs b/src/net/nat/frames.rs new file mode 100644 index 00000000..60f163d0 --- /dev/null +++ b/src/net/nat/frames.rs @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Hand-built Ethernet/IPv4 frame helpers for the NAT's frame-level +//! protocols (DHCP, DNS, UDP NAT, ICMP echo). The stateful protocols (ARP, +//! TCP) go through the smoltcp interface instead and never come here. + +use smoltcp::phy::ChecksumCapabilities; +use smoltcp::wire::{ + EthernetAddress, EthernetFrame, EthernetProtocol, Icmpv4Message, Icmpv4Packet, Icmpv4Repr, + IpProtocol, Ipv4Address, Ipv4Packet, Ipv4Repr, UdpPacket, UdpRepr, +}; + +use super::GATEWAY_MAC; + +const ETH_HDR: usize = 14; +const IPV4_HDR: usize = 20; +const UDP_HDR: usize = 8; + +/// Build a complete Ethernet + IPv4 + UDP frame carrying `payload`. +pub fn build_udp( + dst_mac: EthernetAddress, + src_ip: Ipv4Address, + src_port: u16, + dst_ip: Ipv4Address, + dst_port: u16, + payload: &[u8], +) -> Vec { + let checksum = ChecksumCapabilities::default(); + let mut buf = vec![0u8; ETH_HDR + IPV4_HDR + UDP_HDR + payload.len()]; + let mut eth = EthernetFrame::new_unchecked(&mut buf[..]); + eth.set_src_addr(GATEWAY_MAC); + eth.set_dst_addr(dst_mac); + eth.set_ethertype(EthernetProtocol::Ipv4); + let ip_repr = Ipv4Repr { + src_addr: src_ip, + dst_addr: dst_ip, + next_header: IpProtocol::Udp, + payload_len: UDP_HDR + payload.len(), + hop_limit: 64, + }; + let mut ip = Ipv4Packet::new_unchecked(eth.payload_mut()); + ip_repr.emit(&mut ip, &checksum); + let udp_repr = UdpRepr { src_port, dst_port }; + let mut udp = UdpPacket::new_unchecked(ip.payload_mut()); + udp_repr.emit( + &mut udp, + &src_ip.into(), + &dst_ip.into(), + payload.len(), + |b| b.copy_from_slice(payload), + &checksum, + ); + buf +} + +/// Answer an ICMP echo request (to any destination) as the destination +/// itself. The gateway has no raw-socket path to really ping for the guest, +/// so like classic slirp a reply only proves the NAT is alive. +pub fn icmp_echo_reply(guest_frame: &[u8]) -> Option> { + let checksum = ChecksumCapabilities::default(); + let eth = EthernetFrame::new_checked(guest_frame).ok()?; + let ip = Ipv4Packet::new_checked(eth.payload()).ok()?; + let icmp = Icmpv4Packet::new_checked(ip.payload()).ok()?; + if icmp.msg_type() != Icmpv4Message::EchoRequest { + return None; + } + let repr = Icmpv4Repr::parse(&icmp, &checksum).ok()?; + let Icmpv4Repr::EchoRequest { + ident, + seq_no, + data, + } = repr + else { + return None; + }; + let reply = Icmpv4Repr::EchoReply { + ident, + seq_no, + data, + }; + let mut buf = vec![0u8; ETH_HDR + IPV4_HDR + reply.buffer_len()]; + let mut reth = EthernetFrame::new_unchecked(&mut buf[..]); + reth.set_src_addr(GATEWAY_MAC); + reth.set_dst_addr(eth.src_addr()); + reth.set_ethertype(EthernetProtocol::Ipv4); + let ip_repr = Ipv4Repr { + src_addr: ip.dst_addr(), + dst_addr: ip.src_addr(), + next_header: IpProtocol::Icmp, + payload_len: reply.buffer_len(), + hop_limit: 64, + }; + let mut rip = Ipv4Packet::new_unchecked(reth.payload_mut()); + ip_repr.emit(&mut rip, &checksum); + let mut ricmp = Icmpv4Packet::new_unchecked(rip.payload_mut()); + reply.emit(&mut ricmp, &checksum); + Some(buf) +} + +/// Map a virtual destination to the host address the NAT actually dials: +/// the gateway itself stands in for the host's loopback. +pub fn map_host_ip(dst: Ipv4Address) -> std::net::IpAddr { + if dst == super::GATEWAY_IP { + std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST) + } else { + std::net::IpAddr::V4(dst) + } +} diff --git a/src/net/nat/mod.rs b/src/net/nat/mod.rs new file mode 100644 index 00000000..66fa516e --- /dev/null +++ b/src/net/nat/mod.rs @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Userspace NAT backend: a slirp-style virtual gateway. +//! +//! The guest machine sees an ordinary Ethernet segment with a gateway and a +//! DNS server on it (the QEMU/slirp convention): +//! +//! - network `10.0.2.0/24` +//! - guest address `10.0.2.15` (static or via the built-in BOOTP/DHCP server) +//! - gateway / NAT router `10.0.2.2` (TCP/UDP to it are mapped to the host's +//! `127.0.0.1`, so the guest can reach host-local services) +//! - DNS forwarder `10.0.2.3` (resolved through the host's own resolver) +//! +//! Outbound IPv4 is NATed onto ordinary host sockets, so no host privileges, +//! drivers, or per-OS configuration are needed and behavior is identical on +//! Linux, macOS, and Windows. There is no inbound path (no host port +//! forwards yet) and no IPv6; ICMP echo is answered locally by the gateway +//! for any destination, which proves the NAT is up, not that the target is +//! reachable (raw sockets would need privileges, as in classic slirp). +//! +//! Threading: one `a2065-nat` thread owns the whole engine -- the smoltcp +//! interface that terminates ARP and the guest's TCP, the UDP flow table, +//! the DHCP/DNS responders, and every host socket. Frames cross to and from +//! the emulated NIC over bounded channels that drop on overflow, so a +//! stalled host can never block the emulator thread (a dropped frame is +//! ordinary Ethernet loss; the guest's protocols retransmit). See +//! [`crate::net`] for the determinism contract: NAT traffic is host-paced +//! and breaks byte-identical replay while it flows, and a save state brings +//! the backend up fresh (flows die, guest TCP retries). + +mod dhcp; +mod dns; +mod engine; +mod frames; +mod tcp; +mod udp; + +use crate::net::NetBackend; +use smoltcp::wire::{EthernetAddress, Ipv4Address}; +use std::sync::mpsc; +use std::time::Duration; + +/// The guest's address on the virtual segment. +pub const GUEST_IP: Ipv4Address = Ipv4Address::new(10, 0, 2, 15); +/// The virtual gateway / NAT router. +pub const GATEWAY_IP: Ipv4Address = Ipv4Address::new(10, 0, 2, 2); +/// The virtual DNS forwarder. +pub const DNS_IP: Ipv4Address = Ipv4Address::new(10, 0, 2, 3); +/// The segment's directed broadcast (guest datagrams here are not NATed). +pub const SEGMENT_BROADCAST: Ipv4Address = Ipv4Address::new(10, 0, 2, 255); +/// Prefix length of the virtual segment (255.255.255.0). +pub const PREFIX_LEN: u8 = 24; +/// The gateway's MAC (the slirp convention: 52:55 then the gateway IP). +pub const GATEWAY_MAC: EthernetAddress = EthernetAddress([0x52, 0x55, 0x0A, 0x00, 0x02, 0x02]); + +/// Frames the emulator can queue toward the engine before drops (TX loss). +const TO_ENGINE_CAPACITY: usize = 256; +/// Frames the engine can queue toward the guest before drops (RX loss). +const TO_GUEST_CAPACITY: usize = 512; + +/// The emulator-side handle: hands guest frames to the NAT thread and pulls +/// guest-bound frames back. Both directions are non-blocking. +pub struct NatBackend { + to_engine: Option>>, + from_engine: mpsc::Receiver>, + thread: Option>, +} + +impl NatBackend { + pub fn new() -> Self { + let (in_tx, in_rx) = mpsc::sync_channel(TO_ENGINE_CAPACITY); + let (out_tx, out_rx) = mpsc::sync_channel(TO_GUEST_CAPACITY); + let thread = std::thread::Builder::new() + .name("a2065-nat".into()) + .spawn(move || run_engine(in_rx, out_tx)) + .map_err(|e| log::warn!("NAT thread failed to start: {e}; NIC left isolated")) + .ok(); + // With no engine thread the receiver is already dropped, so keep no + // sender either: `send()` then skips the per-frame allocation and the + // board behaves like a cleanly isolated NIC. + let to_engine = thread.is_some().then_some(in_tx); + Self { + to_engine, + from_engine: out_rx, + thread, + } + } +} + +impl Default for NatBackend { + fn default() -> Self { + Self::new() + } +} + +impl NetBackend for NatBackend { + fn send(&mut self, frame: &[u8]) { + if let Some(tx) = &self.to_engine { + // Full queue = TX loss, never a stall on the emulator thread. + let _ = tx.try_send(frame.to_vec()); + } + } + + fn poll(&mut self) -> Option> { + self.from_engine.try_recv().ok() + } +} + +impl Drop for NatBackend { + fn drop(&mut self) { + // Dropping the sender wakes the engine loop (Disconnected) within one + // recv timeout; join so its sockets and workers wind down with it. + self.to_engine = None; + if let Some(t) = self.thread.take() { + let _ = t.join(); + } + } +} + +/// The NAT thread body: ingest guest frames, step the engine, ship output. +fn run_engine(in_rx: mpsc::Receiver>, out_tx: mpsc::SyncSender>) { + let mut engine = engine::Engine::new(); + loop { + // The 1 ms receive timeout doubles as the engine tick: host sockets + // are all non-blocking and get pumped once per iteration. + match in_rx.recv_timeout(Duration::from_millis(1)) { + Ok(frame) => { + engine.handle_guest_frame(&frame); + while let Ok(frame) = in_rx.try_recv() { + engine.handle_guest_frame(&frame); + } + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + engine.step(); + while let Some(frame) = engine.pop_output() { + if out_tx.try_send(frame).is_err() { + // Guest-bound queue full (RX loss) or the backend is gone; + // drop the remainder of this batch either way. + break; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backend_starts_and_shuts_down() { + let mut b = NatBackend::new(); + b.send(&[0u8; 60]); // malformed junk must be tolerated + assert!(b.poll().is_none() || b.poll().is_none()); + drop(b); // must not hang on join + } +} diff --git a/src/net/nat/tcp.rs b/src/net/nat/tcp.rs new file mode 100644 index 00000000..d89e4a18 --- /dev/null +++ b/src/net/nat/tcp.rs @@ -0,0 +1,265 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! TCP flow NAT. The guest's TCP is terminated by smoltcp on the virtual +//! gateway: when a SYN to a new destination arrives, a listening socket is +//! created for exactly that (address, port) -- the interface accepts any +//! IPv4 destination -- and a host connection is dialed in parallel on a +//! short-lived worker (std has no non-blocking connect). Once both sides +//! are up, the flow is a plain byte splice; smoltcp's receive window gives +//! guest-side backpressure for free when the host socket stops accepting +//! writes. + +use smoltcp::iface::{SocketHandle, SocketSet}; +use smoltcp::socket::tcp as tsock; +use smoltcp::wire::{IpAddress, IpListenEndpoint, Ipv4Address, Ipv4Packet, TcpPacket}; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use super::frames; + +const MAX_FLOWS: usize = 128; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +/// A flow whose host dial never reported back is reaped after this. +const CONNECT_REAP: Duration = Duration::from_secs(30); +const SOCKET_BUF: usize = 64 * 1024; + +enum HostState { + /// Worker thread still dialing; guest bytes pile up in the socket + /// buffer, bounded by its size and then the receive window. + Connecting, + Up { + stream: TcpStream, + /// The guest side has left the handshake (reached ESTABLISHED or + /// beyond). Until then `may_recv()` reads false the same as it does + /// for a half-closed connection, so the guest-FIN half-close must + /// wait for this before firing -- otherwise a host dial that beats + /// the guest's handshake ACK (a loopback-mapped service) would shut + /// the host write side down mid-handshake. + established: bool, + /// Host sent EOF; its FIN has been forwarded as `close()`. + read_eof: bool, + /// Guest sent FIN; forwarded as a write shutdown. + wrote_shutdown: bool, + }, + /// Host side failed or was torn down; the smoltcp socket got an abort + /// (RST to the guest) or close and is draining out. + Gone, +} + +struct TcpFlow { + id: u64, + key: (u16, Ipv4Address, u16), + handle: SocketHandle, + host: HostState, + opened: Instant, +} + +pub struct TcpNat { + flows: Vec, + connect_tx: mpsc::Sender<(u64, std::io::Result)>, + connect_rx: mpsc::Receiver<(u64, std::io::Result)>, + next_id: u64, +} + +impl Default for TcpNat { + fn default() -> Self { + let (connect_tx, connect_rx) = mpsc::channel(); + Self { + flows: Vec::new(), + connect_tx, + connect_rx, + next_id: 0, + } + } +} + +impl TcpNat { + /// Called for every guest TCP segment before it enters the stack: a SYN + /// to a destination with no flow yet opens one. + pub fn maybe_open(&mut self, ip: &Ipv4Packet<&[u8]>, sockets: &mut SocketSet<'static>) { + let Ok(tcp) = TcpPacket::new_checked(ip.payload()) else { + return; + }; + if !tcp.syn() || tcp.ack() { + return; + } + log::debug!( + "nat tcp: SYN {}:{} -> {}:{}", + ip.src_addr(), + tcp.src_port(), + ip.dst_addr(), + tcp.dst_port() + ); + let key = (tcp.src_port(), ip.dst_addr(), tcp.dst_port()); + if self.flows.iter().any(|f| f.key == key) { + return; // SYN retransmit for a flow already opening + } + if self.flows.len() >= MAX_FLOWS { + return; // dropped SYN: the guest gets a connect timeout + } + let mut sock = tsock::Socket::new( + tsock::SocketBuffer::new(vec![0u8; SOCKET_BUF]), + tsock::SocketBuffer::new(vec![0u8; SOCKET_BUF]), + ); + let listen_on = IpListenEndpoint { + addr: Some(IpAddress::Ipv4(ip.dst_addr())), + port: tcp.dst_port(), + }; + if sock.listen(listen_on).is_err() { + log::debug!("nat tcp: listen failed for {listen_on}"); + return; + } + let handle = sockets.add(sock); + let id = self.next_id; + self.next_id += 1; + let target = SocketAddr::new(frames::map_host_ip(ip.dst_addr()), tcp.dst_port()); + log::debug!("nat tcp: dialing {target} for flow {id}"); + let tx = self.connect_tx.clone(); + let spawned = std::thread::Builder::new() + .name("a2065-nat-dial".into()) + .spawn(move || { + let r = TcpStream::connect_timeout(&target, CONNECT_TIMEOUT); + let _ = tx.send((id, r)); + }); + if spawned.is_err() { + sockets.remove(handle); + return; + } + self.flows.push(TcpFlow { + id, + key, + handle, + host: HostState::Connecting, + opened: Instant::now(), + }); + } + + /// Move bytes between every flow's smoltcp socket and host socket. + pub fn pump(&mut self, sockets: &mut SocketSet<'static>) { + while let Ok((id, result)) = self.connect_rx.try_recv() { + let Some(flow) = self.flows.iter_mut().find(|f| f.id == id) else { + continue; // flow already reaped; a success is just dropped + }; + match result { + Ok(stream) => { + let ok = stream.set_nonblocking(true).is_ok(); + let _ = stream.set_nodelay(true); + if ok { + flow.host = HostState::Up { + stream, + established: false, + read_eof: false, + wrote_shutdown: false, + }; + continue; + } + sockets.get_mut::(flow.handle).abort(); + flow.host = HostState::Gone; + } + Err(e) => { + // Connection refused/unreachable: RST the guest. + log::debug!("nat tcp: dial for flow {id} failed: {e}"); + sockets.get_mut::(flow.handle).abort(); + flow.host = HostState::Gone; + } + } + } + + for flow in &mut self.flows { + let sock = sockets.get_mut::(flow.handle); + let HostState::Up { + stream, + established, + read_eof, + wrote_shutdown, + } = &mut flow.host + else { + continue; + }; + // The guest has cleared the handshake once the socket is no + // longer listening or half-open. + if !*established + && !matches!( + sock.state(), + tsock::State::Listen | tsock::State::SynReceived + ) + { + *established = true; + } + let mut broken = false; + + // Guest -> host. WouldBlock consumes nothing, so unsent bytes + // stay in the socket buffer and close the guest's window. + while !broken && sock.can_recv() { + let mut moved = 0usize; + let r = sock.recv(|data| match stream.write(data) { + Ok(n) => { + moved = n; + (n, false) + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => (0, false), + Err(_) => (0, true), + }); + match r { + Ok(true) => broken = true, + Ok(false) if moved == 0 => break, + Ok(false) => {} + Err(_) => break, + } + } + + // Guest FIN, fully drained: half-close toward the host. Gated on + // `established` so a not-yet-handshaked socket (may_recv() false) + // is not mistaken for a half-closed one. + if !broken && *established && !*wrote_shutdown && !sock.may_recv() && !sock.can_recv() { + let _ = stream.shutdown(std::net::Shutdown::Write); + *wrote_shutdown = true; + } + + // Host -> guest, only as much as the send buffer can take so a + // read never overruns `send_slice`. + let mut tmp = [0u8; 4096]; + while !broken && !*read_eof && sock.can_send() { + let room = sock.send_capacity() - sock.send_queue(); + if room == 0 { + break; + } + let want = room.min(tmp.len()); + match stream.read(&mut tmp[..want]) { + Ok(0) => { + *read_eof = true; + sock.close(); // forward the host's FIN + } + Ok(n) => { + let _ = sock.send_slice(&tmp[..n]); + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break, + Err(_) => broken = true, + } + } + + if broken { + sock.abort(); + flow.host = HostState::Gone; + } + } + + // Reap finished flows (socket fully closed), plus flows whose socket + // never left the handshake within the reap window -- a guest SYN that + // the interface dropped (e.g. a bad checksum) leaves a listening + // socket that would otherwise sit forever and, after MAX_FLOWS of + // them, wedge all TCP. + self.flows.retain(|flow| { + let state = sockets.get::(flow.handle).state(); + let reap = state == tsock::State::Closed + || (matches!(state, tsock::State::Listen | tsock::State::SynReceived) + && flow.opened.elapsed() > CONNECT_REAP); + if reap { + sockets.remove(flow.handle); + } + !reap + }); + } +} diff --git a/src/net/nat/udp.rs b/src/net/nat/udp.rs new file mode 100644 index 00000000..01992697 --- /dev/null +++ b/src/net/nat/udp.rs @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! UDP flow NAT: each (guest port, destination) pair gets one connected, +//! non-blocking host socket; replies are re-framed toward the guest by the +//! engine. Flows die after an idle timeout, like any home router's NAT. + +use smoltcp::wire::Ipv4Address; +use std::net::UdpSocket; +use std::time::{Duration, Instant}; + +use super::frames; + +const MAX_FLOWS: usize = 64; +const IDLE_TIMEOUT: Duration = Duration::from_secs(60); +/// Largest datagram accepted back from the host (fits any guest-side MTU +/// after fragmentation is ruled out; bigger replies are truncated by recv). +const RECV_BUF: usize = 2048; + +struct UdpFlow { + guest_port: u16, + remote_ip: Ipv4Address, + remote_port: u16, + sock: UdpSocket, + last_used: Instant, +} + +#[derive(Default)] +pub struct UdpNat { + flows: Vec, +} + +impl UdpNat { + /// Forward one guest datagram (payload of a UDP packet not claimed by + /// the DHCP or DNS responders). + pub fn handle_datagram( + &mut self, + guest_port: u16, + dst_ip: Ipv4Address, + dst_port: u16, + payload: &[u8], + ) { + // No broadcast/multicast NAT (NetBIOS chatter and friends). The + // segment's own directed broadcast is suppressed by address rather + // than by "ends in .255", which would wrongly drop unicast to a real + // host ending in .255 on a wider external subnet. + if dst_ip.is_broadcast() || dst_ip.is_multicast() || dst_ip == super::SEGMENT_BROADCAST { + return; + } + log::debug!( + "nat udp: guest :{guest_port} -> {dst_ip}:{dst_port} ({} bytes)", + payload.len() + ); + let now = Instant::now(); + if let Some(flow) = self.flows.iter_mut().find(|f| { + f.guest_port == guest_port && f.remote_ip == dst_ip && f.remote_port == dst_port + }) { + flow.last_used = now; + let _ = flow.sock.send(payload); + return; + } + self.flows + .retain(|f| now.duration_since(f.last_used) < IDLE_TIMEOUT); + if self.flows.len() >= MAX_FLOWS { + return; // drop: the guest side sees ordinary UDP loss + } + let Ok(sock) = UdpSocket::bind(("0.0.0.0", 0)) else { + return; + }; + if sock + .connect((frames::map_host_ip(dst_ip), dst_port)) + .is_err() + || sock.set_nonblocking(true).is_err() + { + return; + } + let _ = sock.send(payload); + self.flows.push(UdpFlow { + guest_port, + remote_ip: dst_ip, + remote_port: dst_port, + sock, + last_used: now, + }); + } + + /// Drain replies from every flow: (guest port, remote ip, remote port, + /// payload), ready for re-framing toward the guest. + #[allow(clippy::type_complexity)] + pub fn poll(&mut self) -> Vec<(u16, Ipv4Address, u16, Vec)> { + let now = Instant::now(); + let mut out = Vec::new(); + for flow in &mut self.flows { + let mut buf = [0u8; RECV_BUF]; + while let Ok(n) = flow.sock.recv(&mut buf) { + flow.last_used = now; + out.push(( + flow.guest_port, + flow.remote_ip, + flow.remote_port, + buf[..n].to_vec(), + )); + } + } + self.flows + .retain(|f| now.duration_since(f.last_used) < IDLE_TIMEOUT); + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn datagram_reaches_localhost_and_reply_returns() { + // The gateway address maps to the host's loopback, so a local echo + // peer stands in for "the internet" without any network access. + let peer = UdpSocket::bind("127.0.0.1:0").unwrap(); + let port = peer.local_addr().unwrap().port(); + let mut nat = UdpNat::default(); + nat.handle_datagram(5000, super::super::GATEWAY_IP, port, b"marco"); + + let mut buf = [0u8; 64]; + peer.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let (n, from) = peer.recv_from(&mut buf).unwrap(); + assert_eq!(&buf[..n], b"marco"); + peer.send_to(b"polo", from).unwrap(); + + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let replies = nat.poll(); + if let Some((gp, rip, rport, data)) = replies.into_iter().next() { + assert_eq!(gp, 5000); + assert_eq!(rip, super::super::GATEWAY_IP); + assert_eq!(rport, port); + assert_eq!(data, b"polo"); + break; + } + assert!(Instant::now() < deadline, "no UDP reply seen"); + std::thread::sleep(Duration::from_millis(5)); + } + } +} diff --git a/src/savestate.rs b/src/savestate.rs index 7c033ed4..db4f959f 100644 --- a/src/savestate.rs +++ b/src/savestate.rs @@ -111,7 +111,10 @@ const STATE_MAGIC: &[u8; 8] = b"CLSSTATE"; // 32: SCSI target slots (Wd33c93, A4091) hold a ScsiTarget enum (disk or // CD-ROM drive) instead of a bare ScsiDisk; the CD-ROM drive carries // CD-DA playback state and the tray countdown of a pending disc swap -pub const STATE_VERSION: u32 = 32; +// 33: A2065 gained the latched init-block MODE word (DTX/DRX/LOOP gating +// of the LANCE engines) and NetConfig the Nat variant (userspace NAT +// backend) +pub const STATE_VERSION: u32 = 33; /// Default state file name, timestamped like the screenshot/recorder names. pub fn auto_filename() -> std::path::PathBuf { diff --git a/src/zorro.rs b/src/zorro.rs index 5b3c1908..94f042e1 100644 --- a/src/zorro.rs +++ b/src/zorro.rs @@ -659,8 +659,8 @@ struct RawBoardMeta { dma: Option, int2: Option, int6: Option, - /// Host network backend ("none"/"loopback"); presence grants the `net` - /// capability (the net_send/net_recv imports). + /// Host network backend ("none"/"loopback"/"nat"); presence grants the + /// `net` capability (the net_send/net_recv imports). net: Option, /// Default plugin settings (free-form key/value). config: Option, @@ -805,7 +805,7 @@ pub fn load_board_metadata(path: &Path) -> Result { let net = match &raw.net { Some(s) => crate::net::parse_net_config(s).ok_or_else(|| { anyhow::anyhow!( - "{}: net = {:?} is not a known backend (expected \"none\" or \"loopback\")", + "{}: net = {:?} is not a known backend (expected \"none\", \"loopback\", or \"nat\")", path.display(), s )