From e41bb7dc27e832a3888f26c70d9839245bb33602 Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Sat, 18 Jul 2026 20:42:25 +0100 Subject: [PATCH 1/3] a2065: user-mode NAT backend and LANCE model hardening Give the A2065 Ethernet board real host connectivity via a slirp-style user-mode NAT, and harden the Am7990 LANCE model so a real SANA-II a2065.device driver works against it end to end. NAT backend (src/net/nat/, behind the default-on net-nat feature): a virtual gateway that NATs the guest's outbound IPv4 onto ordinary host sockets with no privileges, identically on Linux, macOS, and Windows. The guest sees the QEMU/slirp segment (10.0.2.15/24, gateway 10.0.2.2, DNS 10.0.2.3, built-in BOOTP/DHCP). smoltcp terminates ARP and the guest's TCP on the gateway and each flow is spliced onto a host socket; DNS resolves through the host's own resolver, and UDP NAT, ICMP echo, and DHCP are handled at frame level. A dedicated a2065-nat thread owns all host sockets and crosses frames to the emulated NIC over bounded drop-on-full channels, so the deterministic core never blocks on the network. Select it with [a2065] net = "nat" or --a2065-net nat. LANCE hardening (src/a2065.rs): received frames now carry the FCS trailer counted in MCNT (drivers read the payload as MCNT - 4), RX and TX frames chain across descriptors (STP..ENP), the init-block MODE word gates the engines (DTX/DRX) and drives the LOOP internal-loopback self-test, and an RX ring overrun sets MISS. STATE_VERSION 32 -> 33 for the new MODE field and the NetConfig::Nat variant. Validated end to end against AmigaOS a2065.device running AmiTCP: the guest resolves DNS, opens TCP connections, and loads a live web page over the emulated board. Docs updated in docs/zorro.md, the configuration guide, the peripherals internals, and the example config. --- Cargo.lock | 86 +++++ Cargo.toml | 16 +- copperline.example.toml | 13 +- docs/guide/configuration.md | 36 +- docs/internals/peripherals.md | 25 +- docs/zorro.md | 37 ++- src/a2065.rs | 470 +++++++++++++++++++++++--- src/config.rs | 9 +- src/main.rs | 7 + src/{net.rs => net/mod.rs} | 33 +- src/net/nat/dhcp.rs | 181 ++++++++++ src/net/nat/dns.rs | 333 +++++++++++++++++++ src/net/nat/engine.rs | 601 ++++++++++++++++++++++++++++++++++ src/net/nat/frames.rs | 108 ++++++ src/net/nat/mod.rs | 154 +++++++++ src/net/nat/tcp.rs | 265 +++++++++++++++ src/net/nat/udp.rs | 143 ++++++++ src/savestate.rs | 5 +- src/zorro.rs | 6 +- 19 files changed, 2447 insertions(+), 81 deletions(-) rename src/{net.rs => net/mod.rs} (71%) create mode 100644 src/net/nat/dhcp.rs create mode 100644 src/net/nat/dns.rs create mode 100644 src/net/nat/engine.rs create mode 100644 src/net/nat/frames.rs create mode 100644 src/net/nat/mod.rs create mode 100644 src/net/nat/tcp.rs create mode 100644 src/net/nat/udp.rs 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/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..038425d7 --- /dev/null +++ b/src/net/nat/dns.rs @@ -0,0 +1,333 @@ +// 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); + 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); + } + } + 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..a4f4bb3a --- /dev/null +++ b/src/net/nat/mod.rs @@ -0,0 +1,154 @@ +// 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(); + Self { + to_engine: Some(in_tx), + 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 ) From 7c32d491c2638e7afb6c5e94da4d9c0015f810f4 Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Sat, 18 Jul 2026 20:51:00 +0100 Subject: [PATCH 2/3] a2065 nat: answer on thread-spawn failure instead of stalling Two error-path fixes from PR review: - NatBackend keeps no send half when the engine thread fails to spawn, so send() skips the per-frame allocation and the board behaves like a cleanly isolated NIC instead of allocating then failing Disconnected on every frame. - The DNS forwarder replies SERVFAIL when a resolver worker thread fails to spawn, so the guest resolver retries promptly rather than waiting out a DNS timeout. --- src/net/nat/dns.rs | 4 ++++ src/net/nat/mod.rs | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/net/nat/dns.rs b/src/net/nat/dns.rs index 038425d7..8c5efb7a 100644 --- a/src/net/nat/dns.rs +++ b/src/net/nat/dns.rs @@ -84,6 +84,9 @@ impl DnsResolver { 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 || { @@ -103,6 +106,7 @@ impl DnsResolver { }); if spawned.is_err() { self.outstanding.fetch_sub(1, Ordering::Relaxed); + let _ = self.results_tx.send((guest_port, servfail)); } } QTYPE_PTR => { diff --git a/src/net/nat/mod.rs b/src/net/nat/mod.rs index a4f4bb3a..66fa516e 100644 --- a/src/net/nat/mod.rs +++ b/src/net/nat/mod.rs @@ -75,8 +75,12 @@ impl NatBackend { .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: Some(in_tx), + to_engine, from_engine: out_rx, thread, } From e8a6f7d4ffa757feabf8083353719462b33bad82 Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Sat, 18 Jul 2026 21:03:32 +0100 Subject: [PATCH 3/3] flatpak: vendor smoltcp in cargo-sources.json Regenerated from Cargo.lock after adding the smoltcp dependency for the NAT backend. The Flathub build runs offline, so smoltcp and its transitive crates (byteorder, heapless, hash32, managed, defmt) must be listed as vendored sources; without them the offline cargo build fails with "no matching package named smoltcp". --- packaging/flatpak/cargo-sources.json | 117 +++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) 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",