diff --git a/docs/pcap-release-plan.md b/docs/pcap-release-plan.md new file mode 100644 index 0000000..5883827 --- /dev/null +++ b/docs/pcap-release-plan.md @@ -0,0 +1,254 @@ +# PCAP release + elevation plan + +Status: **in progress** (updated 2026-06-20). The PCAP feature itself is **done** +and lives on branch `add-pcap-builds` (GenSayer's "Add PCap support" + danifunker +edition, merged). This document now tracks the remaining elevation / installer / +release-wiring work to completion. + +> Tracking convention: every actionable item below is a `- [ ]` checkbox. Tick it +> (`- [x]`) as it lands. Items are tagged **[branch]** (lives on `add-pcap-builds`, +> the source/installer files) or **[main]** (lives on the fork's `main`, the CI +> pipeline). See the ownership rule immediately below — it is load-bearing. + +--- + +## ⚠️ Branch / file-ownership rule (LOCKED 2026-06-20) + +The build-pipeline files exist **only** on the fork's `main` branch: +`release.yml`, `appstore.yml`, `sync-upstream.yml`, and vendored build-only files +(e.g. `LICENSE-GPL3.txt`). They are **not** on `add-pcap-builds` and we must +**not** merge `origin/main` to pull them in. + +Work therefore splits by file type: + +| Lives on **`add-pcap-builds`** (this branch) | Lives on **`main`** (pipeline, separate) | +|---|---| +| Elevation/capture code in `iris-gui` + `iris` | `release.yml` build-variant jobs (Phase 1) | +| `installer/iris-gui.iss` Inno `[Code]` block | `make_dmg` Info.plist + ChmodBPF-copy steps | +| `installer/macos/chmod-bpf/*` resources | Pcap-installer build step (Windows job) | +| `installer/*.entitlements` edits | Native build-deps install (apt/pacman/WinPcap) | +| `Cargo.toml` packaging metadata (deb/rpm deps, setcap) | `DOWNLOADS.md` generator rows/footnotes | + +So: a Phase that says "alter `release.yml`" is **main work** even when the +resources it consumes are authored here. The two halves meet only at release time. + +--- + +## Current state (2026-06-20) + +- [x] **Phase 0 — feature merged.** `add-pcap-builds` carries the pcap feature: + - root `Cargo.toml`: `pcap = ["dep:pcap"]`, `pcap = { version = "2", optional = true }` + - `iris-gui/Cargo.toml`: `pcap = ["iris/pcap"]` + - `src/net_pcap.rs` (`PcapEngine`, `open_capture`, `is_pcap_mode`) present. +- [x] **Build sanity** on this branch (verified 2026-06-20, all green): + - [x] `cargo build --features pcap` + - [x] `cargo build -p iris-gui --features pcap` + - [x] `cargo build -p iris-gui` (no-feature / App-Store path still green) + - [x] `cargo build` (plain CLI) + 278 lib tests green + +--- + +## Locked decisions (2026-06-19) + +| Decision | Choice | +|---|---| +| Sequencing | Feature first (done), then elevation/installer, then release wiring on `main` | +| Build variants | PCAP implies lightning — 3 total: `standard`, `lightning`, `pcap` (= lightning+pcap, + CHD like every build). No 4-way cross-product. | +| In-app elevation | In scope for v1. Linux: rusty-backup `pkexec` whole-process re-exec. macOS: **ChmodBPF** (unprivileged app + one-time admin install). | +| Windows Npcap | Installer detects existing wpcap; if missing, offers to download+launch Npcap from npcap.com. Plus a matrix link. | +| App Store | **Never** ships PCAP (sandbox can't open a raw capture). Leave `appstore.yml` untouched. | + +--- + +# Work tracker + +## A. On-branch work (`add-pcap-builds`) — source / installer files + +### A1 — Capture-permission detection + GUI surfacing *(cross-cutting, DONE 2026-06-20)* +- [x] **[branch]** Surface `PcapEngine::open_capture` failure to the GUI: added + `PcapStatus` enum (`src/net.rs`) + `NatControl.pcap_status` field/methods; + `PcapEngine` sets Active/PermissionDenied/DeviceError via `classify_open_error` + (`src/net_pcap.rs`, string-classifies EPERM/EACCES — pcap 2.x has no perm + variant); `Machine::net_pcap_status()` accessor; `handle.rs` samples it into + `Status` + exposes `EmulatorHandle::pcap_status()`. Unit-tested. +- [x] **[branch]** UX entry points (both, per decision): explicit "Enable packet + capture…" button in the Network tab (`ConfigAction::EnablePacketCapture`) **and** + an auto-prompt modal in `main.rs` when a running pcap-mode machine reports + `PermissionDenied` (one-shot per run). Both call the new cross-platform + `iris-gui/src/capture_access.rs` dispatch (per-OS `enable()` + `permission_hint()`; + Linux/macOS/Windows stubs return the manual steps until A2/A4/A7 fill them in). + +### A2 — Linux elevation *(DONE 2026-06-20)* +- [x] **[branch]** Ported `relaunch_with_elevation()` into the Linux `imp` of + `iris-gui/src/capture_access.rs`. pkexec `env …` re-exec, re-injecting + `DISPLAY/WAYLAND_DISPLAY/WAYLAND_SOCKET/XAUTHORITY/XDG_RUNTIME_DIR/HOME/APPIMAGE/ + ARGV0` + `SUDO_USER` and `SUDO_UID/GID` read from `/proc/self` (no `libc` dep). + Uses generic polkit (`pkexec`) — no custom `.policy`. +- [x] **[branch]** AppImage wrinkle kept: re-exec `$APPIMAGE` when set. +- [x] **[branch]** Trigger wired via A1 (auto on `PermissionDenied`, or the + explicit button) → `capture_access::enable_packet_capture()`. +- [ ] **[branch]** ⚠️ *Local type-check pending* — no `rustup`/Linux target on the + dev box, so the Linux `imp` was reviewed (edition-2021-correct) but compiles for + the first time on Dani's Linux build. (Edge: a cancelled pkexec dialog ends the + app since `exec` already replaced it — documented; setcap avoids the prompt.) +- [x] **[branch]** setcap is the package default → see A6. + +### A3 — macOS ChmodBPF resources *(DONE 2026-06-20)* +- [x] **[branch]** `installer/macos/chmod-bpf/io.github.danifunker.iris.ChmodBPF.plist` + — LaunchDaemon, `RunAtLoad` (label matches bundle id `io.github.danifunker.iris`). + `plutil -lint` OK. +- [x] **[branch]** `installer/macos/chmod-bpf/ChmodBPF` — script: idempotent + `dseditgroup -o create access_bpf`, `chgrp access_bpf /dev/bpf*`, `chmod g+rw`, + reapply on boot. `sh -n` clean, executable. + +### A4 — macOS in-app ChmodBPF install flow (iris-gui) *(DONE 2026-06-20)* +- [x] **[branch]** Implemented in `iris-gui/src/capture_access.rs` (macOS `imp`). + Resources are **embedded via `include_str!`** (no .app-bundle dependency → works + in a plain `cargo run` dev build, and the **`make_dmg` copy step is no longer + needed** — see B2). Staged to a temp dir; one privileged `/bin/sh` script run via + a single `osascript … with administrator privileges` prompt. +- [x] **[branch]** Privileged step: copy plist → `/Library/LaunchDaemons/`, script + → `/Library/Application Support/IRIS/ChmodBPF/`; `dseditgroup -o create` + add the + real `$USER` (captured pre-elevation); chmod current `/dev/bpf*`; + `launchctl bootout||true` then `bootstrap system …`. +- [x] **[branch]** `bpf_accessible()` probe (`/dev/bpf0..15`, EACCES→false, + Ok/EBUSY→true) short-circuits to `Enabled`; otherwise install → `NeedsRelaunch` + ("quit & reopen"); cancel detected via osascript `-128`. +- [ ] **[branch]** *(deferred)* Fallback one-shot whole-process sudo re-exec if the + user declines the daemon — low priority; the daemon path is the primary flow. + +### A5 — macOS entitlements (on-branch half of packaging) *(DONE 2026-06-20)* +- [x] **[branch]** Added `com.apple.security.automation.apple-events` to + `installer/iris-gui-notarized.entitlements` (osascript drives Apple Events). + `plutil -lint` OK. *(The matching `NSAppleEventsUsageDescription` Info.plist key + is still **[main]** — the plist is generated by `make_dmg`; see B2.)* + +### A6 — Linux package metadata (on-branch half of packaging) *(DONE 2026-06-20)* +- [x] **[branch]** `iris-gui/Cargo.toml`: deb keeps `depends = "$auto"` (shlibdeps + resolves libpcap per-variant + handles the Ubuntu `t64` rename — hardcoding + would break noble and over-constrain non-pcap debs); rpm gets explicit + `requires = { libpcap = "*" }` (cargo-generate-rpm doesn't auto-detect). +- [x] **[branch]** setcap postinst (the no-root default): `installer/linux/postinst` + (deb, via `maintainer-scripts`) + `post_install_script` (rpm) run + `setcap cap_net_raw,cap_net_admin+eip /usr/bin/iris-gui`, best-effort/guarded so + install never fails. ⚠️ *Packaging only verifiable on a Linux build (cargo-deb / + cargo-generate-rpm) — flagged for Dani.* Manifest parses (`cargo metadata` OK). + +### A7 — Windows Npcap installer logic *(DONE 2026-06-20, revised: no auto-download)* +Decision (2026-06-20): **never silently download or bundle Npcap.** The flow is +detect → **open the npcap.com page in the browser** → user installs it → re-check. +- [x] **[branch]** `installer/iris-gui.iss` `[Code]` block, **gated behind ISPP + `#ifdef Pcap`** so the standard installer is byte-for-byte unchanged; only the + pcap installer (compiled `iscc /DPcap=1`, a **[main]** step) gets it. + 1. `NpcapInstalled()` — `{sys}\Npcap\wpcap.dll`, legacy `{sys}\wpcap.dll`, and + the `npcap` service registry key. + 2. When missing, a `CreateCustomPage` (after Select Tasks, skipped when present + via `ShouldSkipPage`) explains Npcap is needed and has an **"Open the Npcap + download page"** button → `ShellExec` to `https://npcap.com/#download`. No + `DownloadTemporaryFile`, no `Exec` of any downloaded file. + 3. **Try again:** `NextButtonClick` re-checks; if still missing, a Yes/No prompt + lets the user stay on the page to install + re-check, or continue without it. + 4. Removed the old `[Tasks]` npcap entry and the `NpcapVersion` define. Installer + stays per-user/no-admin (Npcap's own installer raises its own UAC). +- [x] **[branch]** Runtime Windows path (`capture_access.rs`) aligned to the same + philosophy: `enable()` checks for Npcap; if present, advises Administrator + + relaunch; if missing, opens `npcap.com` in the browser (never downloads) and + asks the user to install + relaunch. +- [ ] **[branch]** ⚠️ *Not compile-tested* — `ISCC` is Windows-only; Pascal + reviewed against the Inno API. Flagged for Dani's Windows test. + +## B. On-main work (`main`) — CI pipeline, do NOT commit on this branch + +> **Status (2026-06-20):** B1–B3 are **implemented on branch `pcap-release-pipeline`** +> (cut off `main`; `release.yml` only, +218 lines). They live there, not on +> `add-pcap-builds`, per the file-ownership rule. The boxes below stay unchecked in +> this doc because the doc travels with `add-pcap-builds`; both branches merge to +> `main` for a full pcap release. Note: the WinPcap link uses `LIBPCAP_LIBDIR` +> (the crate's `build.rs` honors it), not `LIB`/`rustc-link-search`. + +### B1 — release.yml pcap build variant (Phase 1) +- [ ] **[main]** Mirror the `lightning` step across every job, adding a 3rd `pcap` + build. GUI features `iris/lightning,iris/pcap,bundled`; CLI features + `chd,camera,jit,rex-jit,lightning,pcap` (keep `chd` explicit on CLI). +- [ ] **[main]** Native build deps per job: Linux packages `libpcap-dev` (apt); + Linux AppImage `libpcap` (pacman) + quick-sharun bundles `libpcap.so`; Windows + **WinPcap Developer Pack** (BSD) on the runner + `LIB`/`rustc-link-search` so + `#[link(name="wpcap")]` resolves; macOS none (in SDK). +- [ ] **[main]** Artifact naming: `IRIS-[-pcap]---.` + (insert `-pcap` where `-lightning` goes). +- [ ] **[main]** Windows job: matrix `cli_features_pcap`, GUI pcap portable zip, + **pcap installer build** (where A7's `.iss` `[Code]` runs), CLI pcap zip + uploads. +- [ ] **[main]** Linux AppImage + packages jobs: pcap GUI + CLI builds, packages, uploads. + +### B2 — release.yml make_dmg (Phase 3 main half) +- [ ] **[main]** Add `NSAppleEventsUsageDescription` to the `make_dmg` Info.plist + heredoc (release.yml ~`:348`) for the **pcap variant** (needed for the osascript + admin prompt under the hardened runtime). +- [x] ~~copy `installer/macos/chmod-bpf/*` into the bundle~~ **DROPPED** — A4 embeds + the resources via `include_str!`, so no bundle copy is needed. (The `make_dmg + "pcap"` variant + `sign_notarize_package …iris-pcap…` + uploads are still part of + B1's variant work.) + +### B3 — DOWNLOADS.md generator (Phase 1 docs) +- [ ] **[main]** Add pcap filenames to the missing-asset checklist; add a 📡 PCAP + column/rows + explainer to the three tables; add the Npcap note/link to the + Windows footnote. + +--- + +## Testing matrix (Dani — needs real hardware/OS) + +These cannot be validated from this dev box alone; flagged for you to run. ✅ = I +can verify locally (build/logic); 🧪 = needs your hands-on test on that OS. + +| Area | What to test | Where | +|---|---|---| +| Build (all features) | `cargo build --features pcap` + `-p iris-gui --features pcap` + plain `-p iris-gui` | ✅ this box (macOS) | +| **macOS ChmodBPF** | Run the in-app "Enable packet capture" → one admin prompt → daemon installs → quit/reopen → pcap mode bridges onto wired iface | 🧪 macOS | +| macOS daemon persistence | Reboot → `/dev/bpf*` still group-readable (LaunchDaemon `RunAtLoad`) | 🧪 macOS | +| macOS Gatekeeper | `launchctl bootstrap` works on current macOS; runtime-installed daemon not blocked | 🧪 macOS | +| **Linux pkexec** | pcap mode → capture EPERM → elevation modal → pkexec prompt → re-exec as root → capture works (X11 **and** Wayland) | 🧪 Linux | +| Linux AppImage | Same, re-execing `$APPIMAGE` (not the FUSE `current_exe()`) | 🧪 Linux (AppImage build) | +| Linux setcap | deb/rpm install → `setcap` postinst → runs unprivileged with capture | 🧪 Linux (after B1 packages) | +| **Windows Npcap** | Installer with no driver → opt-in task → downloads + launches Npcap → IRIS pcap mode captures (Administrator) | 🧪 Windows | +| Windows detect | Installer with Npcap already present → skips the download page | 🧪 Windows | +| Windows build link | pcap crate finds the WinPcap Dev Pack import lib on the runner | 🧪 Windows (CI, after B1) | + +--- + +## Licensing summary (clean except Windows-Npcap) + +- IRIS core: **BSD-3-Clause** (`LICENSE`; `LICENSE-GPL3.txt` is for the CHD path, orthogonal). +- `pcap` crate: **MIT OR Apache-2.0**. +- libpcap (Linux/macOS): **BSD-3-Clause** — bundle in AppImage / depend in deb/rpm; ships in macOS. +- Windows **Npcap**: proprietary, **redistribution forbidden** — never bundle; user installs it. + Build-link against the **BSD WinPcap Dev Pack**, not the Npcap SDK. + +## Runtime requirements (document regardless of in-app elevation) + +- Linux: root or `setcap cap_net_raw,cap_net_admin+eip`. AppImage → run via pkexec/sudo. +- macOS: one-time admin install of **ChmodBPF** (app then runs unprivileged); quit & + reopen IRIS after install. Wired-only — many Wi-Fi APs reject the bridged MAC. +- Windows: Administrator + a WinPcap-compatible driver (Npcap) installed. +- Default backend stays **NAT** everywhere; PCAP is opt-in via `[network] mode = "pcap"`. + +## Resolved decisions (2026-06-20) + +- **Implementation order**: **A1 (cross-cutting) first**, then pick an OS. All + three elevation paths depend on the capture-failure surfacing + Networking-tab hook. +- **Elevation UX trigger**: **both** — an explicit "Enable packet capture" button + in the Networking tab AND an automatic modal when a pcap-mode machine hits a + permission error on start. (Affects A1/A2/A4.) +- **Linux package path**: **setcap-in-postinst is the default** for deb/rpm (no + root GUI); pkexec re-exec is the AppImage/portable fallback. (Affects A2/A6.) + +## Open risks + +- Windows link step: confirm the pcap `build.rs` finds the WinPcap Dev Pack import + lib on the runner (`LIB`/search dir). Highest-uncertainty Phase-1 item. **[main]** +- macOS ChmodBPF: `launchctl bootstrap` vs legacy `load`; group-membership-needs- + relaunch UX; runtime-installed daemon is outside notarization (confirm Gatekeeper + doesn't block it). +- Artifact/upload count grows ~50% (3rd variant). Accepted. + + diff --git a/installer/iris-gui-notarized.entitlements b/installer/iris-gui-notarized.entitlements index 85b08f9..f85c528 100644 --- a/installer/iris-gui-notarized.entitlements +++ b/installer/iris-gui-notarized.entitlements @@ -22,5 +22,15 @@ com.apple.security.cs.allow-unsigned-executable-memory + + + com.apple.security.automation.apple-events + diff --git a/installer/iris-gui.iss b/installer/iris-gui.iss index 68c1bd1..243d0d9 100644 --- a/installer/iris-gui.iss +++ b/installer/iris-gui.iss @@ -1,5 +1,7 @@ -; Inno Setup script for IRIS (Windows, per-user install). -; Produces a per-user Setup.exe that installs into %LocalAppData% — no admin required. +; Inno Setup script for IRIS (Windows). +; Defaults to a per-user install into %LocalAppData% (no admin required), but the +; user can choose "install for all users" (elevates to admin), which installs +; into C:\Program Files\IRIS — the {auto*} constants follow whichever they pick. ; ; Build: ; iscc /DMyAppVersion=2025-06-09-02-00 /DSourceDir=path\to\build /DAssetsDir=path\to\icons installer\iris-gui.iss @@ -40,7 +42,12 @@ AppUpdatesURL={#MyAppURL}/releases LicenseFile={#LicenseFile} PrivilegesRequired=lowest PrivilegesRequiredOverridesAllowed=dialog -DefaultDirName={localappdata}\Programs\IRIS +; Use the "auto" Program Files constant so the destination follows the install +; mode the user picks via PrivilegesRequiredOverridesAllowed: an all-users +; (elevated) install lands in C:\Program Files\IRIS, a per-user install in +; %LocalAppData%\Programs\IRIS. A literal {localappdata} would force the per-user +; folder even when the user chose "install for all users". +DefaultDirName={autopf}\{#MyAppName} DisableProgramGroupPage=yes DefaultGroupName={#MyAppName} DisableDirPage=no @@ -66,7 +73,100 @@ Source: "{#LicenseFile}"; DestDir: "{app}"; DestName: "LICENSE.txt"; Flags: igno [Icons] Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" Name: "{group}\Uninstall {#MyAppName}"; Filename: "{uninstallexe}" -Name: "{userdesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon [Run] Filename: "{app}\{#MyAppExeName}"; Description: "Launch {#MyAppName}"; Flags: nowait postinstall skipifsilent + +; ─────────────────────────────────────────────────────────────────────────── +; Npcap handling — PCAP installer variant only (compiled with `iscc /DPcap=1`). +; The standard/lightning installer has no [Code] section and is unchanged. +; +; PCAP bridged networking needs a WinPcap-compatible driver (Npcap). We do NOT +; bundle it (proprietary, redistribution forbidden) and we never silently +; download it. Instead, when Npcap is missing we show a page that OPENS the +; official npcap.com download page in the user's browser; the user installs +; Npcap themselves (its installer has its own UAC), then clicks Next to re-check. +; The page is skipped entirely when Npcap is already present. The IRIS installer +; stays per-user/no-admin throughout. +#ifdef Pcap +[Code] +var + NpcapPage: TWizardPage; + +{ True when a WinPcap-compatible driver (Npcap, or a legacy WinPcap) is present. + Checks the wpcap.dll the `pcap` crate links against and the npcap service. } +function NpcapInstalled(): Boolean; +begin + Result := FileExists(ExpandConstant('{sys}\Npcap\wpcap.dll')) or + FileExists(ExpandConstant('{sys}\wpcap.dll')) or + RegKeyExists(HKLM, 'SYSTEM\CurrentControlSet\Services\npcap'); +end; + +{ Open the official Npcap download page in the user's default browser. } +procedure OpenNpcapSite(Sender: TObject); +var + ErrorCode: Integer; +begin + ShellExec('open', 'https://npcap.com/#download', '', '', SW_SHOWNORMAL, ewNoWait, ErrorCode); +end; + +procedure InitializeWizard(); +var + Info: TNewStaticText; + OpenButton: TNewButton; +begin + NpcapPage := CreateCustomPage(wpSelectTasks, + 'Npcap (packet-capture driver)', + 'PCAP bridged networking needs the free Npcap driver.'); + + Info := TNewStaticText.Create(NpcapPage); + Info.Parent := NpcapPage.Surface; + Info.Left := 0; + Info.Top := 0; + Info.Width := NpcapPage.SurfaceWidth; + Info.AutoSize := False; + Info.WordWrap := True; + Info.Height := ScaleY(96); + Info.Caption := + 'IRIS bridged (PCAP) networking requires Npcap, a free WinPcap-compatible driver. ' + + 'It is not bundled with IRIS and is not downloaded automatically.' + #13#10#13#10 + + 'Click the button below to open the Npcap download page in your browser. Install ' + + 'Npcap (its installer asks for Administrator), then return here and click Next — ' + + 'IRIS will re-check for the driver. You can also continue and install it later.'; + + OpenButton := TNewButton.Create(NpcapPage); + OpenButton.Parent := NpcapPage.Surface; + OpenButton.Left := 0; + OpenButton.Top := Info.Top + Info.Height + ScaleY(8); + OpenButton.Width := ScaleX(240); + OpenButton.Height := ScaleY(28); + OpenButton.Caption := 'Open the Npcap download page'; + OpenButton.OnClick := @OpenNpcapSite; +end; + +{ Skip the Npcap page when the driver is already present. } +function ShouldSkipPage(PageID: Integer): Boolean; +begin + Result := False; + if (NpcapPage <> nil) and (PageID = NpcapPage.ID) then + Result := NpcapInstalled(); +end; + +{ "Try again": re-check on Next. If still missing, let the user retry (stay on + the page to install first) or continue without it. } +function NextButtonClick(CurPageID: Integer): Boolean; +begin + Result := True; + if (NpcapPage <> nil) and (CurPageID = NpcapPage.ID) and (not NpcapInstalled()) then + begin + if MsgBox( + 'Npcap was not detected yet. PCAP bridged networking will not work until it is installed.' + + #13#10#13#10 + + 'Click Yes to re-check after installing Npcap (stay on this page), or No to continue ' + + 'and install it later.', + mbConfirmation, MB_YESNO) = IDYES then + Result := False; + end; +end; +#endif diff --git a/installer/linux/postinst b/installer/linux/postinst new file mode 100755 index 0000000..53239d4 --- /dev/null +++ b/installer/linux/postinst @@ -0,0 +1,17 @@ +#!/bin/sh +# +# IRIS deb postinst — grant the GUI binary packet-capture capability so PCAP +# (bridged) networking works WITHOUT running IRIS as root. This is the preferred +# Linux path for installed packages (no per-launch prompt). Best-effort: if setcap +# is missing or the filesystem lacks xattr support, capture just falls back to the +# in-app pkexec elevation, so we NEVER fail the install over this. +# +# Applied to every variant's binary (the metadata is shared); harmless on +# standard/lightning builds, which simply never exercise the capability. +set -e +if [ "$1" = "configure" ]; then + if command -v setcap >/dev/null 2>&1; then + setcap cap_net_raw,cap_net_admin+eip /usr/bin/iris-gui 2>/dev/null || true + fi +fi +exit 0 diff --git a/installer/macos/chmod-bpf/ChmodBPF b/installer/macos/chmod-bpf/ChmodBPF new file mode 100755 index 0000000..01c5202 --- /dev/null +++ b/installer/macos/chmod-bpf/ChmodBPF @@ -0,0 +1,40 @@ +#!/bin/sh +# +# ChmodBPF — grant the BPF capture devices (/dev/bpf*) group read/write so an +# UNPRIVILEGED IRIS (and the libpcap it links) can open a raw capture for PCAP +# (bridged) networking. Modeled on Wireshark's ChmodBPF. +# +# Installed by IRIS's in-app "Enable packet capture" step: +# - this script -> /Library/Application Support/IRIS/ChmodBPF/ChmodBPF +# - the LaunchDaemon io.github.danifunker.iris.ChmodBPF.plist -> /Library/LaunchDaemons/ +# The daemon runs this at every boot (RunAtLoad): macOS recreates the bpf nodes on +# demand with default (root-only) permissions, so the grant is re-applied each boot. +# +# The emulator itself NEVER runs as root — that is the whole point of ChmodBPF +# over a sudo re-exec of the GUI. + +BPF_GROUP="access_bpf" + +syslog -s -l notice "IRIS ChmodBPF: setting permissions on /dev/bpf*" + +# Ensure the access_bpf group exists (idempotent). The in-app installer also +# creates it and adds the user; group membership only takes effect in new login +# sessions, which is why IRIS asks the user to quit & reopen after enabling. +if ! dseditgroup -o read "$BPF_GROUP" >/dev/null 2>&1; then + dseditgroup -o create "$BPF_GROUP" +fi + +# Wait briefly for at least one bpf node to appear (created on demand at boot). +attempts=0 +while [ ! -e /dev/bpf0 ] && [ "$attempts" -lt 10 ]; do + attempts=$((attempts + 1)) + sleep 1 +done + +if [ -e /dev/bpf0 ]; then + chgrp "$BPF_GROUP" /dev/bpf* + chmod g+rw /dev/bpf* + syslog -s -l notice "IRIS ChmodBPF: /dev/bpf* now group-readable by $BPF_GROUP" +else + syslog -s -l error "IRIS ChmodBPF: no /dev/bpf* devices found" +fi diff --git a/installer/macos/chmod-bpf/io.github.danifunker.iris.ChmodBPF.plist b/installer/macos/chmod-bpf/io.github.danifunker.iris.ChmodBPF.plist new file mode 100644 index 0000000..9ad1176 --- /dev/null +++ b/installer/macos/chmod-bpf/io.github.danifunker.iris.ChmodBPF.plist @@ -0,0 +1,30 @@ + + + + + + Label + io.github.danifunker.iris.ChmodBPF + ProgramArguments + + /Library/Application Support/IRIS/ChmodBPF/ChmodBPF + + RunAtLoad + + KeepAlive + + + diff --git a/iris-gui/Cargo.toml b/iris-gui/Cargo.toml index c550ba6..bbdaaa7 100644 --- a/iris-gui/Cargo.toml +++ b/iris-gui/Cargo.toml @@ -10,7 +10,15 @@ maintainer = "Dani Sarfati " copyright = "2025 Dani Sarfati" license-file = "../LICENSE" extended-description = "IRIS emulates an SGI Indy workstation (MIPS R4400) and boots IRIX 6.5 and 5.3 to a usable system with shell, networking, and X11." +# `$auto` runs dpkg-shlibdeps, which resolves the pcap variant's libpcap link to +# the correctly-versioned package name for the build's distro — including the +# Ubuntu 24.04 `t64` rename (libpcap0.8 -> libpcap0.8t64). That's why we DON'T +# hardcode libpcap here: a literal name would break on noble, and would also be +# over-broad on the standard/lightning debs that don't link libpcap at all. depends = "$auto" +# PCAP (bridged) networking: setcap the binary at install so it captures without +# root. See installer/linux/postinst (best-effort; falls back to in-app pkexec). +maintainer-scripts = "../installer/linux" section = "games" priority = "optional" # Asset source paths are resolved relative to this package dir (iris-gui/), @@ -26,6 +34,17 @@ assets = [ ] [package.metadata.generate-rpm] +# cargo-generate-rpm does NOT auto-detect shared-lib deps, so list libpcap +# explicitly for the pcap variant. Harmless on standard/lightning rpms (libpcap +# is a tiny, near-universal package). The rpm names it `libpcap` on Fedora/RHEL. +requires = { libpcap = "*" } +# PCAP: setcap the binary at install so it captures without root (mirrors the deb +# postinst). Best-effort; capture falls back to the in-app pkexec elevation. +post_install_script = ''' +if command -v setcap >/dev/null 2>&1; then + setcap cap_net_raw,cap_net_admin+eip /usr/bin/iris-gui 2>/dev/null || true +fi +''' assets = [ { source = "target/release/iris-gui", dest = "/usr/bin/iris-gui", mode = "755" }, { source = "assets/icons/icon-256.png", dest = "/usr/share/icons/hicolor/256x256/apps/iris-gui.png", mode = "644" }, @@ -50,6 +69,10 @@ appstore = ["bundled"] # 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"] +# Emulate an R5000 CPU instead of the default R4400 (compile-time: the cache +# model differs deeply). Surfaced read-only on the Memory tab via +# iris::build_features::CPU. Build with: cargo build -p iris-gui --features r5k +r5k = ["iris/r5k"] [dependencies] # Group A (additive) features are always on for iris-gui so the user can diff --git a/iris-gui/src/capture_access.rs b/iris-gui/src/capture_access.rs new file mode 100644 index 0000000..4215d2b --- /dev/null +++ b/iris-gui/src/capture_access.rs @@ -0,0 +1,343 @@ +//! Cross-platform "enable packet capture" helper for the PCAP networking backend. +//! +//! Opening a raw libpcap capture needs privilege the GUI lacks by default: root / +//! `CAP_NET_RAW` on Linux, group access to `/dev/bpf*` on macOS, and a +//! WinPcap-compatible driver (+ Administrator) on Windows. This module is the +//! single entry point the UI calls to obtain that permission, dispatching to the +//! platform's native mechanism. +//! +//! The detailed per-OS flows land incrementally — Linux pkexec/setcap (task A2), +//! macOS ChmodBPF admin install (task A4), Windows Npcap (task A7, mostly in the +//! installer). This file defines the stable surface they plug into; until a flow +//! is wired up, [`enable_packet_capture`] returns the manual steps so the button +//! is still useful. + +/// Result of an [`enable_packet_capture`] attempt. +#[allow(dead_code)] // `Enabled` / `NeedsRelaunch` are produced once A2/A4 land. +pub enum EnableOutcome { + /// Capture is (or should now be) permitted; the user can use PCAP mode. + Enabled, + /// The privileged step succeeded but the user must **quit & reopen IRIS** for + /// it to take effect (e.g. macOS group membership only applies to new login + /// sessions). Carries a message to show. + NeedsRelaunch(String), + /// The user cancelled the OS privilege prompt — no change, no error needed. + Cancelled, + /// Couldn't enable; carries a human-readable reason / manual next step. + Failed(String), +} + +/// Attempt to grant this build permission to open a raw PCAP capture using the +/// platform's native mechanism. Safe to call from the GUI thread; may block on a +/// native admin prompt while it's open. +pub fn enable_packet_capture() -> EnableOutcome { + imp::enable() +} + +/// One-line, platform-specific hint shown next to the PCAP warning, describing +/// how capture permission is obtained on this OS. +pub fn permission_hint() -> &'static str { + imp::HINT +} + +#[cfg(target_os = "linux")] +mod imp { + //! Linux capture elevation. + //! + //! libpcap opens the capture inside the process, and there's no way to inject + //! a pre-opened fd, so the whole process must be privileged. Two paths: + //! * **setcap** (the package default, applied by the deb/rpm postinst — + //! task A6): `cap_net_raw,cap_net_admin+eip` on the binary → capture works + //! unprivileged, no prompt, and this `enable()` is never even reached. + //! * **pkexec re-exec** (this function — the portable / AppImage fallback): + //! relaunch the whole process elevated. Ported from rusty-backup + //! `src/os/linux.rs`. Note: pkexec **replaces** this process, so a + //! cancelled auth dialog ends the app — that's why setcap is preferred for + //! installed packages. + + use super::EnableOutcome; + use std::os::unix::fs::MetadataExt; + use std::os::unix::process::CommandExt; + use std::path::PathBuf; + use std::process::Command; + + pub const HINT: &str = + "Linux: packages grant capture via setcap automatically; AppImage/portable relaunches with pkexec."; + + pub fn enable() -> EnableOutcome { + // On success `exec` replaces this process and never returns; any return is + // an error (pkexec missing, etc.). A cancelled auth dialog terminates the + // (already-replaced) process, so there is no "Cancelled" return here. + EnableOutcome::Failed(relaunch_with_elevation()) + } + + /// Re-exec the whole process under `pkexec`, re-injecting the GUI/session env + /// pkexec strips. Returns only on failure (the returned String is the reason); + /// on success `exec` has already replaced this process. + fn relaunch_with_elevation() -> String { + // Inside an AppImage, current_exe() is the per-user FUSE mount under + // /tmp/.mount_* which root can't read; APPIMAGE points at the real file, + // and elevating that re-bootstraps the squashfs as root. + let target = match std::env::var_os("APPIMAGE") { + Some(p) => PathBuf::from(p), + None => match std::env::current_exe() { + Ok(p) => p, + Err(e) => return format!("couldn't find the IRIS executable: {e}"), + }, + }; + let args: Vec = std::env::args().skip(1).collect(); + + if which_pkexec().is_none() { + return format!( + "pkexec not found. Install polkit (policykit-1), or grant capture \ + capability manually:\n\n \ + sudo setcap cap_net_raw,cap_net_admin+eip {}", + target.display() + ); + } + + // pkexec strips the environment: re-inject display + identity vars so the + // elevated process can reach X11/Wayland and resolve the real user's home. + let mut env_args: Vec = Vec::new(); + for var in [ + "DISPLAY", "WAYLAND_DISPLAY", "WAYLAND_SOCKET", "XAUTHORITY", + "XDG_RUNTIME_DIR", "HOME", "APPIMAGE", "ARGV0", + ] { + if let Ok(val) = std::env::var(var) { + env_args.push(format!("{var}={val}")); + } + } + if let Ok(user) = std::env::var("USER") { + env_args.push(format!("SUDO_USER={user}")); + } + // /proc/self is owned by the process's real uid/gid — read them without a + // libc dependency so the elevated process can recover the original user. + if let Ok(meta) = std::fs::metadata("/proc/self") { + env_args.push(format!("SUDO_UID={}", meta.uid())); + env_args.push(format!("SUDO_GID={}", meta.gid())); + } + + // `exec` replaces the current process image; returns only on failure. + let err = Command::new("pkexec") + .arg("env") + .args(&env_args) + .arg(&target) + .args(&args) + .exec(); + format!("failed to relaunch with pkexec: {err}") + } + + fn which_pkexec() -> Option { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path) + .map(|d| d.join("pkexec")) + .find(|p| p.is_file()) + } +} + +#[cfg(target_os = "macos")] +mod imp { + //! macOS ChmodBPF install flow (Wireshark's model). + //! + //! A one-time admin step installs a LaunchDaemon that makes `/dev/bpf*` + //! group-readable by the `access_bpf` group and adds the user to it; the + //! **unprivileged** IRIS + the libpcap it links then open the capture + //! normally. The emulator never runs as root. + //! + //! The daemon script + plist are embedded at build time (so this works in a + //! plain dev build, not just a packaged .app), staged to a temp dir, and + //! moved into place by a single privileged shell script run via one native + //! `osascript … with administrator privileges` prompt. + + use super::EnableOutcome; + use std::os::unix::fs::PermissionsExt; + use std::path::{Path, PathBuf}; + use std::process::Command; + + pub const HINT: &str = + "macOS: a one-time admin install (ChmodBPF) makes /dev/bpf* readable, then quit & reopen IRIS."; + + /// The daemon helper resources, baked into the binary from the repo so the + /// flow needs no .app bundle layout at runtime. + const CHMODBPF_SCRIPT: &str = include_str!("../../installer/macos/chmod-bpf/ChmodBPF"); + const CHMODBPF_PLIST: &str = + include_str!("../../installer/macos/chmod-bpf/io.github.danifunker.iris.ChmodBPF.plist"); + + const PLIST_NAME: &str = "io.github.danifunker.iris.ChmodBPF.plist"; + const DAEMON_PLIST: &str = "/Library/LaunchDaemons/io.github.danifunker.iris.ChmodBPF.plist"; + const SUPPORT_DIR: &str = "/Library/Application Support/IRIS/ChmodBPF"; + + pub fn enable() -> EnableOutcome { + // Already permitted (e.g. the daemon ran and we relaunched) → no prompt. + if bpf_accessible() { + return EnableOutcome::Enabled; + } + let user = match std::env::var("USER") { + Ok(u) if !u.is_empty() && u != "root" => u, + _ => return EnableOutcome::Failed( + "couldn't determine the current macOS user to grant capture access".into()), + }; + let staged = match stage_resources() { + Ok(s) => s, + Err(e) => return EnableOutcome::Failed(format!("couldn't stage ChmodBPF resources: {e}")), + }; + let script_path = staged.dir.join("install.sh"); + if let Err(e) = std::fs::write(&script_path, install_script(&staged, &user)) { + return EnableOutcome::Failed(format!("couldn't write install script: {e}")); + } + match run_admin_shell(&script_path) { + AdminResult::Ok => EnableOutcome::NeedsRelaunch( + "Packet capture enabled. Quit and reopen IRIS so the new group \ + membership takes effect, then start the machine again.".into()), + AdminResult::Cancelled => EnableOutcome::Cancelled, + AdminResult::Failed(e) => EnableOutcome::Failed(format!("ChmodBPF install failed: {e}")), + } + } + + /// Whether this process can already open a BPF capture device. Probes + /// `/dev/bpf0..bpf15`: a successful open or `EBUSY` (in use, but reachable) + /// means permissions are fine; `EACCES`/`EPERM` means they aren't. `ENOENT` + /// just means that node doesn't exist yet — keep probing. + fn bpf_accessible() -> bool { + use std::io::ErrorKind; + for n in 0..16 { + match std::fs::OpenOptions::new().read(true).write(true).open(format!("/dev/bpf{n}")) { + Ok(_) => return true, + Err(e) => match e.kind() { + ErrorKind::PermissionDenied => return false, + // EBUSY (16 on Darwin): node exists and we may open it, it's + // just in use → permissions are fine, treat as reachable. + _ if e.raw_os_error() == Some(16) => return true, + _ => continue, // ENOENT / other → try the next node + }, + } + } + false + } + + struct Staged { + dir: PathBuf, + script: PathBuf, + plist: PathBuf, + } + + /// Write the embedded ChmodBPF script + plist to a private temp dir. + fn stage_resources() -> std::io::Result { + let dir = std::env::temp_dir().join(format!("iris-chmodbpf-{}", std::process::id())); + std::fs::create_dir_all(&dir)?; + let script = dir.join("ChmodBPF"); + let plist = dir.join(PLIST_NAME); + std::fs::write(&script, CHMODBPF_SCRIPT)?; + std::fs::write(&plist, CHMODBPF_PLIST)?; + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755))?; + Ok(Staged { dir, script, plist }) + } + + /// The privileged `/bin/sh` script: install the daemon, create+join the + /// `access_bpf` group, apply perms to current nodes, and (re)load the daemon. + /// `set -e` aborts on a hard failure; tolerant steps are guarded with `|| true`. + fn install_script(s: &Staged, user: &str) -> String { + // Paths are from std::env::temp_dir() (no quotes); single-quote them anyway. + let sh = |p: &Path| format!("'{}'", p.display()); + format!( + "#!/bin/sh\n\ + set -e\n\ + mkdir -p '{support}'\n\ + cp {staged_script} '{support}/ChmodBPF'\n\ + chown root:wheel '{support}/ChmodBPF'\n\ + chmod 755 '{support}/ChmodBPF'\n\ + cp {staged_plist} '{daemon}'\n\ + chown root:wheel '{daemon}'\n\ + chmod 644 '{daemon}'\n\ + dseditgroup -o create access_bpf 2>/dev/null || true\n\ + dseditgroup -o edit -a '{user}' -t user access_bpf\n\ + chgrp access_bpf /dev/bpf* 2>/dev/null || true\n\ + chmod g+rw /dev/bpf* 2>/dev/null || true\n\ + launchctl bootout system '{daemon}' 2>/dev/null || true\n\ + launchctl bootstrap system '{daemon}'\n", + support = SUPPORT_DIR, + daemon = DAEMON_PLIST, + staged_script = sh(&s.script), + staged_plist = sh(&s.plist), + user = user, + ) + } + + enum AdminResult { + Ok, + Cancelled, + Failed(String), + } + + /// Run `script_path` as root behind one native admin prompt via + /// `osascript … "do shell script … with administrator privileges"`. + fn run_admin_shell(script_path: &Path) -> AdminResult { + // Only the script path is interpolated into the AppleScript string; it + // comes from temp_dir() so it has no quotes to escape. + let apple = format!( + "do shell script \"/bin/sh '{}'\" with administrator privileges", + script_path.display() + ); + match Command::new("osascript").arg("-e").arg(&apple).output() { + Ok(out) if out.status.success() => AdminResult::Ok, + Ok(out) => { + let err = String::from_utf8_lossy(&out.stderr); + // osascript reports a user cancel as "User canceled. (-128)". + if err.contains("-128") || err.to_lowercase().contains("cancel") { + AdminResult::Cancelled + } else { + AdminResult::Failed(err.trim().to_string()) + } + } + Err(e) => AdminResult::Failed(format!("couldn't run osascript: {e}")), + } + } +} + +#[cfg(target_os = "windows")] +mod imp { + use super::EnableOutcome; + use std::process::Command; + + pub const HINT: &str = + "Windows: needs the Npcap driver + Administrator. The button opens the download page."; + + /// Whether a WinPcap-compatible driver (Npcap, or legacy WinPcap) is present. + fn npcap_present() -> bool { + let root = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".into()); + let p = std::path::Path::new(&root); + p.join("System32\\Npcap\\wpcap.dll").exists() || p.join("System32\\wpcap.dll").exists() + } + + pub fn enable() -> EnableOutcome { + // Check first: if the driver is already present, the only remaining + // requirement is Administrator. + if npcap_present() { + return EnableOutcome::NeedsRelaunch( + "Npcap is installed. PCAP capture also needs Administrator — relaunch IRIS as \ + Administrator, then start the machine again." + .into(), + ); + } + // We never bundle or silently download the driver. Open the official page + // so the user installs Npcap themselves, then relaunches and tries again. + let _ = Command::new("explorer") + .arg("https://npcap.com/#download") + .spawn(); + EnableOutcome::NeedsRelaunch( + "Opened the Npcap download page in your browser. Install Npcap (a free \ + WinPcap-compatible driver), then relaunch IRIS as Administrator and try again." + .into(), + ) + } +} + +// Fallback for any other target so the crate still builds. +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +mod imp { + use super::EnableOutcome; + pub const HINT: &str = "PCAP capture requires elevated privileges on this platform."; + pub fn enable() -> EnableOutcome { + EnableOutcome::Failed("Packet capture isn't supported on this platform.".into()) + } +} diff --git a/iris-gui/src/config_ui.rs b/iris-gui/src/config_ui.rs index db4761e..1011970 100644 --- a/iris-gui/src/config_ui.rs +++ b/iris-gui/src/config_ui.rs @@ -165,6 +165,10 @@ pub enum ConfigAction { /// User clicked "Refresh" on the Network tab's PCAP selector; the app should /// re-enumerate host interfaces and update its cache. RefreshPcapIfaces, + /// User clicked "Enable packet capture…" in the Network tab's PCAP section; + /// the app should run the platform's privilege flow (Linux setcap/pkexec, + /// macOS ChmodBPF install, Windows driver check) via `capture_access`. + EnablePacketCapture, } /// Everything a config tab hands back to the app for one frame. @@ -243,6 +247,19 @@ fn show_general(ui: &mut Ui, cfg: &mut MachineConfig) -> ConfigAction { } fn show_memory(ui: &mut Ui, cfg: &mut MachineConfig) { + ui.heading("Processor"); + Grid::new("cpu_grid").num_columns(2).striped(true).show(ui, |ui| { + ui.label("CPU"); + ui.label(RichText::new(build_features::CPU).strong()); + ui.end_row(); + }); + ui.label(RichText::new( + "The CPU is fixed at build time — the R4400 and R5000 differ in their cache \ + architecture, so it can't be switched at runtime. To use a different CPU, \ + download that build.") + .weak()); + ui.separator(); + ui.heading("Memory"); ui.label("RAM bank sizes in MB (valid: 0, 8, 16, 32, 64, 128)"); Grid::new("mem_grid").num_columns(2).striped(true).show(ui, |ui| { @@ -447,6 +464,10 @@ pub struct NetworkOutcome { /// A port-forward rule was added/removed/edited → the app should rebind the /// running NAT's forward listeners live. pub forwards_changed: bool, + /// The PCAP host interface was changed and committed (dropdown pick, or the + /// manual field lost focus) → reopen the running PcapEngine's capture on the + /// new NIC without a guest reboot. + pub iface_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" @@ -487,19 +508,44 @@ fn show_network( }); if cfg.network.mode == NetMode::Pcap { - if let a @ ConfigAction::RefreshPcapIfaces = pcap_interface_picker(ui, cfg, pcap_ifaces) { + let (a, iface_committed) = pcap_interface_picker(ui, cfg, pcap_ifaces); + if let ConfigAction::RefreshPcapIfaces = a { out.action = a; } + if iface_committed { + out.iface_changed = true; + out.changed = true; + } 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)."); + "PCAP mode bridges onto a real interface — the guest joins your real LAN \ + directly. The NAT gateway and port forwards don't apply here and are \ + hidden below. Requires elevated privileges (root/CAP_NET_RAW on Unix, or \ + a WinPcap-compatible driver + Administrator on Windows)."); + + // Explicit, OS-specific way to grant capture permission up front (the + // other trigger is automatic: the app pops the same prompt if a + // pcap-mode machine hits a permission error on Start). The hint text + // and the action are platform-specific — see `capture_access`. + ui.horizontal(|ui| { + if ui.button("Enable packet capture…") + .on_hover_text("Grant IRIS permission to capture on a real interface. \ + A one-time admin/root step per the note below.") + .clicked() + { + out.action = ConfigAction::EnablePacketCapture; + } + ui.label(RichText::new(crate::capture_access::permission_hint()).weak()); + }); ui.separator(); } } + // The NAT subnet settings and port forwards only apply to the software + // gateway. In PCAP bridged mode the guest is directly on your real LAN, so + // they're meaningless — hide the whole block. (The NFS share further below + // works in both modes.) + if cfg.network.mode != NetMode::Pcap { 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. \ @@ -711,6 +757,7 @@ fn show_network( guest services (log in, copy files, and so on). Inbound only, and it works once the guest is \ up on the NAT subnet. None exist by default.") .weak()); + } // end: NAT-only section (hidden in PCAP mode) ui.separator(); ui.strong("NFS share"); @@ -726,6 +773,13 @@ fn show_network( } else { None }; out.changed = true; } + // PCAP mode: the in-core NFS server is presented as its own virtual L2 host + // on the bridged LAN (see `NfsVirtualHost`), so it needs its own IP on the + // guest's subnet. NAT mode mounts from the gateway and doesn't use this. The + // change takes effect on the next Start (the virtual host is built then). + if cfg.nfs.is_some() && cfg.network.mode == NetMode::Pcap { + out.changed |= pcap_nfs_ip_row(ui, cfg, host); + } if let Some(nfs) = cfg.nfs.as_mut() { Grid::new("nfs_grid").num_columns(2).striped(true).show(ui, |ui| { ui.label("Shared dir"); @@ -772,9 +826,21 @@ fn show_network( } } - // Live mount command — gateway fills in to match the subnet. The export - // is the single root, so the path is just "/". - let gw = assess.derived.as_ref().map(|d| d.gateway.to_string()).unwrap_or_else(|| "192.168.0.1".into()); + // Live mount command — the server IP fills in to match the backend. The + // export is the single root, so the path is just "/". NAT: the gateway IP + // of the configured subnet. PCAP: the in-process NFS server's own virtual + // LAN IP (the guest is bridged, so there's no NAT gateway to mount from). + let gw = if cfg.network.mode == NetMode::Pcap { + cfg.network.nfs_pcap_ip + .map(|ip| ip.to_string()) + .unwrap_or_else(|| "".into()) + } else { + let (b, p) = netplan::parse_cidr(cfg.nat_subnet.as_deref()); + netplan::classify(b, p, host) + .derived + .map(|d| d.gateway.to_string()) + .unwrap_or_else(|| "192.168.0.1".into()) + }; ui.label(RichText::new("Pick a folder, boot the Indy, then mount it:").weak()); ui.code(format!("mkdir /shared\nmount {gw}:/ /shared")); ui.label(RichText::new("Your files then appear at /shared on the Indy.").weak()); @@ -783,17 +849,92 @@ fn show_network( out } +/// PCAP mode: edit the virtual NFS host's IPv4 address (`cfg.network.nfs_pcap_ip`). +/// The server answers ARP + portmap/NFS/mountd UDP for this single address on the +/// bridged LAN, so it only needs an IP on the guest's subnet — no netmask or +/// gateway (it never routes). Returns true if the value changed this frame. +fn pcap_nfs_ip_row(ui: &mut Ui, cfg: &mut MachineConfig, host: &[crate::netplan::HostIface]) -> bool { + #[cfg(not(feature = "pcap"))] + let _ = host; + + let buf_id = ui.make_persistent_id("nfs_pcap_ip_buf"); + let last_id = ui.make_persistent_id("nfs_pcap_ip_last"); + + let cur = cfg.network.nfs_pcap_ip.map(|i| i.to_string()).unwrap_or_default(); + let mut text: String = ui.data_mut(|d| d.get_temp::(buf_id)).unwrap_or_else(|| cur.clone()); + // Re-sync the buffer if the stored IP changed outside this code (machine switch). + let last: String = ui.data_mut(|d| d.get_temp::(last_id)).unwrap_or_default(); + if cur != last { + text = cur.clone(); + } + + let mut changed = false; + ui.horizontal(|ui| { + ui.label("NFS server IP"); + let resp = ui.add(TextEdit::singleline(&mut text) + .hint_text("e.g. 192.168.1.213").desired_width(130.0)); + if resp.changed() { + cfg.network.nfs_pcap_ip = text.trim().parse::().ok(); + changed = true; + } + // One-click suggestion: a free address high in the host LAN's host range. + #[cfg(feature = "pcap")] + if let Some(ip) = suggest_nfs_ip(cfg, host) { + if ui.button(format!("Use {ip}")) + .on_hover_text("A free address on your LAN's subnet") + .clicked() + { + text = ip.to_string(); + cfg.network.nfs_pcap_ip = Some(ip); + changed = true; + } + } + }); + ui.label(RichText::new( + "Give the NFS server a free address on the same subnet your Indy uses (your real LAN). \ + Then on the Indy: mount it from this IP. No gateway needed — it's reachable directly.") + .weak()); + + ui.data_mut(|d| { + d.insert_temp(buf_id, text); + d.insert_temp(last_id, cfg.network.nfs_pcap_ip.map(|i| i.to_string()).unwrap_or_default()); + }); + changed +} + +/// Suggest a free IPv4 for the PCAP NFS host: take the subnet of the selected +/// capture interface (else the first host interface), reserve the host's own +/// addresses, and pick the first [`nfs_ip_candidates`] entry. +/// +/// [`nfs_ip_candidates`]: iris::net_pcap::nfs_ip_candidates +#[cfg(feature = "pcap")] +fn suggest_nfs_ip(cfg: &MachineConfig, host: &[crate::netplan::HostIface]) -> Option { + let want = cfg.network.pcap_interface.as_deref().filter(|s| !s.is_empty()); + let iface = want + .and_then(|n| host.iter().find(|h| h.name == n)) + .or_else(|| host.first())?; + let reserved: Vec<_> = host.iter().map(|h| h.addr).collect(); + iris::net_pcap::nfs_ip_candidates(iface.network, iface.prefix, &reserved) + .into_iter() + .next() +} + /// 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. +/// Returns `(action, committed)` — `committed` is true when the interface was +/// deliberately changed (a dropdown pick, or the manual field losing focus), the +/// cue to reopen a running PcapEngine's capture. Per-keystroke text edits don't +/// commit, so typing "bridge100" doesn't thrash through `b`, `br`, … fn pcap_interface_picker( ui: &mut Ui, cfg: &mut MachineConfig, pcap_ifaces: &Option, String>>, -) -> ConfigAction { +) -> (ConfigAction, bool) { let mut action = ConfigAction::None; + let mut committed = false; // 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). @@ -816,6 +957,7 @@ fn pcap_interface_picker( if ui.selectable_label(is_auto, "Auto-pick (first up, non-loopback)").clicked() { cfg.network.pcap_interface = None; is_auto = true; + committed = true; } let _ = is_auto; @@ -826,6 +968,7 @@ fn pcap_interface_picker( 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()); + committed = true; } } } @@ -854,12 +997,17 @@ fn pcap_interface_picker( ui.horizontal(|ui| { ui.label(" or type index/name"); let mut manual = current.clone().unwrap_or_default(); - if ui.add(TextEdit::singleline(&mut manual) + let resp = ui.add(TextEdit::singleline(&mut manual) .hint_text("e.g. 1, eth0, or blank = auto") - .desired_width(260.0)).changed() - { + .desired_width(260.0)); + if resp.changed() { cfg.network.pcap_interface = if manual.trim().is_empty() { None } else { Some(manual) }; } + // Commit (reopen the live capture) only when the field loses focus, so a + // running reswap doesn't fire on every keystroke. + if resp.lost_focus() { + committed = true; + } }); // Show an inline error if enumeration failed. @@ -867,7 +1015,7 @@ fn pcap_interface_picker( ui.colored_label(Color32::from_rgb(0xe0, 0x60, 0x60), format!("Interface list unavailable: {e}")); } - action + (action, committed) } fn show_vino(ui: &mut Ui, cfg: &mut MachineConfig) -> ConfigAction { diff --git a/iris-gui/src/handle.rs b/iris-gui/src/handle.rs index 8dbff33..62b5338 100644 --- a/iris-gui/src/handle.rs +++ b/iris-gui/src/handle.rs @@ -22,6 +22,9 @@ pub enum Cmd { /// Rebind the running NAT's inbound port-forward listeners from this rule /// set, live — no reboot. Ignored if not running. SetPortForwards(Vec), + /// Reopen the PCAP capture on a different host interface (`None` = auto-pick) + /// without rebooting the guest. Ignored if not running / not in PCAP mode. + SetPcapInterface(Option), SaveState(String), RestoreState(String), Screenshot(PathBuf), @@ -98,6 +101,10 @@ pub struct Status { pub net_guest_gateway: Option, /// IRIS's current NAT gateway (reflects any live adoption). pub net_nat_gateway: Option, + /// Live PCAP capture-backend status. `Inactive` in NAT mode / non-pcap + /// builds; `PermissionDenied` when a pcap-mode machine couldn't open the raw + /// capture, which drives the "Enable packet capture" elevation prompt. + pub pcap_status: iris::net::PcapStatus, } /// State of the internal-network ("NET") indicator shown next to MIPS. @@ -199,6 +206,7 @@ impl EmulatorHandle { self.status.net_guest_ip = s.net_guest_ip; self.status.net_guest_gateway = s.net_guest_gateway; self.status.net_nat_gateway = s.net_nat_gateway; + self.status.pcap_status = s.pcap_status; } match &evt { Evt::Started => { @@ -233,6 +241,12 @@ impl EmulatorHandle { net_state_for(self.status.running, self.status.cpu_halted, self.net_seen_frames) } + /// Live PCAP capture-backend status, sampled from the running machine. + /// `Inactive` when no machine is up, in NAT mode, or on a non-pcap build. + /// The app watches this for `PermissionDenied` to raise the "Enable packet + /// capture" elevation prompt. + pub fn pcap_status(&self) -> iris::net::PcapStatus { self.status.pcap_status } + /// Stop the machine (if running) and join the worker thread. Idempotent. /// Call this from the GUI's `on_exit` so a running machine is cleaned up /// even when the user closes the window without pressing Stop — and so the @@ -315,9 +329,12 @@ fn worker_loop( let net_guest_ip = machine.as_ref().and_then(|m| m.net_observed_guest_ip()); let net_guest_gateway = machine.as_ref().and_then(|m| m.net_observed_gateway()); let net_nat_gateway = machine.as_ref().map(|m| m.nat_expected().1); + let pcap_status = machine.as_ref() + .map_or(iris::net::PcapStatus::Inactive, |m| m.net_pcap_status()); let _ = evt_tx.send(Evt::Status(Status { mips, cpu_halted, cpu_stopped, chd_sync_pending, net_frames, net_guest_ip, net_guest_gateway, net_nat_gateway, + pcap_status, ..Status::default() })); } @@ -400,6 +417,9 @@ fn worker_loop( Ok(Cmd::SetPortForwards(rules)) => { if let Some(m) = machine.as_ref() { m.set_port_forwards(rules); } } + Ok(Cmd::SetPcapInterface(iface)) => { + if let Some(m) = machine.as_ref() { m.set_pcap_interface(iface); } + } Ok(Cmd::Stop) => { if let Some(m) = machine.take() { *ps2_slot.lock() = None; diff --git a/iris-gui/src/main.rs b/iris-gui/src/main.rs index d8ef4c2..0afc917 100644 --- a/iris-gui/src/main.rs +++ b/iris-gui/src/main.rs @@ -1,6 +1,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod camera_test; +mod capture_access; mod config_ui; mod dialogs; mod framebuffer; @@ -173,6 +174,14 @@ struct App { /// Set when the Networking tab commits a subnet that fails the sanity checks /// (non-RFC1918 or a host conflict); drives the Override/Cancel modal. net_sanity_modal: Option, + /// `true` while the "Enable packet capture" prompt is open — raised either by + /// the user's button in the Network tab or automatically when a pcap-mode + /// machine reports `PcapStatus::PermissionDenied` on Start. + pcap_perm_modal: bool, + /// One-shot guard so the automatic permission prompt fires at most once per + /// run (reset on `Evt::Started`); without it the modal would re-open every + /// frame the capture stays denied. + pcap_perm_prompted: bool, new_machine: NewMachineDialog, create_disk: CreateDiskDialog, /// If true, central panel shows the tabbed config editor; otherwise the @@ -451,6 +460,8 @@ impl App { confirm_embedded_prom: false, net_ifaces: netplan::gather_host_ifaces(), net_sanity_modal: None, + pcap_perm_modal: false, + pcap_perm_prompted: false, new_machine, create_disk: CreateDiskDialog::default(), show_config_editor: false, @@ -490,6 +501,22 @@ impl App { self.pcap_ifaces = Some(config_ui::enumerate_pcap_ifaces()); } + /// Run the platform's "grant packet-capture permission" flow and report the + /// result. Invoked by the Network tab's "Enable packet capture…" button and + /// by the automatic permission-denied prompt. The per-OS work lives in + /// `capture_access` (Linux pkexec/setcap, macOS ChmodBPF, Windows Npcap). + fn run_enable_packet_capture(&mut self) { + use capture_access::EnableOutcome; + // Dismiss the prompt regardless of outcome; the user made a choice. + self.pcap_perm_modal = false; + match capture_access::enable_packet_capture() { + EnableOutcome::Enabled => self.toast("packet capture enabled"), + EnableOutcome::NeedsRelaunch(msg) => self.toast(msg), + EnableOutcome::Cancelled => {} + EnableOutcome::Failed(why) => self.toast(why), + } + } + fn toast(&mut self, msg: impl Into) { self.toast = Some((msg.into(), std::time::Instant::now())); } @@ -707,11 +734,16 @@ impl App { } } - /// App Store preflight: attached CHD disks (non-scratch HDDs) whose folder - /// we can't write, so their on-exit `.diff.chd` fold would be denied. Empty - /// off the sandbox build, where folders are reachable directly. + /// macOS preflight: attached CHD disks (non-scratch HDDs) whose folder we + /// can't write, so their on-exit `.diff.chd` fold would be denied. Runs on + /// ALL macOS builds: the sandbox (MAS) build needs an explicit directory + /// security-scoped grant, while the notarized build is subject to macOS TCC + /// (Documents/Desktop/Downloads/iCloud/external/network volumes are blocked + /// until the user consents). Either way we probe real writability and prompt + /// up front, so the fold doesn't silently fail at quit. Empty off macOS, + /// where there's no such gate and folders are writable directly. fn chd_dirs_needing_grant(&self) -> Vec { - if !cfg!(feature = "appstore") { + if !cfg!(target_os = "macos") { return Vec::new(); } let mut out = Vec::new(); @@ -822,7 +854,12 @@ impl App { fn handle_events(&mut self, ctx: &egui::Context) { for evt in self.emu.drain_events() { match evt { - Evt::Started => self.toast("emulator started"), + Evt::Started => { + // Fresh run: re-arm the one-shot PCAP permission prompt so a + // pcap-mode machine that can't open its capture re-prompts. + self.pcap_perm_prompted = false; + self.toast("emulator started"); + } Evt::Stopped => self.toast("emulator stopped"), Evt::PowerOff => self.toast("guest powered off (safe to stop)"), Evt::StateSaved(n) => self.toast(format!("state saved: {n}")), @@ -850,6 +887,17 @@ impl App { } } } + // Automatic PCAP elevation prompt: a pcap-mode machine that couldn't open + // its raw capture for lack of privilege reports `PermissionDenied`. Raise + // the same "Enable packet capture" prompt the Network tab button uses, + // once per run (the one-shot guard avoids re-opening it every frame). + if !self.pcap_perm_prompted + && self.emu.pcap_status() == iris::net::PcapStatus::PermissionDenied + { + self.pcap_perm_prompted = true; + self.pcap_perm_modal = true; + } + // Repaint cadence: ~60 fps while the emulator is running so we // can pull the latest framebuffer; lazy otherwise to save CPU. let next = if self.emu.is_running() { 16 } else { 250 }; @@ -1453,57 +1501,56 @@ impl App { if running && !halted { ui.label(format!("{:.0} MIPS", self.emu.status.mips)); } - // Internal-network indicator — always shown (grey while unpowered or - // halted). Green once the guest is carrying NAT IP traffic; red when a - // running guest has produced none (a hint its IP is missing or wrong for - // the configured NAT subnet). - let (net_color, net_tip) = match self.emu.net_state() { - NetState::Active => ( - Color32::from_rgb(0x35, 0xb8, 0x4a), - "Internal network: carrying guest NAT traffic.", - ), - NetState::Idle => ( - Color32::from_rgb(0xd9, 0x4a, 0x3d), - "Internal network: no NAT traffic yet.\nThe guest may have no IP — or the wrong IP for this NAT subnet.", - ), - NetState::Off => ( - Color32::from_gray(0x80), - "Internal network: machine not running.", - ), + // Networking indicator — ONE badge that shows both liveness (the dot's + // colour) and the active backend (the label: NAT / PCAP). Grey while + // unpowered/halted; green once the guest is carrying IP traffic, red when + // a running guest has produced none. Works for both backends — the PCAP + // engine counts guest IP frames too. The backend is latched on Start + // (launched_net), not the live editor. + use iris::config::NetMode; + let backend = self.launched_net.clone(); + let state = self.emu.net_state(); + let net_color = match state { + NetState::Active => Color32::from_rgb(0x35, 0xb8, 0x4a), + NetState::Idle => Color32::from_rgb(0xd9, 0x4a, 0x3d), + NetState::Off => Color32::from_gray(0x80), }; + let (net_label, net_tip): (&str, String) = match (running, backend.as_ref()) { + (true, Some((NetMode::Pcap, iface))) => { + let iface = iface.as_deref().filter(|s| !s.is_empty()).unwrap_or("auto"); + let tip = match state { + NetState::Active => format!("Bridged (PCAP) on {iface}: guest is carrying IP traffic."), + NetState::Idle => format!("Bridged (PCAP) on {iface}: no guest IP traffic yet.\nCheck the guest's IP / DHCP — wired connections only."), + NetState::Off => "Bridged (PCAP) networking: machine not running.".into(), + }; + ("PCAP", tip) + } + (true, Some((NetMode::Nat, _))) => { + let tip = match state { + NetState::Active => "Internal NAT network: carrying guest traffic.".into(), + NetState::Idle => "Internal NAT network: no traffic yet.\nThe guest may have no IP — or the wrong IP for this NAT subnet.".into(), + NetState::Off => "Internal NAT network: machine not running.".into(), + }; + ("NAT", tip) + } + _ => ("NET", "Internal network: machine not running.".into()), + }; + // "check" diagnoses the guest IP against the NAT subnet — only meaningful + // in NAT mode, so it's hidden in PCAP. + let nat_running = running && matches!(backend.as_ref(), Some((NetMode::Nat, _))); let mut want_check = false; ui.horizontal(|ui| { - // U+2022 (bullet), sized up as a status light. NOT U+25CF (●), - // which renders as tofu — egui's label font chain (Ubuntu-Light → - // NotoEmoji → emoji-icon-font) has no filled circle; U+25CF is only - // in Hack, which is monospace-only. See rules/gui/egui-…-tofu.md. - ui.label(RichText::new("\u{2022}").size(18.0).color(net_color)).on_hover_text(net_tip); - ui.label("NET").on_hover_text(net_tip); - if running && ui.small_button("check").on_hover_text("Diagnose guest networking").clicked() { + // U+2022 (bullet) as a status light. NOT U+25CF (●), which renders as + // tofu in egui's font chain. See rules/gui/egui-…-tofu.md. + ui.label(RichText::new("\u{2022}").size(18.0).color(net_color)).on_hover_text(net_tip.as_str()); + ui.label(net_label).on_hover_text(net_tip.as_str()); + if nat_running && ui.small_button("check").on_hover_text("Diagnose guest networking").clicked() { want_check = true; } }); 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. @@ -1811,6 +1858,7 @@ impl App { ConfigAction::RequestEmbeddedProm => self.confirm_embedded_prom = true, ConfigAction::TestCamera => self.open_camera_test(), ConfigAction::RefreshPcapIfaces => self.refresh_pcap_ifaces(), + ConfigAction::EnablePacketCapture => self.run_enable_packet_capture(), ConfigAction::None => {} } if out.disks_changed { self.mark_dirty(); } @@ -1824,6 +1872,18 @@ impl App { // takes effect without a restart (latest-wins coalesces in the NAT). self.emu.send(Cmd::SetPortForwards(self.cfg.port_forward.clone())); } + // Live PCAP host-NIC reswap: if the running machine is bridged and the + // interface was just committed, reopen the capture on the new NIC — no + // guest reboot. Latch the new iface into launched_net so the status badge + // reflects it. + if out.net.iface_changed + && matches!(self.launched_net.as_ref(), Some((iris::config::NetMode::Pcap, _))) + { + self.emu.send(Cmd::SetPcapInterface(self.cfg.network.pcap_interface.clone())); + if let Some((_, iface)) = self.launched_net.as_mut() { + *iface = self.cfg.network.pcap_interface.clone(); + } + } if let Some(p) = out.net.prompt { self.net_sanity_modal = Some(NetSanityModal { reason: p.reason, suggestion: p.suggestion, revert_to: p.revert_to, @@ -2165,18 +2225,28 @@ impl App { } use iris::nfsudp::NfsVersion; - // Gateway the guest will mount from: the one IRIS has live-adopted while - // running, else the gateway derived from the configured NAT subnet, else - // the documented default. Matches network_check_window's derivation. - let (eb, ep) = netplan::parse_cidr(self.cfg.nat_subnet.as_deref()); - let cfg_gw = netplan::classify(eb, ep, &[]).derived.map(|d| d.gateway.to_string()); - let gw = self - .emu - .status - .net_guest_gateway - .map(|g| g.to_string()) - .or(cfg_gw) - .unwrap_or_else(|| "192.168.0.1".into()); + // Address the guest mounts from. PCAP (bridged) mode: the in-core NFS + // server is a virtual L2 host at the IP you assigned — show that, not a NAT + // gateway (there is none). NAT mode: the gateway IRIS live-adopted while + // running, else the gateway derived from the configured subnet, else the + // documented default. (Matches network_check_window's derivation.) + let pcap = self.cfg.network.mode == iris::config::NetMode::Pcap; + let gw = if pcap { + self.cfg + .network + .nfs_pcap_ip + .map(|ip| ip.to_string()) + .unwrap_or_else(|| "".into()) + } else { + let (eb, ep) = netplan::parse_cidr(self.cfg.nat_subnet.as_deref()); + let cfg_gw = netplan::classify(eb, ep, &[]).derived.map(|d| d.gateway.to_string()); + self.emu + .status + .net_guest_gateway + .map(|g| g.to_string()) + .or(cfg_gw) + .unwrap_or_else(|| "192.168.0.1".into()) + }; let mut open = true; egui::Window::new("Mount the shared folder in IRIX") @@ -2200,10 +2270,15 @@ impl App { return; }; - ui.label( - "IRIS serves the folder below over NFS, in-process, from the NAT gateway. \ + let source = if pcap { + "as a virtual host on your LAN at the address you assigned" + } else { + "from the NAT gateway" + }; + ui.label(format!( + "IRIS serves the folder below over NFS, in-process, {source}. \ Boot IRIX, make sure networking is up (the NET light / Check networking), \ - then log in as root and run these commands at an IRIX shell:"); + then log in as root and run these commands at an IRIX shell:")); ui.add_space(6.0); if nfs.shared_dir.trim().is_empty() { @@ -2778,6 +2853,38 @@ impl eframe::App for App { if close { self.net_sanity_modal = None; } } + // PCAP capture-permission prompt (button-raised or auto-raised on a + // permission-denied capture). "Enable" runs the platform privilege flow; + // "Not now" just dismisses — the guest keeps running on NAT-less PCAP + // (i.e. no networking) until the user enables capture or switches to NAT. + if self.pcap_perm_modal { + let mut do_enable = false; + let mut close = false; + egui::Window::new("Enable packet capture") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.set_max_width(440.0); + ui.label(RichText::new( + "PCAP (bridged) networking needs permission to capture on a real \ + interface, which IRIS doesn't have yet.").strong()); + ui.add_space(4.0); + ui.label(RichText::new(capture_access::permission_hint()).weak()); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.add(egui::Button::new("Enable packet capture…") + .fill(Color32::from_rgb(60, 90, 140))).clicked() + { + do_enable = true; + } + if ui.button("Not now").clicked() { close = true; } + }); + }); + if do_enable { self.run_enable_packet_capture(); } // also clears the modal + if close { self.pcap_perm_modal = false; } + } + // Missing-disk modal. enum MissingChoice { None, Cancel, Detach, EditDisks } let mut choice = MissingChoice::None; @@ -2841,7 +2948,10 @@ impl eframe::App for App { // CHD folder-grant modal (App Store): attached CHDs sit in folders we // can't write, so their on-exit fold would be denied. Offer to grant // each folder, or start anyway and forgo compaction. - enum ChdGrantChoice { None, Cancel, StartAnyway, Grant(String) } + // `Grant` is built only in the sandbox branch, `OpenPrivacy`/`Recheck` + // only in the notarized branch — so one set is always cfg-dead. + #[allow(dead_code)] + enum ChdGrantChoice { None, Cancel, StartAnyway, Grant(String), OpenPrivacy, Recheck } let mut chd_choice = ChdGrantChoice::None; if let Some(modal) = &self.chd_grant_modal { egui::Window::new("Grant folder access to compact disks") @@ -2866,17 +2976,41 @@ impl eframe::App for App { put each disk image in its own dedicated folder and grant only that.") .italics().weak()); ui.add_space(6.0); - ui.label(RichText::new("Grant the containing folder so IRIS can compact it on exit:").weak()); - // One button per distinct folder — a recursive grant covers - // every disk under it. - let mut dirs: Vec<&str> = modal.disks.iter().map(|d| d.dir.as_str()).collect(); - dirs.sort_unstable(); - dirs.dedup(); - for dir in dirs { - if ui.button(format!("Grant \"{dir}\"…")).clicked() { - chd_choice = ChdGrantChoice::Grant(dir.to_string()); + // Sandbox (MAS): a directory security-scoped grant via the + // open panel conveys write access. One button per distinct + // folder — a recursive grant covers every disk under it. + #[cfg(feature = "appstore")] + { + ui.label(RichText::new("Grant the containing folder so IRIS can compact it on exit:").weak()); + let mut dirs: Vec<&str> = modal.disks.iter().map(|d| d.dir.as_str()).collect(); + dirs.sort_unstable(); + dirs.dedup(); + for dir in dirs { + if ui.button(format!("Grant \"{dir}\"…")).clicked() { + chd_choice = ChdGrantChoice::Grant(dir.to_string()); + } } } + // Notarized macOS: the block is macOS Privacy (TCC), which a + // folder pick doesn't override — the user allows IRIS in + // System Settings, then re-checks (a relaunch may be needed + // for Full Disk Access to take effect). + #[cfg(not(feature = "appstore"))] + { + ui.label(RichText::new( + "macOS Privacy & Security is blocking write access to these folders. \ + Allow IRIS under System Settings → Privacy & Security (Files and Folders, \ + or add it to Full Disk Access), then Re-check. You may need to quit and \ + reopen IRIS for Full Disk Access to take effect.").weak()); + ui.horizontal(|ui| { + if ui.button("Open Privacy & Security…").clicked() { + chd_choice = ChdGrantChoice::OpenPrivacy; + } + if ui.button("Re-check").clicked() { + chd_choice = ChdGrantChoice::Recheck; + } + }); + } ui.add_space(4.0); ui.horizontal(|ui| { if ui.button("Cancel").clicked() { chd_choice = ChdGrantChoice::Cancel; } @@ -2908,6 +3042,27 @@ impl eframe::App for App { } } } + ChdGrantChoice::OpenPrivacy => { + // Open System Settings → Privacy & Security → Full Disk Access so + // the user can add IRIS (the "+"). No-op off macOS. + #[cfg(target_os = "macos")] + { + let _ = std::process::Command::new("open") + .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles") + .spawn(); + } + } + ChdGrantChoice::Recheck => { + // Re-probe each listed folder; drop the ones now writable (the + // user allowed IRIS in System Settings). Mirrors the Grant path. + if let Some(modal) = &mut self.chd_grant_modal { + modal.disks.retain(|d| !dir_writable(std::path::Path::new(&d.dir))); + if modal.disks.is_empty() { + self.chd_grant_modal = None; + self.start_emulator(); + } + } + } } // The window starts hidden so the first frame(s) can fit it to the diff --git a/src/config.rs b/src/config.rs index 1e15378..a06700e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -131,6 +131,9 @@ pub struct NetworkConfig { /// 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, + /// PCAP-only virtual IP for the in-process NFS server (so a bridged guest can + /// mount it). None = NFS-in-PCAP not configured. + pub nfs_pcap_ip: Option, } /// `[network]` section: backend selection and PCAP options. @@ -144,6 +147,12 @@ pub struct NetworkSection { /// Run `iris --list-net-interfaces` to enumerate candidates. #[serde(default, skip_serializing_if = "Option::is_none")] pub pcap_interface: Option, + /// PCAP-only: the virtual LAN IP the in-process NFS server answers on, so a + /// bridged guest (which is directly on your real LAN, with no NAT gateway to + /// reach) can mount it. None = NFS-in-PCAP not configured. NAT mode ignores + /// this and serves NFS at the gateway IP instead. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nfs_pcap_ip: Option, } /// Where VINO's video-in capture should come from. @@ -492,6 +501,7 @@ impl MachineConfig { nat_subnet, mode: self.network.mode, pcap_interface: self.network.pcap_interface.clone(), + nfs_pcap_ip: self.network.nfs_pcap_ip, } } diff --git a/src/hpc3.rs b/src/hpc3.rs index 986832c..4663ff4 100644 --- a/src/hpc3.rs +++ b/src/hpc3.rs @@ -1026,6 +1026,7 @@ impl Hpc3 { let subnet = net.nat_subnet.unwrap_or_default(); let net_mode = net.mode; let pcap_interface = net.pcap_interface; + let nfs_pcap_ip = net.nfs_pcap_ip; let rtc = Arc::new(Ds1x86::new(8192, nvram_path)); let pdma_dump = Arc::new(AtomicU32::new(0)); @@ -1095,6 +1096,7 @@ impl Hpc3 { netmask: subnet.netmask, mode: net_mode, pcap_interface, + nfs_pcap_ip, ..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 1b224c6..a3fbffa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,19 @@ pub mod build_features { /// from the MIPS executor hot path. Interactive debugging (GDB stub, /// monitor breakpoints) is non-functional in this build. pub const LIGHTNING: bool = cfg!(feature = "lightning"); + /// The emulated CPU, fixed at build time (the cache model differs deeply + /// between the R4400 and R5000, so it's a compile-time choice, not a runtime + /// setting). `r5k` selects the R5000; `r5ksc`/`r5ksc_triton` add a secondary + /// cache. The GUI surfaces this read-only on the Memory tab. + pub const CPU: &str = if cfg!(feature = "r5ksc_triton") { + "MIPS R5000 (Triton on-die L2)" + } else if cfg!(all(feature = "r5k", feature = "r5ksc")) { + "MIPS R5000 (external L2)" + } else if cfg!(feature = "r5k") { + "MIPS R5000" + } else { + "MIPS R4400" + }; } pub mod config; diff --git a/src/machine.rs b/src/machine.rs index b63fdcc..5a368b3 100644 --- a/src/machine.rs +++ b/src/machine.rs @@ -768,6 +768,14 @@ impl Machine { self.hpc3.seeq().nat_control().observed_gateway() } + /// Live status of the PCAP bridged-capture backend. `Inactive` unless this is + /// a `--features pcap` build whose active machine uses `mode = "pcap"`; goes + /// `PermissionDenied` when the raw capture can't be opened for lack of + /// privilege, which the GUI turns into an "Enable packet capture" prompt. + pub fn net_pcap_status(&self) -> crate::net::PcapStatus { + self.hpc3.seeq().nat_control().pcap_status() + } + /// Move the running NAT onto a new subnet without a reboot: the NAT thread /// swaps its `(gateway, client, netmask)` and flushes connection state on /// its next loop. Typically gateway = network+1, client = network+2. @@ -788,6 +796,13 @@ impl Machine { self.hpc3.seeq().nat_control().set_port_forwards(rules); } + /// Change the PCAP bridged host interface (`None` = auto-pick) on a running + /// machine without rebooting the guest: the PcapEngine reopens its capture on + /// the new NIC. No-op in NAT mode. + pub fn set_pcap_interface(&self, iface: Option) { + self.hpc3.seeq().nat_control().request_pcap_interface(iface); + } + /// Step the CPU `n` instructions in-line on the calling thread, with all /// peripheral threads stopped so the CPU sees no external interrupts. /// Used by Phase 3.3 snapshot determinism validator. diff --git a/src/net.rs b/src/net.rs index 731f2d3..2d7f564 100644 --- a/src/net.rs +++ b/src/net.rs @@ -7,7 +7,7 @@ use std::collections::{HashMap, VecDeque}; 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::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering}; use std::sync::Arc; use crate::config::{ForwardBind, ForwardProto, NatSubnet, NetMode, NfsConfig, PortForwardConfig}; use crate::devlog::LogModule; @@ -64,6 +64,9 @@ pub struct GatewayConfig { pub mode: NetMode, /// Host interface to bridge onto in PCAP mode. None = auto-pick. pub pcap_interface: Option, + /// PCAP-only: virtual LAN IP the in-process NFS server answers on. `Some` + /// together with `nfs` enables the bridged NFS responder ([`NfsVirtualHost`]). + pub nfs_pcap_ip: Option, } impl Default for GatewayConfig { @@ -79,6 +82,7 @@ impl Default for GatewayConfig { port_forwards: vec![], mode: NetMode::Nat, pcap_interface: None, + nfs_pcap_ip: None, } } } @@ -476,6 +480,189 @@ fn portmap_reply(xid: u32, port: u32) -> Vec { r } +/// A virtual NFS host on the bridged LAN, used only in PCAP mode. The in-process +/// NFS server normally lives inside the NAT engine and answers at the gateway IP; +/// in PCAP mode the NAT engine isn't running and the guest sits directly on the +/// real LAN, so we present the server as its own L2 host at a configured virtual +/// IP. [`PcapEngine`] feeds each guest TX frame to [`maybe_handle`]: an ARP-for or +/// UDP-to our IP (portmap / NFS / mountd) is answered and the reply frame(s) are +/// injected straight back to the guest — still zero host sockets. Anything else is +/// forwarded to the wire unchanged. +/// +/// Inbound IP fragments (a large NFS WRITE whose wsize exceeds the link MTU) are +/// reassembled in [`handle_udp`] before dispatch, keyed by (src, id, proto) and +/// aged out after 5s — mirroring the NAT engine's `handle_ip`. Outbound replies are +/// auto-fragmented (via [`ip_frames_udp`]); MOUNT/READ and small ops still fit one +/// datagram. +/// +/// [`maybe_handle`]: NfsVirtualHost::maybe_handle +/// [`handle_udp`]: NfsVirtualHost::handle_udp +#[cfg(feature = "pcap")] +pub(crate) struct NfsVirtualHost { + ip: Ipv4Addr, + mac: [u8; 6], + server: crate::nfsudp::NfsServer, + nfs_cfg: NfsConfig, + ip_id: u16, + /// Inbound IP-fragment reassembly buffer, keyed by (src IP, IP id, proto). + frag_reasm: HashMap<(u32, u16, u8), FragReasm>, +} + +#[cfg(feature = "pcap")] +impl NfsVirtualHost { + pub(crate) fn new(ip: Ipv4Addr, nfs_cfg: NfsConfig) -> Self { + // Locally-administered MAC, distinct from the NAT gateway's, so the guest + // keeps the virtual NFS host as its own ARP entry. (0xBF53 ≈ "BF-NFS".) + let mac = [0x02, 0x00, 0xDE, 0xAD, 0xBF, 0x53]; + let server = crate::nfsudp::NfsServer::new(nfs_cfg.shared_dir.clone(), nfs_cfg.version); + Self { ip, mac, server, nfs_cfg, ip_id: 1, frag_reasm: HashMap::new() } + } + + /// Handle a guest-originated Ethernet `frame` if it's addressed to the virtual + /// NFS host; returns reply frame(s) to inject back to the guest, or `None` if + /// the frame isn't for us (the caller bridges it to the wire). + pub(crate) fn maybe_handle(&mut self, frame: &[u8]) -> Option>> { + if frame.len() < 14 { + return None; + } + match r16(frame, 12) { + ETHERTYPE_ARP => self.handle_arp(frame), + ETHERTYPE_IP => self.handle_udp(frame), + _ => None, + } + } + + fn handle_arp(&self, frame: &[u8]) -> Option>> { + if frame.len() < 14 + 28 { + return None; + } + let a = &frame[14..]; + if r16(a, 0) != ARP_HW_ETHER + || r16(a, 2) != ARP_PROTO_IP + || a[4] != 6 + || a[5] != 4 + || r16(a, 6) != ARP_OP_REQUEST + { + return None; + } + let sender_mac: [u8; 6] = a[8..14].try_into().unwrap(); + let sender_ip = Ipv4Addr::new(a[14], a[15], a[16], a[17]); + let target_ip = Ipv4Addr::new(a[24], a[25], a[26], a[27]); + if target_ip != self.ip { + return None; + } + let mut arp = [0u8; 28]; + w16(&mut arp, 0, ARP_HW_ETHER); + w16(&mut arp, 2, ARP_PROTO_IP); + arp[4] = 6; + arp[5] = 4; + w16(&mut arp, 6, ARP_OP_REPLY); + arp[8..14].copy_from_slice(&self.mac); + arp[14..18].copy_from_slice(&self.ip.octets()); + arp[18..24].copy_from_slice(&sender_mac); + arp[24..28].copy_from_slice(&sender_ip.octets()); + Some(vec![eth_frame(&sender_mac, &self.mac, ETHERTYPE_ARP, &arp)]) + } + + fn handle_udp(&mut self, frame: &[u8]) -> Option>> { + if frame.len() < 34 { + return None; + } + let src_mac: [u8; 6] = frame[6..12].try_into().unwrap(); + let ip = &frame[14..]; + if ip[9] != IP_PROTO_UDP { + return None; + } + let dst_ip = Ipv4Addr::new(ip[16], ip[17], ip[18], ip[19]); + if dst_ip != self.ip { + return None; + } + let src_ip = Ipv4Addr::new(ip[12], ip[13], ip[14], ip[15]); + let ihl = ((ip[0] & 0x0f) as usize) * 4; + let ip_total = r16(ip, 2) as usize; + if ip_total < ihl || frame.len() < 14 + ihl { + return None; + } + let ip_end = ip_total.min(frame.len() - 14); + let ip_payload = &ip[ihl..ip_end]; + + // Inbound IP-fragment reassembly: a large NFS WRITE arrives fragmented when + // its wsize exceeds the link MTU. Buffer fragments by (src, id, proto) and + // only dispatch once the whole UDP datagram is contiguous; mirrors the NAT + // engine's `handle_ip`. Without this, large guest→host writes silently fail. + // A buffered-but-incomplete fragment returns `Some(vec![])` so the caller + // treats it as consumed (not bridged onto the wire), since it's addressed to + // our virtual host. + let flags_frag = r16(ip, 6); + let more = flags_frag & 0x2000 != 0; + let frag_off = ((flags_frag & 0x1fff) as usize) * 8; + let reassembled: Option> = if more || frag_off != 0 { + let id = r16(ip, 4); + let key = (u32::from(src_ip), id, IP_PROTO_UDP); + self.frag_reasm.retain(|_, v| v.last.elapsed() < Duration::from_secs(5)); + match self.frag_reasm.entry(key).or_insert_with(FragReasm::new).add(frag_off, ip_payload, more) { + Some(full) => { self.frag_reasm.remove(&key); Some(full) } + None => return Some(Vec::new()), // consumed; still waiting on fragments + } + } else { + None + }; + // The complete UDP datagram: the reassembled one, or this lone unfragmented one. + let udp: &[u8] = reassembled.as_deref().unwrap_or(ip_payload); + if udp.len() < 8 { + return None; + } + let sport = r16(udp, 0); + let dport = r16(udp, 2); + let payload = &udp[8..]; + let reply = match dport { + UDP_PORT_PORTMAP => { + let xid = if payload.len() >= 4 { r32(payload, 0) } else { 0 }; + portmap_reply(xid, portmap_lookup(payload, &self.nfs_cfg)) + } + NFS_VM_PORT | MOUNTD_VM_PORT => self.server.handle(payload)?, + _ => return None, + }; + // Reply source = our virtual host; reply port = the port the guest hit. + let dgram = udp_packet(self.ip, src_ip, dport, sport, &reply); + let id = self.ip_id; + self.ip_id = self.ip_id.wrapping_add(1); + Some(ip_frames_udp(&src_mac, &self.mac, self.ip, src_ip, id, &dgram)) + } +} + +/// Live status of the PCAP bridged-capture backend, surfaced to an embedder (the +/// GUI) so it can prompt for privilege elevation when the raw capture can't be +/// opened. Deliberately **not** feature-gated — `NatControl` always carries the +/// field and non-`pcap` builds / the GUI reference the type unconditionally; it +/// only ever leaves `Inactive` when the `pcap` feature is built and the active +/// machine uses `[network] mode = "pcap"`. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +#[repr(u8)] +pub enum PcapStatus { + /// Not bridging — NAT mode, or PCAP not yet attempted (default). + #[default] + Inactive = 0, + /// Capture handle is open; frames are bridging onto the host interface. + Active = 1, + /// Open failed for lack of privilege (EPERM/EACCES, libpcap "permission + /// denied"). The embedder should offer to elevate / enable capture. + PermissionDenied = 2, + /// Open failed for another reason (no such device, driver missing, …). + DeviceError = 3, +} + +impl PcapStatus { + fn from_u8(v: u8) -> Self { + match v { + 1 => PcapStatus::Active, + 2 => PcapStatus::PermissionDenied, + 3 => PcapStatus::DeviceError, + _ => PcapStatus::Inactive, + } + } +} + // ── NAT debug/status control (shared between NatEngine thread and command handler) ─ pub struct NatControl { pub debug_tcp: AtomicBool, @@ -521,6 +708,17 @@ pub struct NatControl { /// an embedder add/remove forwards without a reboot. pub pending_forwards: Mutex>>, pub apply_forwards: AtomicBool, + /// Live PCAP backend status (a [`PcapStatus`] discriminant), set by the + /// `PcapEngine` as it opens (or fails to open) the capture and sampled by the + /// GUI to drive the "Enable packet capture" / elevation prompt. `Inactive` + /// (0) in NAT mode and on non-`pcap` builds. + pub pcap_status: AtomicU8, + /// Pending PCAP host-interface reswap (`None` = auto-pick), latched by + /// `request_pcap_interface`. Lets the GUI change the bridged NIC on a running + /// machine: the `PcapEngine` reopens its capture on the next loop — no guest + /// reboot. Unused in NAT mode. + pub pending_pcap_iface: Mutex>, + pub apply_pcap_iface: AtomicBool, } impl NatControl { @@ -542,8 +740,29 @@ impl NatControl { host_nets: Mutex::new(Vec::new()), pending_forwards: Mutex::new(None), apply_forwards: AtomicBool::new(false), + pcap_status: AtomicU8::new(PcapStatus::Inactive as u8), + pending_pcap_iface: Mutex::new(None), + apply_pcap_iface: AtomicBool::new(false), }) } + + /// Ask the running `PcapEngine` to reopen its capture on host interface + /// `iface` (`None` = auto-pick), applied on the engine's next loop. The guest + /// keeps running — only the host-side capture handle is swapped. No-op in NAT. + pub fn request_pcap_interface(&self, iface: Option) { + *self.pending_pcap_iface.lock() = iface; + self.apply_pcap_iface.store(true, Ordering::Release); + } + + /// Record the live PCAP backend status (called by the `PcapEngine`). + pub fn set_pcap_status(&self, s: PcapStatus) { + self.pcap_status.store(s as u8, Ordering::Relaxed); + } + + /// The live PCAP backend status, for an embedder to drive elevation UI. + pub fn pcap_status(&self) -> PcapStatus { + PcapStatus::from_u8(self.pcap_status.load(Ordering::Relaxed)) + } pub fn dbg_tcp(&self) -> bool { self.debug_tcp.load(Ordering::Relaxed) } pub fn dbg_udp(&self) -> bool { self.debug_udp.load(Ordering::Relaxed) } pub fn dbg_icmp(&self) -> bool { self.debug_icmp.load(Ordering::Relaxed) } @@ -724,6 +943,84 @@ mod ftp_alg_tests { } } +#[cfg(all(test, feature = "pcap"))] +mod nfs_pcap_tests { + use super::*; + + /// A 56-byte portmap GETPORT call asking for the port of `req_prog` over UDP. + fn getport_call(xid: u32, req_prog: u32) -> Vec { + let mut p = vec![0u8; 56]; + w32(&mut p, 0, xid); + w32(&mut p, 4, 0); // msg_type = CALL + w32(&mut p, 8, 2); // rpcvers + w32(&mut p, 12, RPC_PROG_PORTMAP); + w32(&mut p, 16, 2); // portmap vers + w32(&mut p, 20, RPC_PORTMAP_GETPORT); + // cred (flavor+len) and verf (flavor+len) all zero (AUTH_NULL). + w32(&mut p, 40, req_prog); // mapping.prog + w32(&mut p, 44, 3); // mapping.vers + w32(&mut p, 48, 17); // mapping.prot = UDP + w32(&mut p, 52, 0); // mapping.port + p + } + + /// A fragmented inbound portmap request (large NFS WRITEs arrive the same way) + /// must be reassembled, not dropped: intermediate fragments are consumed + /// (`Some([])`, so the caller doesn't bridge them onto the wire) and the final + /// fragment yields the reply built from the whole datagram. + #[test] + fn reassembles_fragmented_request() { + let nfs_ip = Ipv4Addr::new(192, 168, 1, 213); + let guest_ip = Ipv4Addr::new(192, 168, 1, 50); + let guest_mac = [0x08, 0x00, 0x69, 0x01, 0x02, 0x03]; + let host_mac = [0x02, 0x00, 0xde, 0xad, 0xbf, 0x53]; + + let mut host = NfsVirtualHost::new( + nfs_ip, + NfsConfig { shared_dir: "/nonexistent".into(), version: crate::nfsudp::NfsVersion::Auto }, + ); + + // Full UDP datagram (header + portmap GETPORT for NFS), then split its IP + // payload across two fragments at an 8-byte boundary. + let dgram = udp_packet(guest_ip, nfs_ip, 0x9abc, UDP_PORT_PORTMAP, &getport_call(0x1234, RPC_PROG_NFS)); + let (a, b) = dgram.split_at(8); + let frag1 = ip_fragment_frame(&host_mac, &guest_mac, guest_ip, nfs_ip, IP_PROTO_UDP, 0x42, 0, true, a); + let frag2 = ip_fragment_frame(&host_mac, &guest_mac, guest_ip, nfs_ip, IP_PROTO_UDP, 0x42, 8, false, b); + + // First fragment: consumed, no reply yet (NOT None — None would bridge it). + assert_eq!(host.maybe_handle(&frag1), Some(Vec::new())); + + // Second fragment completes the datagram → one reply frame. + let frames = host.maybe_handle(&frag2).expect("reply after reassembly"); + assert_eq!(frames.len(), 1); + // eth(14) + ip(20) + udp(8) = 42-byte headers, then the 28-byte portmap reply + // whose final word is the resolved port. + let reply = &frames[0]; + let port = u32::from_be_bytes(reply[reply.len() - 4..].try_into().unwrap()); + assert_eq!(port, NFS_VM_PORT as u32, "portmap resolved NFS to its VM port"); + } + + /// An unfragmented request still works unchanged (no reassembly path taken). + #[test] + fn handles_unfragmented_request() { + let nfs_ip = Ipv4Addr::new(10, 0, 0, 9); + let guest_ip = Ipv4Addr::new(10, 0, 0, 2); + let guest_mac = [0x08, 0x00, 0x69, 0x0a, 0x0b, 0x0c]; + let host_mac = [0x02, 0x00, 0xde, 0xad, 0xbf, 0x53]; + + let mut host = NfsVirtualHost::new( + nfs_ip, + NfsConfig { shared_dir: "/nonexistent".into(), version: crate::nfsudp::NfsVersion::Auto }, + ); + let dgram = udp_packet(guest_ip, nfs_ip, 0x9abc, UDP_PORT_PORTMAP, &getport_call(7, RPC_PROG_MOUNTD)); + let frame = ip_frame(&host_mac, &guest_mac, guest_ip, nfs_ip, IP_PROTO_UDP, &dgram); + let frames = host.maybe_handle(&frame).expect("reply"); + let reply = &frames[0]; + let port = u32::from_be_bytes(reply[reply.len() - 4..].try_into().unwrap()); + assert_eq!(port, MOUNTD_VM_PORT as u32); + } +} + // ── NAT engine ──────────────────────────────────────────────────────────────── pub struct NatEngine { config: GatewayConfig, diff --git a/src/net_pcap.rs b/src/net_pcap.rs index 5f05aea..e1ab289 100644 --- a/src/net_pcap.rs +++ b/src/net_pcap.rs @@ -39,7 +39,7 @@ use parking_lot::{Condvar, Mutex}; use crate::config::NetMode; use crate::devlog::LogModule; -use crate::net::{eth_summary, mac_str, GatewayConfig, NatControl, NetBackend}; +use crate::net::{eth_summary, mac_str, GatewayConfig, NatControl, NetBackend, NfsVirtualHost, PcapStatus}; /// Summary of one host interface, returned by `list_interfaces()`. pub struct NetInterface { @@ -295,6 +295,11 @@ pub struct PcapEngine { /// 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]>, + /// In-process NFS server presented as its own virtual L2 host on the bridged + /// LAN. `Some` when an NFS share is configured and `[network] nfs_pcap_ip` is + /// set; the guest's ARP/portmap/NFS frames to that IP are answered locally + /// instead of going to the wire. + nfs_host: Option, } impl PcapEngine { @@ -305,7 +310,16 @@ impl PcapEngine { 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 } + // Stand up the virtual NFS host when an export and a virtual IP are both + // configured — otherwise bridged frames just go to the wire. + let nfs_host = match (config.nfs.clone(), config.nfs_pcap_ip) { + (Some(nfs_cfg), Some(ip)) => { + eprintln!("iris: PCAP NFS server on virtual host {} exporting {}", ip, nfs_cfg.shared_dir); + Some(NfsVirtualHost::new(ip, nfs_cfg)) + } + _ => None, + }; + Self { config, tx_cons, rx_prod, rx_wake, tx_wake, running, ctl, guest_mac: None, nfs_host } } /// Open the configured (or auto-selected) capture handle in promiscuous, @@ -328,8 +342,16 @@ impl PcapEngine { impl NetBackend for PcapEngine { fn run(&mut self) { let mut cap = match self.open_capture() { - Ok(c) => c, + Ok(c) => { + // Capture is live — let the GUI light "capturing" and dismiss any + // earlier permission prompt. + self.ctl.set_pcap_status(PcapStatus::Active); + c + } Err(e) => { + // Classify the failure so the GUI can offer to elevate (permission) + // vs. just report a bad/absent device. See `classify_open_error`. + self.ctl.set_pcap_status(classify_open_error(&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. @@ -357,12 +379,63 @@ impl NetBackend for PcapEngine { // the flag so it doesn't stay set. self.ctl.reset_nat.swap(false, Ordering::AcqRel); + // Live host-NIC reswap (GUI changed the PCAP interface on a running + // machine). Reopen the capture in place; the guest is untouched. If + // the new interface fails to open, keep the current one so a typo + // doesn't drop networking. + if self.ctl.apply_pcap_iface.swap(false, Ordering::AcqRel) { + let new_iface = self.ctl.pending_pcap_iface.lock().clone(); + self.config.pcap_interface = new_iface; + match self.open_capture() { + Ok(c) => { + cap = c; + self.ctl.set_pcap_status(PcapStatus::Active); + eprintln!("iris: PCAP capture re-opened on new interface"); + } + Err(e) => { + eprintln!("iris: PCAP reopen failed: {}; keeping the previous interface", e); + } + } + } + // ── 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)); + // Now that the guest's MAC is known, push a kernel BPF filter so + // promiscuous capture only delivers frames the guest can accept + // (its own unicast + broadcast + multicast) rather than the whole + // LAN's unicast chatter, which would otherwise flood the RX ring. + // (Our own injected broadcasts still come back and are dropped by + // the src==guest_mac check below.) + let prog = format!("ether dst {m} or ether broadcast or ether multicast", m = mac_str(&mac)); + if let Err(e) = cap.filter(&prog, true) { + dlog_dev!(LogModule::Net, "PCAP set capture filter failed: {}", e); + } else { + dlog_dev!(LogModule::Net, "PCAP capture filter set: {}", prog); + } + } + // Count guest-originated IPv4 frames so the GUI's NET indicator + // lights in PCAP mode too (the NAT engine isn't running to bump + // this). ARP / link-layer chatter is excluded, matching NAT's + // semantics — it's "the guest is actually carrying IP traffic". + if frame.len() >= 14 && frame[12] == 0x08 && frame[13] == 0x00 { + self.ctl.guest_frames.fetch_add(1, Ordering::Relaxed); + } + // Intercept frames bound for the virtual NFS host (ARP / portmap / + // NFS / mountd): answer them locally and inject the reply on the RX + // ring, rather than leaking them onto the real LAN. + if let Some(host) = self.nfs_host.as_mut() { + if let Some(replies) = host.maybe_handle(&frame) { + for reply in replies { + if self.rx_prod.slots() > 0 && self.rx_prod.push(reply).is_ok() { + self.rx_wake.1.notify_one(); + } + } + continue; + } } if self.ctl.dbg_tcp() { dlog_dev!(LogModule::Net, "PCAP TX {}", eth_summary(&frame)); @@ -414,6 +487,72 @@ pub fn is_pcap_mode(mode: NetMode) -> bool { mode == NetMode::Pcap } +/// Classify a libpcap open/activate error into a [`PcapStatus`] the GUI can act +/// on. pcap 2.x has no dedicated permission variant — EPERM/EACCES surface as +/// `PcapError(String)` or `ErrnoError`, both of which Display to text containing +/// "permission denied" / "operation not permitted" on macOS (BPF) and Linux. So +/// we match the rendered message rather than a crate error variant, which is +/// robust across platforms. +fn classify_open_error(msg: &str) -> PcapStatus { + let m = msg.to_ascii_lowercase(); + if m.contains("permission denied") + || m.contains("operation not permitted") + || m.contains("not permitted") + || m.contains("eacces") + || m.contains("eperm") + { + PcapStatus::PermissionDenied + } else { + PcapStatus::DeviceError + } +} + +/// Ordered candidate IPv4 addresses for the in-PCAP NFS server's virtual host on +/// a bridged subnet `network`/`prefix`. The GUI pings these in order and pre-fills +/// the first that doesn't answer; the [`PcapEngine`] ARP-probes similarly. +/// +/// `.213` sits at offset 212 of a /24's 254 usable hosts (~83.5% up the range — +/// the "85% range"); that ratio is scaled to this subnet for the start, the scan +/// runs upward, and it never exceeds the 95% mark of the usable host range. The +/// network/broadcast addresses and any `reserved` ones (host IP, gateway, guest +/// IP, …) are excluded. At most 16 candidates are returned ("a few to ping"). +pub fn nfs_ip_candidates( + network: std::net::Ipv4Addr, + prefix: u8, + reserved: &[std::net::Ipv4Addr], +) -> Vec { + use std::collections::HashSet; + use std::net::Ipv4Addr; + const MAX_CANDIDATES: usize = 16; + // /31 and /32 (and the degenerate /0) have no usable host band to pick from. + if prefix == 0 || prefix >= 31 { + return Vec::new(); + } + let host_bits = 32 - prefix as u32; + let net = u32::from(network) & (u32::MAX << host_bits); // normalize to network addr + let size = 1u32 << host_bits; + let usable = size - 2; + let first = net + 1; + let broadcast = net + size - 1; + let start_off = (usable as u64 * 212 / 254) as u32; // /24 → 212 → .213 + let cap_off = (usable as u64 * 95 / 100) as u32; // 95% of the usable range + let reserved_set: HashSet = reserved + .iter() + .map(|a| u32::from(*a)) + .chain([net, broadcast]) + .collect(); + let mut out = Vec::new(); + let mut off = start_off; + while off <= cap_off && out.len() < MAX_CANDIDATES { + let Some(addr) = first.checked_add(off) else { break }; + if addr < broadcast && !reserved_set.contains(&addr) { + out.push(Ipv4Addr::from(addr)); + } + off += 1; + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -481,4 +620,61 @@ mod tests { let ifaces = vec![iface("lo", true, true, true, true)]; assert!(resolve_interface(None, &ifaces).is_err()); } + + #[test] + fn nfs_candidates_24_default_starts_at_213() { + let c = nfs_ip_candidates(Ipv4Addr::new(192, 168, 1, 0), 24, &[]); + assert_eq!(c[0], Ipv4Addr::new(192, 168, 1, 213), "default for /24 is .213"); + assert_eq!(c[1], Ipv4Addr::new(192, 168, 1, 214), "scans upward, contiguous"); + assert!(c.len() <= 16, "returns at most a few candidates"); + assert!(c.iter().all(|a| *a <= Ipv4Addr::new(192, 168, 1, 242)), + "never above the 95% cap (.242 on a /24)"); + } + + #[test] + fn nfs_candidates_skip_reserved() { + let res = [Ipv4Addr::new(192, 168, 1, 213), Ipv4Addr::new(192, 168, 1, 214)]; + let c = nfs_ip_candidates(Ipv4Addr::new(192, 168, 1, 0), 24, &res); + assert_eq!(c[0], Ipv4Addr::new(192, 168, 1, 215), "first free after reserved"); + } + + #[test] + fn nfs_candidates_respect_95pct_cap() { + // Reserve the whole band below .242 → only .242 remains, and .243+ are + // never offered (the 95% hard cap). + let res: Vec<_> = (213u8..=241).map(|h| Ipv4Addr::new(192, 168, 1, h)).collect(); + let c = nfs_ip_candidates(Ipv4Addr::new(192, 168, 1, 0), 24, &res); + assert_eq!(c, vec![Ipv4Addr::new(192, 168, 1, 242)]); + } + + #[test] + fn nfs_candidates_normalize_and_edge_prefixes() { + // Host bits in `network` are ignored (normalized to the network address). + let c = nfs_ip_candidates(Ipv4Addr::new(10, 0, 0, 77), 24, &[]); + assert_eq!(c[0], Ipv4Addr::new(10, 0, 0, 213)); + // /31, /32, /0 have no usable host band. + assert!(nfs_ip_candidates(Ipv4Addr::new(10, 0, 0, 0), 31, &[]).is_empty()); + assert!(nfs_ip_candidates(Ipv4Addr::new(10, 0, 0, 0), 32, &[]).is_empty()); + assert!(nfs_ip_candidates(Ipv4Addr::new(10, 0, 0, 0), 0, &[]).is_empty()); + } + + #[test] + fn classify_open_error_detects_permission() { + // macOS BPF and Linux raw-socket permission messages map to the prompt. + for m in [ + "activate device 'en0': (cannot open BPF device) /dev/bpf0: Permission denied", + "libpcap error: socket: Operation not permitted", + "You don't have permission to capture on that device (socket: Operation not permitted)", + "open device 'eth0': EACCES", + ] { + assert_eq!(classify_open_error(m), PcapStatus::PermissionDenied, "{m}"); + } + // Non-permission failures stay a generic device error (no elevation UI). + for m in [ + "open device 'wlan9': No such device exists", + "activate device 'en0': BIOCSETIF: Device not configured", + ] { + assert_eq!(classify_open_error(m), PcapStatus::DeviceError, "{m}"); + } + } } diff --git a/src/seeq8003.rs b/src/seeq8003.rs index 84f40dd..5eca7c6 100644 --- a/src/seeq8003.rs +++ b/src/seeq8003.rs @@ -145,7 +145,14 @@ pub trait SeeqCallback: Send + Sync { } // ── Ring-buffer capacity (number of frames) ─────────────────────────────────── -const CHAN_CAPACITY: usize = 32; +const CHAN_CAPACITY: usize = 256; + +// Max RX frames the enet thread drains from the ring per loop iteration before +// yielding to TX/interrupt work. Lets it clear a burst of address-filtered +// chatter (a busy LAN in promiscuous PCAP mode floods the ring with frames the +// guest rejects) in one pass, so chatter can't pin the ring full and starve the +// guest's own frames. Bounded so TX and interrupt servicing still run. +const RX_DRAIN_CAP: usize = 256; // ── Main device struct ──────────────────────────────────────────────────────── pub struct Seeq8003 { @@ -190,6 +197,7 @@ enum TxPumpResult { /// Result of an RX pump: applied under SeeqState lock. enum RxPumpResult { Nothing, + Filtered, // frame rejected by address_filter (not for the guest) and discarded Refused, Delivered { dma_irq: bool, writeback: Option<(u32, u16)>, frame_len: usize }, } @@ -359,7 +367,7 @@ impl Seeq8003 { if !Self::address_filter(rx_cmd_snap, &station_addr_snap, frame) { dlog_dev!(LogModule::Seeq, "SEEQ pump_rx: address filter dropped {}", eth_summary(frame)); let _ = rx_cons.pop(); // discard filtered frame - return RxPumpResult::Nothing; + return RxPumpResult::Filtered; } dlog_dev!(LogModule::Seeq, "SEEQ RX {} → guest DMA", eth_summary(frame)); @@ -636,8 +644,20 @@ impl Device for Seeq8003 { Self::pump_tx(dma, &mut tx_prod, &tx_wake_enet, &in_reset_enet) } else { TxPumpResult::Nothing }; + // Drain the RX ring: discard leading chatter (frames the guest's + // address_filter rejects) cheaply so a busy LAN can't pin the ring + // full and starve the guest's own frames, then handle at most one + // for-guest frame this iteration (one delivery per interrupt). + // Bounded by RX_DRAIN_CAP so TX and interrupt servicing still run. let rx_result = if let Some(ref dma) = rx_dma { - Self::pump_rx(dma, &mut rx_cons, rx_cmd_snap, station_addr_snap, &in_reset_enet) + let mut r = RxPumpResult::Nothing; + for _ in 0..RX_DRAIN_CAP { + match Self::pump_rx(dma, &mut rx_cons, rx_cmd_snap, station_addr_snap, &in_reset_enet) { + RxPumpResult::Filtered => continue, // chatter discarded; keep draining + other => { r = other; break; } // Delivered / Refused / Nothing + } + } + r } else { RxPumpResult::Nothing }; // Now take SeeqState lock once to apply all results atomically. @@ -676,7 +696,9 @@ impl Device for Seeq8003 { RxPumpResult::Refused => { dlog_dev!(LogModule::Seeq, "[ts={}] SEEQ RX DMA refused, retrying next tick", st.ts); } - RxPumpResult::Nothing => {} + // Filtered never reaches here (the drain loop consumes it), but + // keep the match exhaustive over RxPumpResult. + RxPumpResult::Nothing | RxPumpResult::Filtered => {} } // Only call raise_interrupt if something actually happened this iteration.