diff --git a/Cargo.toml b/Cargo.toml index 681e6df..9ae7394 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,19 @@ chd = ["dep:libchdman-rs"] # Windows — nokhwa MediaFoundation (dep:nokhwa) # Off by default — enable with `cargo build --features camera`. camera = ["dep:nokhwa", "dep:v4l"] +# PCAP/bridged networking backend: instead of the built-in software NAT gateway, +# bridge the guest's raw Ethernet frames onto a real host interface via libpcap. +# Lets the guest appear as a real L2 host on the physical LAN. Requires a pcap +# library at build+run time and elevated privileges (root / CAP_NET_RAW / +# Administrator) to capture and inject. +# Linux/macOS : libpcap (headers+lib). +# Windows : the `pcap` crate links the generic `wpcap` import library, so +# it works with the BSD-licensed WinPcap Developer Pack as well +# as Npcap. IRIS links dynamically and never bundles the driver, +# so the runtime driver's license does not attach to IRIS. +# Off by default — enable with `cargo build --features pcap`. Select at runtime +# with `[network] mode = "pcap"` in iris.toml. +pcap = ["dep:pcap"] [dependencies] clap = { version = "4", features = ["derive"] } @@ -95,6 +108,7 @@ cranelift-module = { version = "0.116", optional = true } cranelift-native = { version = "0.116", optional = true } target-lexicon = { version = "0.13", optional = true } libchdman-rs = { version = "0.287.0-l7", features = ["prebuilt"], optional = true } +pcap = { version = "2", optional = true } [target.'cfg(target_os = "macos")'.dependencies] nokhwa = { version = "0.10", features = ["input-avfoundation"], optional = true } diff --git a/README.md b/README.md index 12b7252..2050ad5 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ cargo run --release --features jit # enable Cranelift MIPS JIT cargo run --release --features ci_clock # synthetic deterministic CP0 Compare clock (CI/snapshot validator only; loses realtime desktop timing) cargo run --release --features chd # mount .chd disk/CD-ROM images directly (via libchdman-rs); off by default to keep builds light cargo run --release --features camera # use host camera as the IndyCam video source (macOS AVFoundation via nokhwa). See [vino] in iris.toml. +cargo run --release --features pcap # bridge guest networking onto a real host interface via libpcap instead of the built-in NAT gateway. See [network] in iris.toml. ``` ### CHD image support (`--features chd`) @@ -100,6 +101,84 @@ See [HELP.md](HELP.md) for the full rundown: serial ports, monitor console, NVRAM/MAC address setup, disk image prep, and more. +## PCAP bridged networking (`--features pcap`) + +By default IRIS gives the guest networking through a built-in software NAT +gateway (DHCP/DNS/TCP/UDP routing + port forwarding). As an alternative you can +bridge the guest's raw Ethernet frames directly onto a real host interface. The +guest then appears as an independent L2 host on your physical LAN and can be +pinged from other machines, use your real DHCP/DNS, etc. + +### Library / licensing + +The `pcap` crate links the generic `wpcap` import library on Windows (NOT a +driver-specific one), so IRIS is not tied to any single provider. You can +build/link against **the BSD-licensed WinPcap Developer Pack** as well as Npcap. +IRIS links dynamically and never bundles the driver, so the runtime driver's +license (e.g. Npcap's redistribution terms) does not attach to IRIS. + +To point the linker at the WinPcap Developer Pack SDK on Windows: +``` +set LIBPCAP_LIBDIR=C:\path\to\WpdPack\Lib\x64 +cargo build --release --features pcap +``` + +On Linux/macOS you need the libpcap headers and library (e.g. `libpcap-dev` on +Debian/Ubuntu, or the macOS system libpcap). + +### Enabling PCAP mode + +1. **Build** with `--features pcap`: + ``` + cargo build --release --features chd,pcap + ``` + +2. **Configure** in `iris.toml` (or pass CLI flags): + ```toml + [network] + mode = "pcap" + pcap_interface = "1" # 1-based index (recommended), or exact name, or omit to auto-pick + ``` + + On Windows, if you prefer the full device name (`\Device\NPF_{GUID}`), use a + TOML *single-quoted* literal string (backslashes are escape characters in + `"double-quoted"` strings): + ```toml + pcap_interface = '\Device\NPF_{8D30ACAE-AC0F-4E05-BF89-F35AD7950663}' + ``` + +3. **List interfaces**: + ``` + iris --list-net-interfaces + ``` + Or from the monitor console: + ``` + net interfaces + ``` + +Alternatively specify on the command line (the index form works here too): +``` +./target/release/iris --net-mode pcap --pcap-interface 1 +./target/release/iris --net-mode pcap --pcap-interface eth0 +``` + +Caveats: +- Requires elevated privileges to open a raw capture: root or `CAP_NET_RAW` + on Linux, root on macOS, Administrator + a WinPcap-compatible driver + (WinPcap or Npcap) on Windows. +- No NAT services (DHCP/DNS/NFS/port-forward) are provided in PCAP mode — the + guest uses the real network's services. Configure IRIX networking for your + LAN accordingly. +- Wired bridges work best. Many Wi-Fi access points reject the guest's extra + MAC address, so bridging onto a wireless interface may not pass traffic. +- The guest still needs its MAC set in NVRAM (`setenv -f eaddr ...`; see + `rules/irix/networking.md`). + +Without `--features pcap`, selecting `mode = "pcap"` logs a warning and falls +back to the NAT gateway, and `--list-net-interfaces` reports that the feature +is missing. + + ## R5000 CPU (`--features r5k`) Switches the emulated CPU from R4400 to R5000: diff --git a/iris-gui/Cargo.toml b/iris-gui/Cargo.toml index 68aacf2..c550ba6 100644 --- a/iris-gui/Cargo.toml +++ b/iris-gui/Cargo.toml @@ -44,6 +44,12 @@ bundled = [] # the CI/Automation config tab (the iris-ci socket is a developer automation # feature unusable in the sandbox). Set by .github/workflows/appstore.yml. appstore = ["bundled"] +# PCAP bridged networking. Off by default because it adds a hard build-time +# dependency on a pcap library (libpcap headers on Unix, a WinPcap-compatible +# `wpcap` SDK on Windows). When enabled, the Network tab can enumerate host +# interfaces in a dropdown and the in-process VM can actually bridge onto them. +# Build with: cargo build -p iris-gui --features pcap +pcap = ["iris/pcap"] [dependencies] # Group A (additive) features are always on for iris-gui so the user can diff --git a/iris-gui/src/config_ui.rs b/iris-gui/src/config_ui.rs index ea31b3e..2f7ab7e 100644 --- a/iris-gui/src/config_ui.rs +++ b/iris-gui/src/config_ui.rs @@ -2,11 +2,75 @@ use egui::{Color32, ComboBox, DragValue, Grid, RichText, ScrollArea, TextEdit, U use iris::build_features; use std::path::Path; use iris::config::{ - ForwardBind, ForwardProto, MachineConfig, NfsConfig, PortForwardConfig, + ForwardBind, ForwardProto, MachineConfig, NetMode, NfsConfig, PortForwardConfig, ScsiDeviceConfig, VinoSource, VinoStandard, VALID_BANK_SIZES, }; use iris::nfsudp::NfsVersion; +/// A host network interface candidate for the PCAP backend selector. This is a +/// GUI-local, feature-independent copy of `iris::net_pcap::NetInterface` so the +/// App state and `show_network` signature don't need `#[cfg(feature = "pcap")]`. +#[derive(Debug, Clone)] +pub struct PcapIface { + pub name: String, + pub description: Option, + pub addrs: Vec, + pub up: bool, + pub running: bool, + pub loopback: bool, +} + +impl PcapIface { + /// One-line summary for the dropdown row. + fn summary(&self) -> String { + let mut s = self.name.clone(); + let mut tags = Vec::new(); + if self.up { tags.push("up"); } + if self.running { tags.push("running"); } + if self.loopback { tags.push("loopback"); } + if !tags.is_empty() { + s.push_str(&format!(" [{}]", tags.join(","))); + } + if let Some(ip) = self.addrs.first() { + s.push_str(&format!(" {ip}")); + } + // Windows device names are opaque GUIDs; the description (NIC model) is + // far more useful, so append it when present. + if let Some(desc) = self.description.as_deref().filter(|d| !d.is_empty()) { + s.push_str(&format!(" — {desc}")); + } + s + } +} + +/// Enumerate host interfaces for the PCAP selector. Returns the candidate list, +/// or an error string (insufficient privileges / no driver / feature missing). +/// Only does real work when built with `--features pcap`; otherwise returns a +/// hint so the UI can explain why the dropdown is unavailable. +pub fn enumerate_pcap_ifaces() -> Result, String> { + #[cfg(feature = "pcap")] + { + iris::net_pcap::list_interfaces().map(|list| { + list.into_iter() + .map(|i| PcapIface { + name: i.name, + description: i.description, + addrs: i.addresses.iter().map(|a| a.to_string()).collect(), + up: i.up, + running: i.running, + loopback: i.loopback, + }) + .collect() + }) + } + #[cfg(not(feature = "pcap"))] + { + Err("this build lacks --features pcap; rebuild iris-gui with `--features pcap` \ + to enumerate and bridge onto host interfaces" + .to_string()) + } +} + /// Which config tab is focused. Toolbar quick-buttons set this. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Tab { @@ -98,6 +162,9 @@ pub enum ConfigAction { /// host camera and show a live preview (using the current `[vino]` standard /// and camera index). TestCamera, + /// User clicked "Refresh" on the Network tab's PCAP selector; the app should + /// re-enumerate host interfaces and update its cache. + RefreshPcapIfaces, } /// Everything a config tab hands back to the app for one frame. @@ -114,11 +181,15 @@ pub fn show_tab( jit: &mut JitEnv, host: &[crate::netplan::HostIface], disk_folders: &[String], + pcap_ifaces: &Option, String>>, ) -> TabOutcome { ScrollArea::vertical().show(ui, |ui| match tab { Tab::General => TabOutcome { action: show_general(ui, cfg), ..Default::default() }, Tab::Disks => { show_disks(ui, cfg); TabOutcome::default() } - Tab::Network => TabOutcome { net: show_network(ui, cfg, host, disk_folders), ..Default::default() }, + Tab::Network => { + let net = show_network(ui, cfg, host, disk_folders, pcap_ifaces); + TabOutcome { action: net.action, net } + } Tab::Memory => { show_memory(ui, cfg); TabOutcome::default() } Tab::Display => { show_display(ui, cfg); TabOutcome::default() } Tab::VideoIn => TabOutcome { action: show_vino(ui, cfg), ..Default::default() }, @@ -345,13 +416,57 @@ pub struct NetworkOutcome { pub forwards_changed: bool, /// A soft-invalid subnet was just committed → pop the override modal. pub prompt: Option, + /// An app-level action requested from the tab (e.g. the PCAP "Refresh" + /// button asking the app to re-enumerate host interfaces). + pub action: ConfigAction, } -fn show_network(ui: &mut Ui, cfg: &mut MachineConfig, host: &[crate::netplan::HostIface], disk_folders: &[String]) -> NetworkOutcome { +fn show_network( + ui: &mut Ui, + cfg: &mut MachineConfig, + host: &[crate::netplan::HostIface], + disk_folders: &[String], + pcap_ifaces: &Option, String>>, +) -> NetworkOutcome { use crate::netplan; let mut out = NetworkOutcome::default(); ui.heading("Networking"); + // The backend selector (and the entire PCAP UI) is only shown when this + // build actually has PCAP support. App Store / bundled builds compile + // without `--features pcap`, where NAT is the only backend — so PCAP must + // not appear anywhere in the UI (a dangling, non-functional option risks an + // App Store rejection). Such builds also force NAT at runtime regardless of + // a stale `mode = "pcap"` carried in from an imported config. + if build_features::PCAP { + Grid::new("net_mode_grid").num_columns(2).striped(true).show(ui, |ui| { + ui.label("Backend"); + ComboBox::from_id_salt("net_mode") + .selected_text(match cfg.network.mode { + NetMode::Nat => "NAT gateway", + NetMode::Pcap => "PCAP (bridged)", + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut cfg.network.mode, NetMode::Nat, "NAT gateway"); + ui.selectable_value(&mut cfg.network.mode, NetMode::Pcap, "PCAP (bridged)"); + }); + ui.end_row(); + }); + + if cfg.network.mode == NetMode::Pcap { + if let a @ ConfigAction::RefreshPcapIfaces = pcap_interface_picker(ui, cfg, pcap_ifaces) { + out.action = a; + } + + ui.colored_label(Color32::from_rgb(0xd0, 0xa0, 0x40), + "PCAP mode bridges onto a real interface. NAT, port forwards, and NFS \ + below are ignored; the guest uses your real LAN. Requires elevated \ + privileges (root/CAP_NET_RAW on Unix, or a WinPcap-compatible driver \ + + Administrator on Windows)."); + ui.separator(); + } + } + ui.label(RichText::new( "IRIS gives the Indy its own private NAT network, the same trick your home router uses. \ The Indy reaches the internet through IRIS, but nothing on your real network can see it. \ @@ -635,6 +750,93 @@ fn show_network(ui: &mut Ui, cfg: &mut MachineConfig, host: &[crate::netplan::Ho out } +/// PCAP interface picker: a dropdown of enumerated host interfaces (with an +/// "Auto-pick" entry and a "Manual…" escape hatch), plus a Refresh button. +/// Stores the choice by interface *name* in `cfg.network.pcap_interface` +/// (`None` = auto-pick). Returns `RefreshPcapIfaces` when the user asks to +/// re-enumerate. +fn pcap_interface_picker( + ui: &mut Ui, + cfg: &mut MachineConfig, + pcap_ifaces: &Option, String>>, +) -> ConfigAction { + let mut action = ConfigAction::None; + + // Selected text for the combo: the current name, "Auto-pick", or the raw + // value if it's something not in the list (e.g. an index or manual name). + let current = cfg.network.pcap_interface.clone(); + let selected_text = match ¤t { + None => "Auto-pick (first up, non-loopback)".to_string(), + Some(v) if v.is_empty() => "Auto-pick (first up, non-loopback)".to_string(), + Some(v) => v.clone(), + }; + + ui.horizontal(|ui| { + ui.label("PCAP interface"); + + ComboBox::from_id_salt("pcap_iface") + .selected_text(selected_text) + .width(320.0) + .show_ui(ui, |ui| { + // Auto-pick entry. + let mut is_auto = current.as_deref().unwrap_or("").is_empty(); + if ui.selectable_label(is_auto, "Auto-pick (first up, non-loopback)").clicked() { + cfg.network.pcap_interface = None; + is_auto = true; + } + let _ = is_auto; + + match pcap_ifaces { + Some(Ok(list)) if !list.is_empty() => { + ui.separator(); + for iface in list { + let selected = current.as_deref() == Some(iface.name.as_str()); + if ui.selectable_label(selected, iface.summary()).clicked() { + cfg.network.pcap_interface = Some(iface.name.clone()); + } + } + } + Some(Ok(_)) => { + ui.separator(); + ui.label(RichText::new("(no interfaces enumerated)").weak()); + } + Some(Err(e)) => { + ui.separator(); + ui.label(RichText::new(format!("(cannot list: {e})")).weak()); + } + None => { + ui.separator(); + ui.label(RichText::new("(click Refresh to enumerate)").weak()); + } + } + }); + + if ui.button("⟳ Refresh").on_hover_text("Re-enumerate host interfaces").clicked() { + action = ConfigAction::RefreshPcapIfaces; + } + }); + + // Manual entry escape hatch: lets the user type an index ("1"), an exact + // name, or a Windows \Device\NPF_{...} string the dropdown can't show well. + ui.horizontal(|ui| { + ui.label(" or type index/name"); + let mut manual = current.clone().unwrap_or_default(); + if ui.add(TextEdit::singleline(&mut manual) + .hint_text("e.g. 1, eth0, or blank = auto") + .desired_width(260.0)).changed() + { + cfg.network.pcap_interface = if manual.trim().is_empty() { None } else { Some(manual) }; + } + }); + + // Show an inline error if enumeration failed. + if let Some(Err(e)) = pcap_ifaces { + ui.colored_label(Color32::from_rgb(0xe0, 0x60, 0x60), format!("Interface list unavailable: {e}")); + } + + action +} + fn show_vino(ui: &mut Ui, cfg: &mut MachineConfig) -> ConfigAction { let mut action = ConfigAction::None; ui.heading("Video-In (IndyCam)"); diff --git a/iris-gui/src/main.rs b/iris-gui/src/main.rs index e98cdfe..e260e24 100644 --- a/iris-gui/src/main.rs +++ b/iris-gui/src/main.rs @@ -243,6 +243,15 @@ struct App { /// Pending "Discard changes (roll back)" awaiting confirmation; `Some` shows /// the are-you-sure modal. cow_discard_confirm: Option, + /// Cached host network-interface list for the PCAP backend selector on the + /// Network tab. `None` until first enumerated (lazily on tab open / refresh) + /// so we don't call libpcap every frame. `Some(Ok(..))` holds the candidate + /// interfaces; `Some(Err(..))` holds the enumeration error to display. + pcap_ifaces: Option, String>>, + /// Latched networking backend + interface the machine was started with + /// (reset to None on Stop). Used by the running status footer to show "net: + /// PCAP → eth0" or "net: NAT" so the user can verify which backend is active. + launched_net: Option<(iris::config::NetMode, Option)>, } /// Progress of the exit-time "Synchronizing disks…" step. @@ -398,9 +407,18 @@ impl App { syncing: None, sync_then_close: false, cow_discard_confirm: None, + pcap_ifaces: None, + launched_net: None, } } + /// (Re)enumerate host network interfaces for the PCAP selector and cache the + /// result. Cheap to call on demand (tab open / Refresh button); never called + /// per-frame. A no-op placeholder when the build lacks `--features pcap`. + fn refresh_pcap_ifaces(&mut self) { + self.pcap_ifaces = Some(config_ui::enumerate_pcap_ifaces()); + } + fn toast(&mut self, msg: impl Into) { self.toast = Some((msg.into(), std::time::Instant::now())); } @@ -495,6 +513,16 @@ impl App { self.toast(format!("'{}' not found — using embedded PROM", self.cfg.prom)); } self.jit.export(); + // Latch the networking backend the machine is being started with, so the + // running status footer can report PCAP vs NAT (and which interface) + // independent of any later edits to the config editor. On a build without + // PCAP support the runtime forces NAT (even for an imported `mode = + // "pcap"`), so reflect that here and never surface PCAP. + self.launched_net = Some(if iris::build_features::PCAP { + (self.cfg.network.mode, self.cfg.network.pcap_interface.clone()) + } else { + (iris::config::NetMode::Nat, None) + }); self.emu.send(Cmd::Start(Box::new(self.cfg.clone()))); // Don't resize the window when the VM launches — its size is latched at // app load (the saved window size, or the first-launch fit to vm_scale) @@ -1291,6 +1319,24 @@ impl App { if want_check { self.show_net_check = true; } + if running { + // Surface the active networking backend so PCAP vs NAT is verifiable + // at a glance. Reflects the config the running machine was started + // with (latched on Start), not the live editor. + match self.launched_net.as_ref() { + Some((iris::config::NetMode::Pcap, iface)) => { + let iface = iface.as_deref().filter(|s| !s.is_empty()).unwrap_or("auto"); + ui.label(RichText::new(format!("net: PCAP → {iface}")) + .color(Color32::from_rgb(120, 180, 220))) + .on_hover_text("Bridged onto a host interface. See the console for \ + 'bridging onto interface …' / 'backend disabled …'."); + } + Some((iris::config::NetMode::Nat, _)) => { + ui.label(RichText::new("net: NAT").color(Color32::LIGHT_GRAY)); + } + None => {} + } + } if running && self.fb_scale > 0.0 { // How magnified the emulated display currently is (1× = native). // Round-snap the readout so a whole-number scale reads cleanly. @@ -1501,16 +1547,29 @@ impl App { } fn central_tabs(&mut self, ui: &mut egui::Ui) { + let prev_tab = self.tab; ui.horizontal_wrapped(|ui| { for t in Tab::visible() { ui.selectable_value(&mut self.tab, t, t.label()); } }); ui.separator(); - let out = show_tab(ui, self.tab, &mut self.cfg, &mut self.jit, &self.net_ifaces, &self.prefs.disk_folders); + // Lazily enumerate PCAP interfaces the first time the Network tab is + // shown (or when switching to it), so the dropdown is populated without + // calling libpcap every frame. + if self.tab == config_ui::Tab::Network + && (self.pcap_ifaces.is_none() || prev_tab != config_ui::Tab::Network) + { + if self.pcap_ifaces.is_none() { + self.refresh_pcap_ifaces(); + } + } + + let out = show_tab(ui, self.tab, &mut self.cfg, &mut self.jit, &self.net_ifaces, &self.prefs.disk_folders, &self.pcap_ifaces); match out.action { ConfigAction::RequestEmbeddedProm => self.confirm_embedded_prom = true, ConfigAction::TestCamera => self.open_camera_test(), + ConfigAction::RefreshPcapIfaces => self.refresh_pcap_ifaces(), ConfigAction::None => {} } if out.net.changed { self.mark_dirty(); } @@ -2036,7 +2095,23 @@ impl App { }); ui.end_row(); ui.label("Network"); - ui.label(self.cfg.nat_subnet.clone().unwrap_or_else(|| "192.168.0.0/24 (default)".into())); + // On a build without PCAP support, the runtime always uses NAT + // regardless of any imported `mode = "pcap"`, and the UI must never + // surface PCAP — so report NAT unconditionally there. + let effective_pcap = iris::build_features::PCAP + && self.cfg.network.mode == iris::config::NetMode::Pcap; + if effective_pcap { + let iface = match self.cfg.network.pcap_interface.as_deref() { + Some(s) if !s.is_empty() => s.to_string(), + _ => "auto-pick".to_string(), + }; + ui.label(format!("PCAP bridged — interface: {iface}")); + } else { + ui.label(format!( + "NAT gateway — {}", + self.cfg.nat_subnet.clone().unwrap_or_else(|| "192.168.0.0/24 (default)".into()) + )); + } ui.end_row(); }); diff --git a/iris.toml b/iris.toml index 4d93ec0..d633edc 100644 --- a/iris.toml +++ b/iris.toml @@ -127,3 +127,37 @@ bind = "localhost" #[vino] #source = "camera" + +# ── Networking backend ──────────────────────────────────────────────────────── +# mode = "nat" (default) : built-in software NAT gateway (DHCP/DNS/ICMP/TCP/UDP +# + the [[port_forward]] rules above). No host +# privileges or extra libraries needed. +# mode = "pcap" : bridge the guest's raw Ethernet frames onto a real +# host interface (requires building with --features +# pcap and elevated privileges). The guest becomes a +# real L2 host on your LAN; NAT services and port +# forwarding do NOT apply in this mode. +# +# List candidate interfaces: iris --list-net-interfaces +# (or, in the monitor: net interfaces) +# +# pcap_interface accepts EITHER: +# - a 1-based index from --list-net-interfaces, e.g. "1" (easiest, esp. Windows) +# - an exact device name, e.g. "eth0" / "en0" +# - omit it entirely: if launched from a terminal you'll get an interactive +# menu to pick the interface; if there's no console (headless/CI) it +# auto-picks the first up, non-loopback NIC. +# +# IMPORTANT (Windows): device names look like \Device\NPF_{GUID}. Backslashes are +# escape characters inside a normal "double-quoted" TOML string, so you MUST use a +# 'single-quoted' TOML *literal* string (no escaping), or just use the index. +# +# NOTE: the [network] header below is REQUIRED. If you uncomment `mode`/ +# `pcap_interface` but leave `[network]` commented out, the keys land in the +# top-level table and are rejected as unknown (the emulator will refuse to +# start) — they only take effect under the [network] table. +[network] +mode = "nat" +#pcap_interface = "1" # by index (recommended) +#pcap_interface = "wlan0" # by name (Linux/macOS) +#pcap_interface = '\Device\NPF_{385F30D0-9166-45D3-BBC6-F1D9C5300AF9}' # Windows (literal '...' string!) diff --git a/rules/irix/networking.md b/rules/irix/networking.md index b09ae9a..9156a84 100644 --- a/rules/irix/networking.md +++ b/rules/irix/networking.md @@ -56,6 +56,26 @@ guest_port = 23 bind = "localhost" ``` +## PCAP bridged networking (alternative to NAT) + +Build with `cargo build --features chd,pcap`. Then in `iris.toml`, set +`[network] mode = "pcap"` and optionally specify a host interface with +`pcap_interface = ""`. The interface choice can be a numeric +index (recommended, esp. on Windows where names are `\Device\NPF_{GUID}`), an +exact name, or omitted to auto-pick. On Windows, a literal name must use a TOML +*single-quoted* literal string because backslashes are escapes in +`"double-quoted"` strings: `pcap_interface = '\Device\NPF_{...}'`. + +In PCAP mode the guest is a real L2 host on the physical LAN — there is NO +built-in DHCP/DNS/NFS/port-forward. Configure IRIX networking for your real +network (the `/etc/config` files above still apply, with your LAN's addresses). + +Requires root/CAP_NET_RAW (Linux), root (macOS), or Administrator + a +WinPcap-compatible driver (WinPcap or Npcap) on Windows. The `pcap` crate links +the generic `wpcap` import library, so the BSD-licensed WinPcap Developer Pack +works too (set `LIBPCAP_LIBDIR` to point the linker at it); IRIS links +dynamically and bundles no driver. The NVRAM `eaddr` MAC must still be set. + ## Keyboard workaround Alt-tabbing away from the Rex window corrupts IRIX X11 keyboard input diff --git a/src/config.rs b/src/config.rs index 5b0163d..1dda69c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -99,6 +99,26 @@ impl Default for NatSubnet { } } +/// Selects which networking backend the SEEQ Ethernet controller is wired to. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, clap::ValueEnum)] +#[serde(rename_all = "lowercase")] +#[value(rename_all = "lowercase")] +pub enum NetMode { + /// Built-in software NAT gateway (ARP/DHCP/DNS/ICMP/TCP/UDP + port forwarding). + /// Works without any host privileges or extra libraries. This is the default. + Nat, + /// Bridge raw Ethernet frames onto a real host interface via libpcap + /// (Linux/macOS) or a WinPcap-compatible driver (WinPcap or Npcap) on Windows. + /// Requires building with `--features pcap` and elevated privileges at runtime. + /// The guest appears as a real L2 host on the physical LAN; NAT services + /// (DHCP/DNS/NFS/port-forward) are NOT provided — use the real network's. + Pcap, +} + +impl Default for NetMode { + fn default() -> Self { NetMode::Nat } +} + /// Networking parameters extracted from `MachineConfig` for the NAT engine and HPC3. #[derive(Debug, Clone, Default)] pub struct NetworkConfig { @@ -106,6 +126,24 @@ pub struct NetworkConfig { pub port_forward: Vec, /// Parsed subnet; None means use the built-in default (192.168.0.0/24). pub nat_subnet: Option, + /// Backend selection: NAT (default) or PCAP bridged. + pub mode: NetMode, + /// Host interface name to bridge onto when `mode == Pcap`. None = auto-pick + /// the first non-loopback interface that libpcap reports as up/running. + pub pcap_interface: Option, +} + +/// `[network]` section: backend selection and PCAP options. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct NetworkSection { + /// Backend: "nat" (default) or "pcap". + #[serde(default)] + pub mode: NetMode, + /// Host interface to bridge onto in PCAP mode (e.g. "eth0", "en0"). + /// Run `iris --list-net-interfaces` to enumerate candidates. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pcap_interface: Option, } /// Where VINO's video-in capture should come from. @@ -199,7 +237,14 @@ mod scsi_keys { /// scalar/inline-value field to be emitted before any table or array-of-table /// field, so all scalars are declared first and the table-valued fields /// (`scsi`, `nfs`, `port_forward`, `vino`) come last. +/// +/// `deny_unknown_fields` makes typos and misplaced keys a hard parse error +/// instead of silently ignoring them. This catches a common footgun: writing +/// `mode = "pcap"` at the top level (because `[network]` was left commented +/// out) used to be silently dropped, so PCAP never engaged and networking +/// quietly stayed on NAT. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct MachineConfig { /// Path to the PROM ROM image. #[serde(default = "default_prom")] @@ -290,6 +335,10 @@ pub struct MachineConfig { /// VINO video-in configuration (IndyCam emulation source). #[serde(default)] pub vino: VinoConfig, + + /// Networking backend selection (`[network]` section). Defaults to NAT. + #[serde(default)] + pub network: NetworkSection, } fn default_ci_socket() -> String { "/tmp/iris.sock".to_string() } @@ -350,6 +399,7 @@ impl Default for MachineConfig { ci_display: false, serial_log: None, vino: VinoConfig::default(), + network: NetworkSection::default(), mouse_scroll_pixels_per_line: default_scroll_pixels_per_line(), lock_aspect_ratio: default_lock_aspect_ratio(), } @@ -359,6 +409,13 @@ impl Default for MachineConfig { impl MachineConfig { /// Load from `iris.toml` if it exists, otherwise return defaults. + /// + /// A *missing* file is fine (defaults are used). A file that exists but + /// fails to parse is **fatal**: previously we silently fell back to + /// defaults, which hid config mistakes — e.g. a Windows `pcap_interface` + /// with unescaped backslashes would discard the entire config, so the + /// emulator would quietly auto-pick a different interface and run with NAT + /// instead of the settings the user wrote. pub fn load_toml(path: &str) -> Self { let Ok(text) = std::fs::read_to_string(path) else { return Self::default(); @@ -366,8 +423,19 @@ impl MachineConfig { match toml::from_str::(&text) { Ok(cfg) => cfg, Err(e) => { - eprintln!("Warning: failed to parse {}: {}", path, e); - Self::default() + eprintln!("Configuration error: failed to parse {}:\n{}", path, e); + // Common footgun: backslashes in a double-quoted string (Windows + // pcap device names look like \Device\NPF_{GUID}). + if text.contains("\\Device\\NPF") || e.to_string().contains("escape") { + eprintln!( + "\nhint: backslashes are escape characters inside a \"double-quoted\" TOML string.\n\ + For a Windows pcap interface, either use the numeric index from\n\ + `iris --list-net-interfaces` (e.g. pcap_interface = \"1\") or a TOML\n\ + 'single-quoted' literal string:\n\ + \n pcap_interface = '\\Device\\NPF_{{...}}'\n" + ); + } + std::process::exit(1); } } } @@ -414,6 +482,8 @@ impl MachineConfig { nfs: self.nfs.clone(), port_forward: self.port_forward.clone(), nat_subnet, + mode: self.network.mode, + pcap_interface: self.network.pcap_interface.clone(), } } @@ -520,6 +590,21 @@ pub struct Cli { #[arg(long = "nat-subnet", value_name = "CIDR")] pub nat_subnet: Option, + /// Networking backend: "nat" (default, software gateway) or "pcap" + /// (bridge onto a real host interface; requires --features pcap). + #[arg(long = "net-mode", value_name = "MODE")] + pub net_mode: Option, + + /// Host interface to bridge onto in PCAP mode (e.g. eth0, en0). + /// Implies --net-mode pcap. List candidates with --list-net-interfaces. + #[arg(long = "pcap-interface", value_name = "IFACE")] + pub pcap_interface: Option, + + /// Print the host network interfaces libpcap can bridge onto, then exit. + /// Requires a build with --features pcap. + #[arg(long = "list-net-interfaces", default_value_t = false)] + pub list_net_interfaces: bool, + /// Enable GDB stub on the given TCP port (e.g. --gdb-port 1234). /// Connect with: target remote localhost: #[arg(long = "gdb-port", value_name = "PORT")] @@ -604,6 +689,16 @@ impl Cli { if let Some(p) = self.gdb_port { cfg.gdb_port = Some(p); } if let Some(ref s) = self.nat_subnet { cfg.nat_subnet = Some(s.clone()); } + if let Some(m) = self.net_mode { cfg.network.mode = m; } + if let Some(ref iface) = self.pcap_interface { + cfg.network.pcap_interface = Some(iface.clone()); + // Specifying an interface implies PCAP mode unless the user also + // explicitly asked for NAT. + if self.net_mode.is_none() { + cfg.network.mode = NetMode::Pcap; + } + } + cfg } } @@ -612,6 +707,22 @@ impl Cli { /// Returns (machine_config, window_scale) where window_scale is 1 or 2. pub fn load_config() -> (MachineConfig, u32) { let cli = Cli::parse(); + + // --list-net-interfaces: print candidate PCAP interfaces and exit. Handled + // here (before machine construction) since it only needs the parsed CLI. + if cli.list_net_interfaces { + #[cfg(feature = "pcap")] + { + print!("{}", crate::net_pcap::format_interfaces()); + std::process::exit(0); + } + #[cfg(not(feature = "pcap"))] + { + eprintln!("iris: --list-net-interfaces requires a build with --features pcap"); + std::process::exit(1); + } + } + let toml_cfg = MachineConfig::load_toml(&cli.config); let cfg = cli.apply(toml_cfg); let scale = cfg.scale; diff --git a/src/hpc3.rs b/src/hpc3.rs index 056dcf7..36bdd33 100644 --- a/src/hpc3.rs +++ b/src/hpc3.rs @@ -1020,6 +1020,8 @@ impl Hpc3 { let nfs = net.nfs; let port_forwards = net.port_forward; let subnet = net.nat_subnet.unwrap_or_default(); + let net_mode = net.mode; + let pcap_interface = net.pcap_interface; let rtc = Arc::new(Ds1x86::new(8192, nvram_path)); let pdma_dump = Arc::new(AtomicU32::new(0)); @@ -1087,6 +1089,8 @@ impl Hpc3 { gateway_ip: subnet.gateway_ip, client_ip: subnet.client_ip, netmask: subnet.netmask, + mode: net_mode, + pcap_interface, ..GatewayConfig::default() }; let seeq = Arc::new(Seeq8003::with_config(Some(seeq_irq), Some(enet_rx_dma), Some(enet_tx_dma), gateway_cfg, heartbeat.clone())); diff --git a/src/lib.rs b/src/lib.rs index a09bd30..1b224c6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod build_features { pub const CHD: bool = cfg!(feature = "chd"); pub const CAMERA: bool = cfg!(feature = "camera"); + pub const PCAP: bool = cfg!(feature = "pcap"); pub const JIT: bool = cfg!(feature = "jit"); pub const REX_JIT: bool = cfg!(feature = "rex-jit"); /// Lightning build strips breakpoint checks and the traceback buffer @@ -44,6 +45,8 @@ pub mod locks; pub mod pit8254; pub mod net; pub mod nfsudp; +#[cfg(feature = "pcap")] +pub mod net_pcap; pub mod seeq8003; pub mod cow_disk; #[cfg(feature = "chd")] diff --git a/src/main.rs b/src/main.rs index 94217f4..6ae69aa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,26 @@ use iris::machine::Machine; fn main() { print_build_features(); - let (cfg, scale) = load_config(); + let (mut cfg, scale) = load_config(); + + // If PCAP networking is selected but no interface was configured, prompt the + // user to choose one now — on the main thread, before the machine starts — + // and write the choice into the config. Done here (not in the network + // backend, which spins up on its own thread during boot) so the prompt + // actually gates startup and reliably reads the console on every platform. + // Skipped silently when there's no interactive console (headless/CI), where + // the backend then auto-picks. + #[cfg(feature = "pcap")] + { + if cfg.network.mode == iris::config::NetMode::Pcap + && cfg.network.pcap_interface.as_deref().map(str::trim).unwrap_or("").is_empty() + { + if let Some(name) = iris::net_pcap::prompt_for_interface() { + cfg.network.pcap_interface = Some(name); + } + } + } + let scroll_pixels_per_line = cfg.mouse_scroll_pixels_per_line; let lock_aspect_ratio = cfg.lock_aspect_ratio; let headless = cfg.headless; @@ -119,6 +138,7 @@ fn print_build_features() { ("tlbstats", cfg!(feature = "tlbstats")), ("chd", cfg!(feature = "chd")), ("camera", cfg!(feature = "camera")), + ("pcap", cfg!(feature = "pcap")), ("ci_clock", cfg!(feature = "ci_clock")), ("developer", cfg!(feature = "developer")), ("developer_ip7", cfg!(feature = "developer_ip7")), diff --git a/src/net.rs b/src/net.rs index 1bf1fec..731f2d3 100644 --- a/src/net.rs +++ b/src/net.rs @@ -9,7 +9,7 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener, TcpStream, UdpSocket}; use socket2::{Domain, Protocol, Socket, Type}; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::Arc; -use crate::config::{ForwardBind, ForwardProto, NatSubnet, NfsConfig, PortForwardConfig}; +use crate::config::{ForwardBind, ForwardProto, NatSubnet, NetMode, NfsConfig, PortForwardConfig}; use crate::devlog::LogModule; use parking_lot::{Condvar, Mutex}; use std::time::{Duration, Instant}; @@ -60,6 +60,10 @@ pub struct GatewayConfig { pub nfs: Option, /// Port forwarding rules: host listens and forwards to the guest. pub port_forwards: Vec, + /// Networking backend: NAT (software gateway) or PCAP (bridged). Default NAT. + pub mode: NetMode, + /// Host interface to bridge onto in PCAP mode. None = auto-pick. + pub pcap_interface: Option, } impl Default for GatewayConfig { @@ -73,10 +77,27 @@ impl Default for GatewayConfig { dns_upstream: "8.8.8.8:53".parse().unwrap(), nfs: None, port_forwards: vec![], + mode: NetMode::Nat, + pcap_interface: None, } } } +// ── Network backend abstraction ─────────────────────────────────────────────── +// +// The SEEQ 8003 chip is backend-agnostic: it hands outbound Ethernet frames to a +// backend over an rtrb ring and receives inbound frames back over another. Any +// type that owns those endpoints and a run loop can serve as the backend. Today +// there are two implementations: +// - `NatEngine` (this file): a software NAT gateway/router. +// - `PcapEngine` (net_pcap.rs, `--features pcap`): bridges frames onto a real +// host interface via libpcap. +pub trait NetBackend: Send { + /// Run the backend loop until the shared `running` flag goes false. Blocks + /// the calling thread (the `seeq-nat` thread). + fn run(&mut self); +} + // ── NAT table entries ───────────────────────────────────────────────────────── struct NatUdpEntry { sock: UdpSocket, @@ -2138,3 +2159,10 @@ impl NatEngine { } } } + +impl NetBackend for NatEngine { + fn run(&mut self) { + // Delegate to the inherent run loop. + NatEngine::run(self) + } +} diff --git a/src/net_pcap.rs b/src/net_pcap.rs new file mode 100644 index 0000000..5f05aea --- /dev/null +++ b/src/net_pcap.rs @@ -0,0 +1,484 @@ +// PCAP bridged-networking backend for the SEEQ 8003 emulator. +// +// An alternative to the software NAT gateway in net.rs. Instead of synthesizing +// replies, this backend bridges the guest's raw Ethernet frames straight onto a +// real host interface via libpcap. The guest then appears as a real layer-2 +// host on the physical LAN: it can DHCP from the real network, be pinged from +// other machines, etc. +// +// Wiring is identical to NatEngine: it owns the same rtrb ring endpoints and +// wake condvars, runs on the "seeq-nat" thread, and implements NetBackend. +// +// Library / licensing note: +// The `pcap` crate links the generic `wpcap` import library on Windows (NOT a +// driver-specific one), so IRIS is not tied to any single provider. You can +// build/link against the BSD-licensed WinPcap Developer Pack as well as Npcap. +// We link dynamically and never bundle the driver, so the runtime driver's +// license (e.g. Npcap's redistribution terms) does not attach to IRIS. +// +// Requirements: +// - Build with `--features pcap` (pulls in the `pcap` crate; needs libpcap +// headers/lib on Unix, or a WinPcap-compatible `wpcap` SDK on Windows). +// - Run with privileges to open a raw capture (root / CAP_NET_RAW on Linux, +// root on macOS, Administrator + a WinPcap-compatible driver on Windows). +// +// Caveats: +// - libpcap delivers our own injected (TX) frames back on the capture handle. +// We filter those out by dropping any captured frame whose Ethernet source +// MAC equals the guest's MAC (learned from the first outbound frame). +// - No NAT services are provided. The guest must obtain its IP from the real +// network (DHCP or static) and the host interface must be on a network that +// tolerates an extra MAC (wired bridges work best; many Wi-Fi APs reject it). + +use std::io::Write as _; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use parking_lot::{Condvar, Mutex}; + +use crate::config::NetMode; +use crate::devlog::LogModule; +use crate::net::{eth_summary, mac_str, GatewayConfig, NatControl, NetBackend}; + +/// Summary of one host interface, returned by `list_interfaces()`. +pub struct NetInterface { + pub name: String, + pub description: Option, + pub addresses: Vec, + pub up: bool, + pub running: bool, + pub loopback: bool, +} + +/// Enumerate the host network interfaces libpcap can bridge onto. +/// +/// Returns an error string if libpcap can't list devices (e.g. insufficient +/// privileges, or no WinPcap-compatible driver installed on Windows). +pub fn list_interfaces() -> Result, String> { + let devices = pcap::Device::list().map_err(|e| e.to_string())?; + Ok(devices + .into_iter() + .map(|d| { + let flags = &d.flags; + NetInterface { + name: d.name, + description: d.desc, + addresses: d.addresses.into_iter().map(|a| a.addr).collect(), + up: flags.is_up(), + running: flags.is_running(), + loopback: flags.is_loopback(), + } + }) + .collect()) +} + +/// Format the interface list as a human-readable table for CLI/monitor output. +pub fn format_interfaces() -> String { + match list_interfaces() { + Ok(ifaces) => { + if ifaces.is_empty() { + return "No network interfaces found (libpcap returned an empty list).\n\ + On Linux you may need root or CAP_NET_RAW; on Windows, install a\n\ + WinPcap-compatible driver (WinPcap or Npcap).\n" + .to_string(); + } + let mut out = String::new(); + out.push_str("Available network interfaces for PCAP bridging:\n"); + out.push_str("(configure with `[network] pcap_interface = \"\"` in iris.toml;\n"); + out.push_str(" e.g. pcap_interface = \"1\" selects the first interface below)\n\n"); + for (idx, i) in ifaces.iter().enumerate() { + let mut tags = Vec::new(); + if i.up { tags.push("up"); } + if i.running { tags.push("running"); } + if i.loopback { tags.push("loopback"); } + let tags_str = if tags.is_empty() { String::new() } else { format!(" [{}]", tags.join(",")) }; + // 1-based index for human selection. + out.push_str(&format!(" {:>2}. {}{}\n", idx + 1, i.name, tags_str)); + if let Some(desc) = &i.description { + out.push_str(&format!(" {}\n", desc)); + } + if !i.addresses.is_empty() { + let addrs: Vec = i.addresses.iter().map(|a| a.to_string()).collect(); + out.push_str(&format!(" addr: {}\n", addrs.join(", "))); + } + } + out + } + Err(e) => format!( + "Failed to list network interfaces: {}\n\ + On Linux you may need root or CAP_NET_RAW; on Windows, install a\n\ + WinPcap-compatible driver (WinPcap or Npcap).\n", + e + ), + } +} + +/// Resolve `[network] pcap_interface` to a concrete device name. +/// +/// The configured value may be: +/// - a 1-based index into the `--list-net-interfaces` listing (e.g. "1"), +/// which is convenient on Windows where device names are long NPF GUIDs +/// like `\Device\NPF_{....}`; +/// - an exact device name (e.g. "eth0", or the full `\Device\NPF_{...}`); +/// - empty / unset, in which case we auto-pick the first non-loopback +/// interface that is up and running with an address. +/// +/// Index resolution uses the same ordering libpcap reports, so the numbers +/// match exactly what the user sees in `--list-net-interfaces`. +fn select_interface(configured: &Option) -> Result { + let ifaces = list_interfaces()?; + + // An explicit value (index or name) always wins. + let trimmed = configured.as_deref().map(str::trim).unwrap_or(""); + if !trimmed.is_empty() { + return resolve_interface(Some(trimmed), &ifaces); + } + + // No interface configured: auto-pick. Interactive selection (the numbered + // menu shown when no interface is set) is done once at CLI startup on the + // main thread via `prompt_for_interface`, NOT here — this runs on the + // seeq-nat thread during machine boot, where it can't gate startup and + // (on Windows especially) a background-thread stdin read returns EOF + // immediately, so a prompt here would just flash the menu and auto-pick. + auto_pick(&ifaces) +} + +/// Interactively prompt on stdin for a host interface to bridge onto, for use +/// when `[network] mode = "pcap"` is set but no `pcap_interface` is configured. +/// +/// Returns the chosen device name, or `None` to defer to auto-pick (blank input, +/// EOF, no interactive console, or enumeration failure). Must be called once at +/// CLI startup on the **main thread** — never from the network backend thread, +/// which runs during boot and must not block on or race the console. +pub fn prompt_for_interface() -> Option { + if !stdin_is_tty() { + return None; + } + let ifaces = list_interfaces().ok()?; + prompt_interface(&ifaces) +} + +/// Pure resolution of an *explicit* (non-empty) `pcap_interface` value against +/// an enumerated list: numeric 1-based index, exact name, or verbatim +/// pass-through. Split out so it can be unit-tested without libpcap. Passing +/// `None`/empty here auto-picks (used by the unit tests). +fn resolve_interface(configured: Option<&str>, ifaces: &[NetInterface]) -> Result { + if let Some(raw) = configured { + let trimmed = raw.trim(); + if !trimmed.is_empty() { + // Numeric index (1-based) selection. + if let Ok(idx) = trimmed.parse::() { + if idx == 0 || idx > ifaces.len() { + return Err(format!( + "pcap_interface index {} is out of range (1..={}); \ + run `iris --list-net-interfaces`", + idx, ifaces.len() + )); + } + return Ok(ifaces[idx - 1].name.clone()); + } + // Exact name match (case-sensitive; NPF GUIDs are case-sensitive). + if let Some(found) = ifaces.iter().find(|i| i.name == trimmed) { + return Ok(found.name.clone()); + } + // Not an index and not a known name: pass it through verbatim so a + // valid-but-unlisted name (or one libpcap accepts directly) still works, + // but warn that it wasn't in the enumerated list. + eprintln!( + "iris: pcap_interface '{}' not found in the interface list; \ + trying it verbatim. Run `iris --list-net-interfaces` to see valid names/indices.", + trimmed + ); + return Ok(trimmed.to_string()); + } + } + auto_pick(ifaces) +} + +/// Auto-pick the first up, running, non-loopback interface that has an address. +fn auto_pick(ifaces: &[NetInterface]) -> Result { + ifaces + .iter() + .find(|i| i.up && i.running && !i.loopback && !i.addresses.is_empty()) + .or_else(|| ifaces.iter().find(|i| i.up && !i.loopback)) + .map(|i| i.name.clone()) + .ok_or_else(|| "no suitable non-loopback interface found; set [network] pcap_interface".to_string()) +} + +/// True when stdin is connected to an interactive terminal. +fn stdin_is_tty() -> bool { + #[cfg(unix)] + { + // SAFETY: isatty just inspects the fd; STDIN_FILENO is always valid. + unsafe { libc::isatty(libc::STDIN_FILENO) == 1 } + } + #[cfg(windows)] + { + use std::os::windows::io::AsRawHandle; + // GetConsoleMode succeeds only on a real console handle. + extern "system" { + fn GetConsoleMode(h: *mut std::ffi::c_void, mode: *mut u32) -> i32; + } + let handle = std::io::stdin().as_raw_handle(); + let mut mode: u32 = 0; + unsafe { GetConsoleMode(handle as *mut std::ffi::c_void, &mut mode) != 0 } + } + #[cfg(not(any(unix, windows)))] + { + false + } +} + +/// Show a numbered menu of interfaces and read the user's choice from stdin. +/// Returns the chosen device name, or None on empty input / EOF / invalid +/// choice (caller falls back to auto-pick). +fn prompt_interface(ifaces: &[NetInterface]) -> Option { + if ifaces.is_empty() { + return None; + } + eprintln!(); + eprintln!("PCAP networking: no [network] pcap_interface configured."); + eprintln!("Select the host interface to bridge the guest onto:"); + eprintln!(); + for (idx, i) in ifaces.iter().enumerate() { + let mut tags = Vec::new(); + if i.up { tags.push("up"); } + if i.running { tags.push("running"); } + if i.loopback { tags.push("loopback"); } + let tags_str = if tags.is_empty() { String::new() } else { format!(" [{}]", tags.join(",")) }; + let addr = if i.addresses.is_empty() { + String::new() + } else { + format!(" {}", i.addresses.iter().map(|a| a.to_string()).collect::>().join(", ")) + }; + let desc = i.description.as_deref().unwrap_or(""); + eprintln!(" {:>2}) {}{}{}", idx + 1, i.name, tags_str, addr); + if !desc.is_empty() { + eprintln!(" {}", desc); + } + } + eprintln!(); + eprint!("Interface number (1-{}, blank = auto-pick): ", ifaces.len()); + let _ = std::io::stderr().flush(); + + let mut line = String::new(); + if std::io::stdin().read_line(&mut line).ok()? == 0 { + return None; // EOF + } + let choice = line.trim(); + if choice.is_empty() { + return None; // blank → auto-pick + } + match choice.parse::() { + Ok(idx) if idx >= 1 && idx <= ifaces.len() => { + let name = ifaces[idx - 1].name.clone(); + eprintln!("Using interface: {}", name); + Some(name) + } + _ => { + eprintln!("Invalid selection '{}'; auto-picking instead.", choice); + None + } + } +} + +/// PCAP bridged backend. Mirrors the threading/ring-buffer contract of NatEngine. +pub struct PcapEngine { + config: GatewayConfig, + tx_cons: rtrb::Consumer>, // outbound frames from enet thread + rx_prod: rtrb::Producer>, // inbound frames to enet thread + rx_wake: Arc<(Mutex<()>, Condvar)>, // signal enet thread on new RX + tx_wake: Arc<(Mutex<()>, Condvar)>, // wait for new TX frames + running: Arc, + ctl: Arc, + /// Guest MAC, learned from the first outbound frame. Used to filter our own + /// injected frames back out of the capture stream. + guest_mac: Option<[u8; 6]>, +} + +impl PcapEngine { + pub fn new(config: GatewayConfig, + tx_cons: rtrb::Consumer>, + rx_prod: rtrb::Producer>, + rx_wake: Arc<(Mutex<()>, Condvar)>, + tx_wake: Arc<(Mutex<()>, Condvar)>, + running: Arc, + ctl: Arc) -> Self { + Self { config, tx_cons, rx_prod, rx_wake, tx_wake, running, ctl, guest_mac: None } + } + + /// Open the configured (or auto-selected) capture handle in promiscuous, + /// immediate, non-blocking mode. + fn open_capture(&self) -> Result, String> { + let iface = select_interface(&self.config.pcap_interface)?; + eprintln!("iris: PCAP networking bridging onto interface '{}'", iface); + let cap = pcap::Capture::from_device(iface.as_str()) + .map_err(|e| format!("open device '{}': {}", iface, e))? + .promisc(true) + .immediate_mode(true) + .snaplen(65535) + .timeout(1) + .open() + .map_err(|e| format!("activate device '{}': {} (need root/CAP_NET_RAW on Unix, or a WinPcap-compatible driver + Administrator on Windows?)", iface, e))?; + cap.setnonblock().map_err(|e| format!("set non-blocking: {}", e)) + } +} + +impl NetBackend for PcapEngine { + fn run(&mut self) { + let mut cap = match self.open_capture() { + Ok(c) => c, + Err(e) => { + eprintln!("iris: PCAP backend disabled: {}", e); + eprintln!("iris: the guest will have NO networking. Use `[network] mode = \"nat\"` for the software gateway."); + // Drain TX so the guest's ring doesn't back up, but produce no RX. + while self.running.load(Ordering::Relaxed) { + { + let (lock, cvar) = &*self.tx_wake; + let mut guard = lock.lock(); + let _ = cvar.wait_for(&mut guard, Duration::from_millis(50)); + } + while self.tx_cons.pop().is_ok() {} + } + return; + } + }; + + while self.running.load(Ordering::Relaxed) { + // Wait for outbound frames or a short timeout to poll the capture. + { + let (lock, cvar) = &*self.tx_wake; + let mut guard = lock.lock(); + let _ = cvar.wait_for(&mut guard, Duration::from_millis(1)); + } + + // Machine reset: nothing stateful to flush in bridged mode, but honor + // the flag so it doesn't stay set. + self.ctl.reset_nat.swap(false, Ordering::AcqRel); + + // ── TX: guest → host wire ──────────────────────────────────────── + while let Ok(frame) = self.tx_cons.pop() { + if frame.len() >= 12 && self.guest_mac.is_none() { + let mac: [u8; 6] = frame[6..12].try_into().unwrap(); + self.guest_mac = Some(mac); + dlog_dev!(LogModule::Net, "PCAP learned guest MAC {}", mac_str(&mac)); + } + if self.ctl.dbg_tcp() { + dlog_dev!(LogModule::Net, "PCAP TX {}", eth_summary(&frame)); + } + if let Err(e) = cap.sendpacket(&frame[..]) { + dlog_dev!(LogModule::Net, "PCAP sendpacket failed: {}", e); + } + } + + // ── RX: host wire → guest ──────────────────────────────────────── + // Drain everything currently buffered; non-blocking next_packet + // returns TimeoutExpired/NoMorePackets when empty. + loop { + if self.rx_prod.slots() == 0 { break; } + match cap.next_packet() { + Ok(pkt) => { + let frame: &[u8] = &pkt; + if frame.len() < 14 { continue; } + // Drop our own injected frames (src MAC == guest MAC). + if let Some(gmac) = self.guest_mac { + if frame[6..12] == gmac { + continue; + } + } + // Hand the raw frame to the enet thread; address filtering + // (station MAC / broadcast / multicast / promiscuous) is + // applied there in Seeq8003::pump_rx. + if self.ctl.dbg_tcp() { + dlog_dev!(LogModule::Net, "PCAP RX {}", eth_summary(frame)); + } + if self.rx_prod.push(frame.to_vec()).is_ok() { + self.rx_wake.1.notify_one(); + } + } + Err(pcap::Error::TimeoutExpired) | Err(pcap::Error::NoMorePackets) => break, + Err(e) => { + dlog_dev!(LogModule::Net, "PCAP next_packet error: {}", e); + break; + } + } + } + } + } +} + +/// True when the requested mode is PCAP. Convenience so callers don't need to +/// import NetMode just to branch. +pub fn is_pcap_mode(mode: NetMode) -> bool { + mode == NetMode::Pcap +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{IpAddr, Ipv4Addr}; + + fn iface(name: &str, up: bool, running: bool, loopback: bool, has_addr: bool) -> NetInterface { + NetInterface { + name: name.to_string(), + description: None, + addresses: if has_addr { vec![IpAddr::V4(Ipv4Addr::new(192, 168, 0, 2))] } else { vec![] }, + up, running, loopback, + } + } + + fn sample() -> Vec { + vec![ + iface("eth0", true, true, false, true), + iface(r"\Device\NPF_{ABC-123}", true, true, false, true), + iface("lo", true, true, true, true), + ] + } + + #[test] + fn selects_by_index() { + let ifaces = sample(); + assert_eq!(resolve_interface(Some("1"), &ifaces).unwrap(), "eth0"); + // Index 2 resolves the awkward Windows-style name without the user typing it. + assert_eq!(resolve_interface(Some("2"), &ifaces).unwrap(), r"\Device\NPF_{ABC-123}"); + // Whitespace tolerated. + assert_eq!(resolve_interface(Some(" 1 "), &ifaces).unwrap(), "eth0"); + } + + #[test] + fn index_out_of_range_errors() { + let ifaces = sample(); + assert!(resolve_interface(Some("0"), &ifaces).is_err()); + assert!(resolve_interface(Some("4"), &ifaces).is_err()); + } + + #[test] + fn selects_by_exact_name() { + let ifaces = sample(); + assert_eq!(resolve_interface(Some("eth0"), &ifaces).unwrap(), "eth0"); + assert_eq!( + resolve_interface(Some(r"\Device\NPF_{ABC-123}"), &ifaces).unwrap(), + r"\Device\NPF_{ABC-123}" + ); + } + + #[test] + fn unknown_name_passes_through_verbatim() { + let ifaces = sample(); + assert_eq!(resolve_interface(Some("tap5"), &ifaces).unwrap(), "tap5"); + } + + #[test] + fn auto_picks_first_up_running_nonloopback_with_addr() { + let ifaces = sample(); + assert_eq!(resolve_interface(None, &ifaces).unwrap(), "eth0"); + assert_eq!(resolve_interface(Some(""), &ifaces).unwrap(), "eth0"); + } + + #[test] + fn auto_pick_errors_when_only_loopback() { + let ifaces = vec![iface("lo", true, true, true, true)]; + assert!(resolve_interface(None, &ifaces).is_err()); + } +} diff --git a/src/seeq8003.rs b/src/seeq8003.rs index c3577ae..84f40dd 100644 --- a/src/seeq8003.rs +++ b/src/seeq8003.rs @@ -555,9 +555,40 @@ impl Device for Seeq8003 { let rx_wake_nat = self.rx_wake.clone(); let nat_ctl = self.nat_ctl.clone(); thread::Builder::new().name("seeq-nat".into()).spawn(move || { - NatEngine::new(config, tx_cons, rx_prod, - rx_wake_nat, tx_wake_nat, - running_nat, nat_ctl).run(); + // Select the network backend. PCAP bridging requires --features pcap; + // if the build lacks it, fall back to the software NAT gateway. + #[cfg(feature = "pcap")] + { + use crate::net::NetBackend; + if config.mode == crate::config::NetMode::Pcap { + eprintln!("iris: networking backend = PCAP (bridged)"); + let mut engine = crate::net_pcap::PcapEngine::new( + config, tx_cons, rx_prod, + rx_wake_nat, tx_wake_nat, + running_nat, nat_ctl); + engine.run(); + return; + } + eprintln!("iris: networking backend = NAT (software gateway)"); + let mut engine = NatEngine::new(config, tx_cons, rx_prod, + rx_wake_nat, tx_wake_nat, + running_nat, nat_ctl); + engine.run(); + return; + } + #[cfg(not(feature = "pcap"))] + { + if config.mode == crate::config::NetMode::Pcap { + eprintln!("iris: [network] mode = \"pcap\" requested but this build \ + lacks --features pcap; falling back to NAT gateway. \ + Rebuild with `--features pcap`."); + } else { + eprintln!("iris: networking backend = NAT (software gateway)"); + } + NatEngine::new(config, tx_cons, rx_prod, + rx_wake_nat, tx_wake_nat, + running_nat, nat_ctl).run(); + } }).expect("seeq-nat spawn"); // ── seeq-enet thread: DMA pump loop ─────────────────────────────────── @@ -685,7 +716,7 @@ impl Device for Seeq8003 { fn register_commands(&self) -> Vec<(String, String)> { vec![ ("seeq".into(), "seeq status".into()), - ("net".into(), "net status [tcp|udp|icmp|all] | net debug [tcp|udp|icmp] [DEV]".into()), + ("net".into(), "net status [tcp|udp|icmp|all] | net debug [tcp|udp|icmp] [DEV] | net interfaces".into()), ] } @@ -771,7 +802,18 @@ impl Device for Seeq8003 { _ => return Err("usage: net debug [tcp|udp|icmp] [on|off]".into()), } } - _ => return Err("usage: net status [tcp|udp|icmp|all] | net debug [tcp|udp|icmp] [on|off]".into()), + Some("interfaces") | Some("ifaces") => { + // List host interfaces available for PCAP bridging. + #[cfg(feature = "pcap")] + { + write!(w, "{}", crate::net_pcap::format_interfaces()).ok(); + } + #[cfg(not(feature = "pcap"))] + { + writeln!(w, "PCAP support not built in (rebuild with --features pcap).").ok(); + } + } + _ => return Err("usage: net status [tcp|udp|icmp|all] | net debug [tcp|udp|icmp] [on|off] | net interfaces".into()), } } _ => return Err("not found".into()),