Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down Expand Up @@ -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 }
Expand Down
79 changes: 79 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand All @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions iris-gui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
208 changes: 205 additions & 3 deletions iris-gui/src/config_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
pub addrs: Vec<String>,
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<Vec<PcapIface>, 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 {
Expand Down Expand Up @@ -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.
Expand All @@ -114,11 +181,15 @@ pub fn show_tab(
jit: &mut JitEnv,
host: &[crate::netplan::HostIface],
disk_folders: &[String],
pcap_ifaces: &Option<Result<Vec<PcapIface>, 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() },
Expand Down Expand Up @@ -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<NetSanityPrompt>,
/// 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<Result<Vec<PcapIface>, 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. \
Expand Down Expand Up @@ -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<Result<Vec<PcapIface>, 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 &current {
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)");
Expand Down
Loading