diff --git a/README.md b/README.md index 95c92b6..59a454e 100644 --- a/README.md +++ b/README.md @@ -26,20 +26,20 @@ you can "cook" your containerized application into a portable single-file app, m | Capability | Linux | Windows | macOS | |---|---|---|---| -| **Run the single-file app** | ✅ rootless user namespaces, zero dependencies | ✅ WSL2 — a minimal Chefer-dedicated distro is auto-provisioned for the run and best-effort removed when the app exits; **or ✅ WHP (no WSL needed)** — boots a bundle-embedded Linux micro-VM via the Windows Hypervisor Platform (validated on real hardware) | 🔧 Virtualization.framework micro-VM; guest path validated on Linux+QEMU, real-Mac VZ boot pending | +| **Run the single-file app** | ✅ rootless user namespaces, zero dependencies | ✅ WSL2 — a minimal Chefer-dedicated distro is auto-provisioned for the run and best-effort removed when the app exits; **or ✅ WHP (no WSL needed)** — boots a bundle-embedded Linux micro-VM via the Windows Hypervisor Platform (validated on real hardware) | ✅ Virtualization.framework micro-VM — validated on real Apple Silicon (still opt-in via `CHEFER_VZ_EXPERIMENTAL=1`) | | **Backend** | `namespaces` (in-process guest-agent) | `wsl2` (preferred when present) → `whp` (no-WSL micro-VM; virtio-blk + virtio-net over MMIO) | `vz` (Linux appliance + musl guest-agent) | -| **Multi-service apps** (`depends_on` topo order + optional health checks) | ✅ | ✅ | 🔧 | -| **Data persistence** (`persist_path` → host dir, survives restarts) | ✅ | ✅ verified | 🔧 | -| **Internal networking** (services reach each other via `127.0.0.1:`) | ✅ | ✅ | 🔧 | -| **Host port mapping — TCP** (`"host:guest"`, host≠guest proxied) | ✅ | ✅ verified | 🔧 | -| **Host port mapping — UDP** | ✅ | ✅ verified — Chefer relays via the VM IP + an in-VM eth0→loopback bridge (WSL2's own forwarding is TCP-only) | 🔧 | -| **GUI services** | ✅ X11 / Wayland socket passthrough | ✅ WSL2 via WSLg ([gui-demo](examples/gui-demo)); **✅ WHP (no WSL)** — bundle-embedded `cage` compositor on a virtio-gpu scanout shown in a **resizable** native window, with keyboard/mouse + bidirectional clipboard (text validated bidirectionally on real hardware; PNG images via the `PNG` format / `CF_DIB`, host read/convert path verified via `--clip-selftest`; resizing the window scales the picture — the resolution-change signal reaches the guest driver, verified on real hardware, but the `cage` kiosk compositor keeps its native mode, so there's no true guest re-modeset) | 🔧 host side written (`--gui`: `VZVirtualMachineView` window + HID + NSPasteboard clipboard, same guest path as WHP); compiles/signs on CI, real-Mac VZ validation pending | -| **`crash: fail_fast`** (any non-zero exit tears down the app, code propagated) | ✅ | ✅ verified | 🔧 | -| **Data-dir migration** (`old_names`) | ✅ | ✅ | 🔧 | -| **Official chown/gosu images** (redis, postgres, …) | ✅ as root (no userns); ✅ rootless via `/etc/subuid` + `newuidmap` (falls back to single-uid without them) | ✅ verified — distro runs as real root, runs official redis/postgres as-is | 🔧 | +| **Multi-service apps** (`depends_on` topo order + optional health checks) | ✅ | ✅ | ✅ verified | +| **Data persistence** (`persist_path` → host dir, survives restarts) | ✅ | ✅ verified | ✅ verified (virtiofs write-back; counter survives restarts) | +| **Internal networking** (services reach each other via `127.0.0.1:`) | ✅ | ✅ | ✅ verified | +| **Host port mapping — TCP** (`"host:guest"`, host≠guest proxied) | ✅ | ✅ verified | ✅ verified — the vz backend relays `127.0.0.1:host → :guest` itself (the cross-platform runtime proxy is skipped on macOS) | +| **Host port mapping — UDP** | ✅ | ✅ verified — Chefer relays via the VM IP + an in-VM eth0→loopback bridge (WSL2's own forwarding is TCP-only) | ✅ verified (same VM-IP relay + in-VM bridge) | +| **GUI services** | ✅ X11 / Wayland socket passthrough | ✅ WSL2 via WSLg ([gui-demo](examples/gui-demo)); **✅ WHP (no WSL)** — bundle-embedded `cage` compositor on a virtio-gpu scanout shown in a **resizable** native window, with keyboard/mouse + bidirectional clipboard (text validated bidirectionally on real hardware; PNG images via the `PNG` format / `CF_DIB`, host read/convert path verified via `--clip-selftest`; resizing the window scales the picture — the resolution-change signal reaches the guest driver, verified on real hardware, but the `cage` kiosk compositor keeps its native mode, so there's no true guest re-modeset) | ✅ validated on real Apple Silicon — `VZVirtualMachineView` window + the same in-guest `cage` path as WHP (compositor up 8/8 boots); clipboard verified (text both ways; PNG guest→host byte-exact, host→guest wire-confirmed). Resolution: the **initial** guest mode matches the window's *pixel* size (Retina 2× backing; `CHEFER_VZ_GUI_SIZE` honored — verified 1280×800→2560×1600 and 1024×600→2048×1200), but **live drag-resize only scales the picture** — same `cage` kiosk-compositor limitation as WHP (the compositor doesn't follow mode changes after startup; verified on real hardware: guest stayed 2560×1600 across window drags). Interactive HID / close-window remain hand-checks (`scripts/vz-smoke.sh --gui`). Caveat: the clipboard channel is sometimes silently delayed — suspected macOS Local Network privacy gating of the ad-hoc-signed helper | +| **`crash: fail_fast`** (any non-zero exit tears down the app, code propagated) | ✅ | ✅ verified | ✅ verified (exit 7 propagated; interface-service exit-0 teardown too) | +| **Data-dir migration** (`old_names`) | ✅ | ✅ | ✅ | +| **Official chown/gosu images** (redis, postgres, …) | ✅ as root (no userns); ✅ rootless via `/etc/subuid` + `newuidmap` (falls back to single-uid without them) | ✅ verified — distro runs as real root, runs official redis/postgres as-is | ✅ (same real-root guest path as WHP) | | **`windows/*` containers** | ❌ | ❌ | ❌ | -Legend: ✅ implemented & exercised in CI / on a real machine · 🔧 implemented, guest path QEMU-verified, awaiting real-Apple-Silicon validation · ⚠️ works with a documented caveat · ❌ not supported. +Legend: ✅ implemented & exercised in CI / on a real machine · ⚠️ works with a documented caveat · ❌ not supported. Linux containers only — `linux/amd64` and `linux/arm64`. `windows/*` images are rejected at validation on every host. @@ -183,7 +183,7 @@ single executable ── user double-clicks ──> chefer-runtime └─ pick platform backend: Linux → rootless namespaces (in-process) Windows → WSL2 (auto-provisioned minimal distro) → WHP (no-WSL Linux micro-VM via Windows Hypervisor Platform: virtio-blk bundle/data, virtio-net TCP/UDP port forwarding, outbound NAT) - macOS → Virtualization.framework micro-VM (appliance/QEMU verified first; real-Mac VZ validation pending) + macOS → Virtualization.framework micro-VM (validated on real Apple Silicon; opt-in via CHEFER_VZ_EXPERIMENTAL=1) │ ▼ guest-agent: assemble rootfs from layers (whiteouts), @@ -263,6 +263,6 @@ Honest list of what doesn't work (yet): - [~] **Faster app startup** — *partly done.* The single file now **caches its extracted bundle in a content-hashed dir** (keyed on the payload sha256): the first run decompresses+verifies once, and **subsequent runs skip extraction entirely** (no re-open, re-decompress, or re-hash of the payload) — the big win for large images launched repeatedly. (`--no-cache` forces fresh temp extraction.) The guest-agent's per-service rootfs assembly is cached, **decompresses layers in parallel** on first assembly (a worker pool decompresses ahead while layers are still applied strictly in order, preserving overlay/whiteout semantics — the pure-Rust musl `ruzstd` decode is CPU-bound), and **on root backends (WSL2 / macOS VM / native-root) uses overlayfs**: each layer is extracted once into its own content-addressed read-only lowerdir (shared/deduped across services and images, OCI whiteouts converted to overlay whiteouts), then mounted as an overlay with a per-run writable upper — no merge-copy, instant mount, layers shared. Rootless native Linux keeps the merged path (rootless can't create overlay whiteout devices). Windows WSL distros are now best-effort removed when the app exits, so WSL import remains a per-run cost unless `CHEFER_KEEP_WSL_DISTRO=1` is set for local debugging or repeated-launch profiling. **GUI apps on the WHP/VZ micro-VM** now ship their `cage`/Xwayland/Mesa **overlay as a read-only squashfs that is mounted (via overlayfs) instead of decompressed** — the VM is fresh every boot and can't cache across runs, so this removes a ~200 MB per-launch unpack from every GUI start. - [x] **`depends_on` health checks** (wait-until-ready), not just start order — a service with `healthcheck` is polled inside its container namespaces until healthy before later services start; services without one are considered ready once spawned. - [x] **Rootless Linux support for chown/gosu images** via `newuidmap` + `/etc/subuid` delegation — when the host has the `uidmap` tools and subuid/subgid ranges for the user (most distros ship both), rootless services get a full uid-range mapping (rootless-Podman-style) and images that `chown`/`gosu` to a dedicated uid (official redis/postgres) run as-is; verified end-to-end with the official `redis` image as a non-root user. Falls back to the old single-uid map when delegation is unavailable. (The root backends — WSL2 / macOS VM / native-root — never needed this.) -- [ ] **macOS VZ boot validated on real Apple Silicon** — the guest path is already QEMU-verified; the host Virtualization.framework shim needs bare-metal hardware to certify. (Includes the per-app netns isolation path inside the VM, which already runs the same guest-agent code.) Note: VZ is virtualization, not emulation — guest arch must match host (Apple Silicon → `linux/arm64`, Intel → `linux/amd64`). -- [~] **GUI apps on macOS** — *host side now implemented; end-to-end gated on real-Mac VZ validation.* The in-guest half (kiosk `cage` + Xwayland on a virtio-gpu scanout) is **already built and proven on the WHP path**, and the macOS host side is now written: `chefer-vz-helper --gui` adds a virtio-gpu scanout (1280×800 default, `CHEFER_VZ_GUI_SIZE=WxH` override) + USB keyboard/absolute-pointer devices and shows the VM in an AppKit window via `VZVirtualMachineView` (VZ feeds HID back automatically; closing the window ends the app, same semantics as WHP). The host↔guest **clipboard** (text + PNG, same token-authenticated wire protocol as WHP, host side on `NSPasteboard`) is implemented too, connecting directly to the guest IP over the VZ NAT, and on macOS 14+ the window is **resizable with dynamic guest resolution** (`VZVirtualMachineView.automaticallyReconfiguresDisplay`). All of it compiles + signs on CI (macOS runner `swiftc`), but a GitHub runner cannot boot a VZ guest — display/input/clipboard need a physical Mac to certify: run **`scripts/vz-smoke.sh [--gui]`** on one to knock out every pending assertion (boot, exit-code propagation, port forwarding, plus a GUI checklist) in one go. Design in [docs/DESIGN.md](docs/DESIGN.md). +- [x] **macOS VZ boot validated on real Apple Silicon** *(2026-07, Apple Silicon / macOS 26)* — `scripts/vz-smoke.sh` fully green on bare metal: VM boot + virtiofs + guest-agent, exit-code propagation, service supervision, host→guest **TCP and UDP** port relays, multi-service `depends_on` + in-app `127.0.0.1` networking, `persist_path` write-back across restarts, and default-`bridge` outbound (pasta → VZ NAT, DNS included). Real-hardware fixes that came out of it: tar-mode restore on extraction (bundled agents lost their exec bit), an arm64-appliance initramfs that shipped an x86-64 busybox (cross-arch `busybox-static` now fetched at build), the runtime port proxy double-binding against the vz relay, and Docker-parity `/etc/resolv.conf` injection into service containers (without it, bridge outbound had connectivity but no DNS). Note: VZ is virtualization, not emulation — guest arch must match host (Apple Silicon → `linux/arm64`, Intel → `linux/amd64`). The backend still requires `CHEFER_VZ_EXPERIMENTAL=1` — flipping it default-on is a separate decision. +- [x] **GUI apps on macOS** — *validated on real Apple Silicon (2026-07).* The in-guest half (kiosk `cage` + Xwayland on a virtio-gpu scanout) is shared with WHP; `chefer-vz-helper --gui` shows the VM in an AppKit window via `VZVirtualMachineView` (1280×800 default, `CHEFER_VZ_GUI_SIZE=WxH` override) with USB keyboard/absolute-pointer devices. Real-hardware results: the compositor came up **8/8 GUI boots** (the `WLR_LIBINPUT_NO_DEVICES` boot-race fix holds on VZ); X11 clients connect and render; the host↔guest **clipboard** works (text verified both ways; PNG guest→host byte-exact on `NSPasteboard`, host→guest wire-confirmed). Resolution: `automaticallyReconfiguresDisplay` (macOS 14+) does push the window's *pixel* size to the guest — the **initial** mode matches the window's Retina backing exactly (a 1280×800-point window yields a 2560×1600 guest mode; `CHEFER_VZ_GUI_SIZE=1024x600` yields 2048×1200) — but **live drag-resize only scales the picture**, same as WHP: the `cage` kiosk compositor doesn't follow mode changes after startup (verified on real hardware — guest stayed 2560×1600 across drags; the fix would be guest-side, e.g. teaching the compositor to re-modeset on virtio-gpu hotplug). Interactive checks that still need a human hand (`scripts/vz-smoke.sh --gui` checklist): typing/clicking into the guest, close-window-ends-app. Known caveat: the clipboard channel (helper → `:55381` over VZ NAT) sometimes sits in a silent `.waiting` state for up to a minute or never connects — suspected macOS Local Network privacy gating of the ad-hoc-signed helper; needs a stable signing identity and a visible hint in the helper. Design in [docs/DESIGN.md](docs/DESIGN.md). - [x] **GPU access / CUDA compute** — *opt-in `gpu: true`; validated end-to-end on both WSL2 and native NVIDIA Linux.* Set `gpu: true` on a service and Chefer binds the host's render/compute device nodes into that service's container — on **native Linux** `/dev/nvidia*` + `/dev/dri`; on the **Windows WSL2 backend** `/dev/dxg` + a bind of the host `/usr/lib/wsl/lib` **and `/usr/lib/wsl/drivers`** (its host-version-matched `libdxcore`/`libcuda`/… ) → CUDA/DirectML/OpenCL/Vulkan work with nothing extra in the image. The same passthrough also covers **NVENC/NVDEC video** and OpenCL/ROCm compute; **GL/Vulkan *rendering* does not work in a headless container** (see the native note below). **Validated end-to-end on real WSL2** (GeForce GT 1030) — the container sees `/dev/dxg` + `/usr/lib/wsl/lib` + `/usr/lib/wsl/drivers`, runs `nvidia-smi` with live telemetry, and executes a `nvcc`-built CUDA `vectorAdd` on the GPU (`sm_61`, 1048576 elems, 0 mismatches); binding `/usr/lib/wsl/lib` alone is not enough — its `libcuda`/`libnvidia-ml` are loader shims that dlopen the real driver from `/usr/lib/wsl/drivers`, so both must be bound (else `nvidia-smi` reports "Driver Not Loaded"). `/dev/dri` is bound when present (absent on some WSL kernels; CUDA uses `/dev/dxg`). It is a **clear startup error** on the non-WSL Windows (WHP) micro-VM and the macOS VM — a bare WHP/VZ VM can't tap Microsoft/Apple GPU paravirtualization, so GPU compute stays **native-Linux + WSL2 only**. **Native Linux** — host-driver-version-matched userspace lib injection (à la the NVIDIA Container Toolkit) reads the driver version from `/proc/driver/nvidia/version` and binds the matching NVIDIA driver libs (`libcuda`/`libnvidia-*`/`libnvcuvid`/`libnvoptix`/GL, matching `nvidia-container-cli list`) into `/run/chefer-nvidia` (versioned + real DT_SONAME + `.so`) plus `LD_LIBRARY_PATH`. **Validated on a GeForce RTX 4070** (driver 570.211.01): a `nvcc`-built CUDA vectorAdd from a stock `nvidia/cuda` image that ships no libcuda ran on the GPU (`sm_89`, 1048576 elems, 0 mismatches), and `ffmpeg -c:v h264_nvenc` encoded in-container (NVENC, exit 0). **OpenGL/Vulkan *rendering* does not work in a headless container**, though: NVIDIA's Vulkan ICD (`libGLX_nvidia` → `libnvidia-glcore`) needs a real graphics/X environment (it references Xorg's `ErrorF` and glibc-2.34-removed `__malloc_hook`), so it falls back to `llvmpipe` — and `docker --gpus all` with `NVIDIA_DRIVER_CAPABILITIES=all` (the nvidia-container-toolkit reference) fails identically on the same headless host, so this is a host/driver limitation, not a Chefer one (Chefer's GUI path renders via X11/Wayland socket passthrough + software rasterization instead). No path to Apple NPU / CoreML from a Linux guest. diff --git a/crates/chefer-runtime/src/extract.rs b/crates/chefer-runtime/src/extract.rs index 7b76b2e..1141173 100644 --- a/crates/chefer-runtime/src/extract.rs +++ b/crates/chefer-runtime/src/extract.rs @@ -281,6 +281,18 @@ fn unpack_tar(reader: R, dest: &Path) -> Result<()> { .with_context(|| format!("failed to create file: {}", out.display()))?; io::copy(&mut entry, &mut file) .with_context(|| format!("failed to write file: {}", out.display()))?; + // 還原 tar header 的 mode:bundle/agents/ 下的 helper/guest-agent/pasta + // 是 0o755,macOS host 要直接 spawn(vz-helper)、guest 也經 virtiofs 執行, + // 不還原就 EACCES。Windows 無 POSIX mode,略過。 + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = entry.header().mode().unwrap_or(0o644) & 0o777; + file.set_permissions(std::fs::Permissions::from_mode(mode)) + .with_context(|| { + format!("failed to set permissions on: {}", out.display()) + })?; + } } other => { bail!( @@ -421,6 +433,64 @@ mod tests { assert!(!bundle_dir.exists(), "未指定 --keep-tmp 時應自動刪除暫存"); } + /// Unix:解壓須還原 tar header 的 mode——bundle/agents/ 的 0o755 掉了會讓 + /// macOS host spawn vz-helper/guest 執行 guest-agent 直接 EACCES。 + #[cfg(unix)] + #[test] + fn extract_restores_exec_mode() { + use std::os::unix::fs::PermissionsExt; + + let mut tar_buf = Vec::new(); + { + let mut b = tar::Builder::new(&mut tar_buf); + for dir in ["bundle/", "bundle/agents/"] { + let mut h = tar::Header::new_gnu(); + h.set_entry_type(tar::EntryType::Directory); + h.set_mode(0o755); + h.set_size(0); + h.set_mtime(0); + b.append_data(&mut h, dir, io::empty()).unwrap(); + } + let mut h = tar::Header::new_gnu(); + h.set_entry_type(tar::EntryType::Regular); + h.set_mode(0o644); + h.set_size(2); + h.set_mtime(0); + b.append_data(&mut h, "bundle/manifest.json", &b"{}"[..]) + .unwrap(); + let mut h = tar::Header::new_gnu(); + h.set_entry_type(tar::EntryType::Regular); + h.set_mode(0o755); + h.set_size(5); + h.set_mtime(0); + b.append_data(&mut h, "bundle/agents/fake-agent", &b"#!bin"[..]) + .unwrap(); + b.finish().unwrap(); + } + let payload = zstd::stream::encode_all(&tar_buf[..], 3).unwrap(); + + let tmp = tempfile::tempdir().unwrap(); + let exe = tmp.path().join("fake-app.exe"); + let ft = write_fake_single(&exe, 16, &payload); + let parent = tmp.path().join("work"); + let opts = ExtractOptions { + extract_parent: Some(&parent), + keep_tmp: false, + }; + let extracted = extract_bundle(&exe, &ft, &opts).unwrap(); + + let agent_mode = fs::metadata(extracted.bundle_dir.join("agents/fake-agent")) + .unwrap() + .permissions() + .mode(); + assert_eq!(agent_mode & 0o777, 0o755, "agents/ 執行檔應還原 0o755"); + let manifest_mode = fs::metadata(extracted.bundle_dir.join("manifest.json")) + .unwrap() + .permissions() + .mode(); + assert_eq!(manifest_mode & 0o777, 0o644, "資料檔應為 0o644"); + } + #[test] fn keep_tmp_preserves_dir() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/chefer-runtime/src/proxy.rs b/crates/chefer-runtime/src/proxy.rs index 9f1aead..d1cac3a 100644 --- a/crates/chefer-runtime/src/proxy.rs +++ b/crates/chefer-runtime/src/proxy.rs @@ -29,6 +29,14 @@ use chefer_bundle::{Manifest, PortProto}; /// 任一 host 埠 bind 失敗(通常是埠已被占用)→ 啟動前整體報錯並列出埠號, /// 不會啟動任何服務。 pub fn start_port_proxies(manifest: &Manifest) -> Result<()> { + // macOS(vz):guest 埠不在 host localhost 上(VZ NAT 無 wslrelay 式自動轉送), + // host→guest 一律由 vz 後端取得 guest IP 後 relay `127.0.0.1:host → :guest` + // (TCP+UDP、含 host==guest)。在此綁 127.0.0.1:host 只會與後端的 relay 搶同一顆埠 + // (同 Windows UDP 的先例),故整個略過。見 DESIGN「埠代理」。 + if cfg!(target_os = "macos") { + tracing::debug!("port proxies are handled by the vz backend on macOS; skipping"); + return Ok(()); + } let mut failures: Vec = Vec::new(); for svc in &manifest.services { for spec in &svc.ports { diff --git a/crates/guest-agent/src/exec.rs b/crates/guest-agent/src/exec.rs index 8033bb8..fef0fbc 100644 --- a/crates/guest-agent/src/exec.rs +++ b/crates/guest-agent/src/exec.rs @@ -246,6 +246,37 @@ fn build_plan(spec: &SpawnSpec) -> Result { }); } + // DNS:容器 rootfs 沒有 Docker daemon 幫忙注入 /etc/resolv.conf(alpine 等基底 image + // 也不自帶),服務內任何 hostname 解析都會倒在預設的 127.0.0.1:53 → 直接失敗。 + // 比照 Docker:把執行環境(VM root / 原生 host)的 /etc/resolv.conf 注入容器。 + // 兩個坑決定了做法「fork 前實體化內容到 temp 檔,再 bind 那個檔」: + // - 不能 fork 前直接寫進 spec.rootfs——rootfs 的 overlay 是孫行程在自己的 mount ns + // 內才蓋上去的,先寫會被 overlay 遮住;bind 在 overlay 之後套用才可見。 + // - 不能直接 bind /etc/resolv.conf——VM 後端(vz/QEMU/WHP)的 appliance init 把它 + // symlink 到 /proc/net/pnp,而孫行程 bind 時已 setns 進 app netns,/proc/net 是 + // 該 netns 的視圖、pnp 不存在 → ENOENT。故在此(root netns)先讀出內容寫成一般檔案。 + // image 自帶的 resolv.conf 一律被蓋掉(通常是 build 環境殘留,如 Docker 內嵌的 + // 127.0.0.11);執行環境沒有該檔或內容為空時不注入、維持原狀。 + if let Ok(dns) = fs::read_to_string("/etc/resolv.conf") + && !dns.trim().is_empty() + { + let staged = + std::env::temp_dir().join(format!("chefer-resolv-{}-{}", std::process::id(), svc.name)); + match fs::write(&staged, &dns) { + Ok(()) => binds.push(BindEntry { + host: staged, + target: join_guest(spec.rootfs, "/etc/resolv.conf")?, + read_only: false, + is_dir: false, + }), + Err(e) => eprintln!( + "[guest-agent] service `{}`: could not stage /etc/resolv.conf for the container ({e}); \ + name resolution inside the service may fail", + svc.name + ), + } + } + // GPU passthrough(opt-in per-service `gpu: true`;見 docs/DESIGN.md「GPU passthrough」)。 // 只在能實際觸及 host GPU 的後端可行(原生 Linux / WSL2);WHP/vz VM 明確報錯, // 不靜默綁到 virtio-gpu 的 2D `/dev/dri`。探測到的節點/libs 以既有 bind 流程套用 diff --git a/crates/vmm-backend/src/whp_util.rs b/crates/vmm-backend/src/whp_util.rs index c6a011e..ea30d59 100644 --- a/crates/vmm-backend/src/whp_util.rs +++ b/crates/vmm-backend/src/whp_util.rs @@ -52,9 +52,15 @@ pub fn helper_in_bundle(bundle_dir: &Path, host_arch: &str) -> PathBuf { /// 網路:用 kernel IP_PNP **靜態**設定 eth0(`10.0.2.15/24`、gateway `10.0.2.2`), /// 不走 DHCP——helper 的 smoltcp gateway 不提供 DHCP server。此 IP 與 gateway 必須與 /// `whp-helper` 的 `NET_GUEST_IP`/`NET_GATEWAY_IP` 一致(host↔guest 網路契約)。 +/// +/// `ip=` 第 8 欄(dns0)帶約定 DNS `10.0.2.3`(slirp 慣例位址;helper 在 +/// `net_backend::NAT_DNS_IP` 提供轉發):kernel ipconfig 對靜態設定**也會**建 +/// `/proc/net/pnp`(無 dns0 時內容只有 `#MANUAL`、無 nameserver 行),dns0 讓它多出 +/// `nameserver 10.0.2.3` → appliance init 的 resolv.conf symlink + guest-agent 的 +/// 容器注入整條沿用 vz/QEMU 的既有鏈(見 docs/DESIGN.md「容器內 DNS」)。 pub fn kernel_command_line(keep_rootfs: bool) -> String { let mut s = String::from( - "console=ttyS0 quiet ip=10.0.2.15::10.0.2.2:255.255.255.0::eth0:off \ + "console=ttyS0 quiet ip=10.0.2.15::10.0.2.2:255.255.255.0::eth0:off:10.0.2.3 \ panic=-1 nolapic lpj=1000000 notsc clocksource=jiffies", ); s.push_str(&format!( @@ -237,8 +243,9 @@ mod tests { let cmdline = kernel_command_line(true); assert!(cmdline.contains("console=ttyS0")); - // WHP 用 kernel 靜態 IP(無 DHCP server),IP/gw 對齊 helper 的 net 契約。 - assert!(cmdline.contains("ip=10.0.2.15::10.0.2.2:255.255.255.0::eth0:off")); + // WHP 用 kernel 靜態 IP(無 DHCP server),IP/gw 對齊 helper 的 net 契約; + // dns0=10.0.2.3(約定 DNS,helper 的 DNS pivot)讓 /proc/net/pnp 有 nameserver 行。 + assert!(cmdline.contains("ip=10.0.2.15::10.0.2.2:255.255.255.0::eth0:off:10.0.2.3")); assert!(!cmdline.contains("ip=dhcp")); assert!(cmdline.contains("nolapic")); assert!(cmdline.contains("lpj=1000000")); diff --git a/crates/whp-helper/Cargo.toml b/crates/whp-helper/Cargo.toml index 0202e21..1f6364a 100644 --- a/crates/whp-helper/Cargo.toml +++ b/crates/whp-helper/Cargo.toml @@ -25,6 +25,8 @@ windows-sys = { version = "0.61", features = [ "Win32_Foundation", "Win32_System_LibraryLoader", "Win32_System_Memory", + # 容器內 DNS(10.0.2.3 pivot):GetNetworkParams 取 host 設定的 DNS server 當轉發上游。 + "Win32_NetworkManagement_IpHelper", # GUI 顯示視窗(M8-c):視窗類別/訊息迴圈、GDI blit(StretchDIBits)。 "Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", diff --git a/crates/whp-helper/src/main.rs b/crates/whp-helper/src/main.rs index deeab8a..8be27e2 100644 --- a/crates/whp-helper/src/main.rs +++ b/crates/whp-helper/src/main.rs @@ -267,7 +267,9 @@ mod whp_api { // vdb(data)image 容量上限(host RAM 內的 backing;env CHEFER_WHP_DATA_MIB 覆寫,預設 256MiB)。 // 必須 ≥ guest 關機 re-tar data_dir 後的 tar 大小,否則回寫 IOERR。 const VIRTIO_DATA_DEFAULT_MIB: u64 = 256; - // user-mode 網路:smoltcp gateway 10.0.2.2、guest 靜態 10.0.2.15/24(與 appliance init 約定)。 + // user-mode 網路:smoltcp gateway 10.0.2.2、guest 靜態 10.0.2.15/24(與 appliance init 約定); + // 約定 DNS 10.0.2.3:53(net_backend::NAT_DNS_IP,kernel cmdline dns0 指向它, + // drain_tx 分流到 DNS pivot 轉發 host 設定的 DNS)。 const NET_GATEWAY_MAC: [u8; 6] = [0x52, 0x54, 0x00, 0x00, 0x00, 0x02]; const NET_GATEWAY_IP: [u8; 4] = [10, 0, 2, 2]; const NET_GUEST_MAC: [u8; 6] = [0x52, 0x54, 0x00, 0x12, 0x34, 0x56]; @@ -1105,17 +1107,28 @@ mod whp_api { ); } // 出網分流: + // - 約定 DNS `10.0.2.3:53`:UDP → DNS pivot(轉發 host 設定的 DNS + // 上游、回程 masquerade);TCP SYN → 同 pivot 的 TCP fallback + // (截斷回應時 resolver 改走 TCP:53)。須在 is_external_dst 之前 + // 分流——10.0.2.3 在 guest 網段內、不算外部。 // - 外部 dst 的 TCP:仍交給 smoltcp(出網 TCP 由 smoltcp 動態 dst 處理); // 但 SYN 先 nat_tcp_syn 預註冊 dst+listen socket+背景 connect host。 // - 外部 dst 的 UDP:手工 NAT(不交 smoltcp,smoltcp 不轉發)。 // - 其餘(ARP、guest↔gateway、同網段、multicast/bcast)照舊餵 smoltcp。 + use super::virtio::net_backend::NAT_DNS_IP; if let Some(t) = super::virtio::nat::parse_tcp(&frame) { - if is_external_dst(t.dst_ip) && t.syn && !t.ack { - self.backend.nat_tcp_syn(t); + if t.syn && !t.ack { + if t.dst_ip == NAT_DNS_IP && t.dst_port == 53 { + self.backend.nat_tcp_dns_syn(t); + } else if is_external_dst(t.dst_ip) { + self.backend.nat_tcp_syn(t); + } } self.phy.push_from_guest(frame); } else if let Some(udp) = super::virtio::nat::parse_udp(&frame) { - if is_external_dst(udp.dst_ip) { + if udp.dst_ip == NAT_DNS_IP && udp.dst_port == 53 { + self.backend.nat_outbound_dns(udp); + } else if is_external_dst(udp.dst_ip) { self.backend.nat_outbound(udp); } else { self.phy.push_from_guest(frame); diff --git a/crates/whp-helper/src/virtio/net_backend.rs b/crates/whp-helper/src/virtio/net_backend.rs index cc7c552..923e256 100644 --- a/crates/whp-helper/src/virtio/net_backend.rs +++ b/crates/whp-helper/src/virtio/net_backend.rs @@ -29,6 +29,17 @@ use smoltcp::wire::{ /// 出網 host TCP connect 的逾時(背景執行緒,不阻塞 VM run loop)。 const NAT_TCP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +/// 「約定 DNS」位址(slirp 慣例 `10.0.2.3`;QEMU 路徑由 slirp 原生在同位址提供 DNS, +/// WHP 與之對齊)。guest 的 resolv.conf(kernel cmdline `ip=` 的 dns0 欄,見 +/// `vmm-backend::whp_util::kernel_command_line`)指向這裡;接線層(main.rs drain_tx)把 +/// 送到此位址 UDP/TCP:53 的查詢分流給 [`NetBackend::nat_outbound_dns`] / +/// [`NetBackend::nat_tcp_dns_syn`] 轉發到 host 設定的 DNS 上游。 +pub const NAT_DNS_IP: [u8; 4] = [10, 0, 2, 3]; + +/// DNS 上游(host 設定的 DNS server)快取壽命:網路切換(VPN 連上/斷開)會改 host DNS, +/// 長駐 app 不能只在啟動時探一次;也不宜每個查詢都呼叫 Windows API。 +const DNS_UPSTREAM_TTL: Duration = Duration::from_secs(60); + /// 乙太網 MTU(virtio-net 預設)。 pub const MTU: usize = 1500; @@ -139,6 +150,9 @@ pub struct NetBackend { /// 出網 TCP NAT:per 4-tuple flow(smoltcp socket ↔ host TcpStream)。 tcp_nat: Vec, next_local_port: u16, + /// `10.0.2.3:53` DNS pivot 的上游(host 設定的 DNS server);lazy 探測 + TTL 快取。 + dns_upstreams: Vec, + dns_upstreams_at: Option, } struct Conn { @@ -155,13 +169,20 @@ struct UdpSession { forward_idx: usize, } -/// 出網 UDP NAT 的一條 flow:以 guest 的 `(ip, port)` 為鍵,持有一個 host UDP socket -/// 對外送/收。回程以 socket `recv_from` 得到的外部位址當合成 frame 的 src。 +/// 出網 UDP NAT 的一條 flow:以 guest 的 `(ip, port, dns)` 為鍵,持有一個 host UDP socket +/// 對外送/收。一般 flow 回程以 socket `recv_from` 得到的外部位址當合成 frame 的 src; +/// DNS pivot flow(guest → `10.0.2.3:53`)回程只收 `upstreams` 內的來源,且 src 一律 +/// masquerade 成 `NAT_DNS_IP:53`——guest 端 resolver 多用 connected UDP socket,回程 +/// src 不是它查的位址會被 kernel 丟掉。 struct NatFlow { guest_ip: [u8; 4], guest_port: u16, guest_mac: [u8; 6], sock: UdpSocket, + /// DNS pivot flow;與一般 flow 分鍵(同一 guest src port 可能同時查外部 DNS 與 10.0.2.3)。 + dns: bool, + /// DNS pivot 的 fanout 上游(一般 flow 為空)。 + upstreams: Vec, } /// 出網 TCP NAT 的 host 端連線狀態。 @@ -195,6 +216,11 @@ impl NetBackend { let mut iface = Interface::new(config, phy, Instant::from_millis(0)); iface.update_ip_addrs(|addrs| { let _ = addrs.push(IpCidr::new(gateway_ip.into(), 24)); + // 約定 DNS 位址(同網段、on-link):guest/pasta 送查詢前會對它 ARP,而 smoltcp + // 只回答自己持有的位址——掛上 /32 讓 ARP 有回應。實際的 :53 流量不進 smoltcp + // socket,由接線層 drain_tx 分流到 nat_outbound_dns / nat_tcp_dns_syn。 + let dns = Ipv4Address::new(NAT_DNS_IP[0], NAT_DNS_IP[1], NAT_DNS_IP[2], NAT_DNS_IP[3]); + let _ = addrs.push(IpCidr::new(IpAddress::Ipv4(dns), 32)); }); Self { iface, @@ -208,6 +234,8 @@ impl NetBackend { nat_flows: Vec::new(), tcp_nat: Vec::new(), next_local_port: 49152, + dns_upstreams: Vec::new(), + dns_upstreams_at: None, } } @@ -261,6 +289,23 @@ impl NetBackend { /// 預註冊——dst IP 動態加進 iface、建 smoltcp listen socket(dst,port)、背景 connect host。 /// 重傳 SYN(同 4-tuple 已有 flow)則略過。 pub fn nat_tcp_syn(&mut self, t: super::nat::OutboundTcp) { + self.nat_tcp_syn_to(t, SocketAddr::from((t.dst_ip, t.dst_port))); + } + + /// DNS pivot 的 TCP fallback(guest → `10.0.2.3:53` 的 SYN):resolver 收到截斷(TC) + /// 回應時會改走 TCP:53(glibc/node/go 都會;musl 不會)。guest 側照常對 10.0.2.3:53 + /// 完成 smoltcp 握手,host 側改連第一個 DNS 上游。 + pub fn nat_tcp_dns_syn(&mut self, t: super::nat::OutboundTcp) { + let Some(up) = self.dns_upstreams().first().copied() else { + return; + }; + self.nat_tcp_syn_to(t, up); + } + + /// [`Self::nat_tcp_syn`] 的實體:guest 可見的連線對象是 `t.dst`(smoltcp 在該位址 + /// listen),host 端實際連 `connect_to`(一般出網兩者相同;DNS pivot 時 dst=10.0.2.3 + /// 而 connect_to=真正的 DNS 上游)。 + fn nat_tcp_syn_to(&mut self, t: super::nat::OutboundTcp, connect_to: SocketAddr) { if self.tcp_nat.iter().any(|f| { f.guest_ip == t.src_ip && f.guest_port == t.src_port @@ -290,9 +335,11 @@ impl NetBackend { } let handle = self.sockets.add(sock); let (tx, rx) = mpsc::channel(); - let dst = SocketAddr::from((t.dst_ip, t.dst_port)); std::thread::spawn(move || { - let _ = tx.send(TcpStream::connect_timeout(&dst, NAT_TCP_CONNECT_TIMEOUT)); + let _ = tx.send(TcpStream::connect_timeout( + &connect_to, + NAT_TCP_CONNECT_TIMEOUT, + )); }); if net_trace_enabled() { let d = t.dst_ip; @@ -325,11 +372,14 @@ impl NetBackend { if ok { return true; } - // 滿了:找一個目前無 flow 使用的 /32 dst 驅逐。 + // 滿了:找一個目前無 flow 使用的 /32 dst 驅逐(約定 DNS 位址是常駐的,永不驅逐)。 let evict = self.iface.ip_addrs().iter().copied().find(|c| { c.prefix_len() == 32 && match c.address() { - IpAddress::Ipv4(a) => !self.tcp_nat.iter().any(|f| f.dst_ip == a.octets()), + IpAddress::Ipv4(a) => { + a.octets() != NAT_DNS_IP + && !self.tcp_nat.iter().any(|f| f.dst_ip == a.octets()) + } #[allow(unreachable_patterns)] _ => false, } @@ -345,8 +395,9 @@ impl NetBackend { } /// flow 關閉後釋放其 dst IP(若無其他 flow 再用該 dst)。須在 flow 已自 tcp_nat 移除後呼叫。 + /// 約定 DNS 位址常駐(ARP 應答依賴它),即使是 DNS TCP fallback 的 flow 關閉也不釋放。 fn release_dst_addr(&mut self, dst_ip: [u8; 4]) { - if self.tcp_nat.iter().any(|f| f.dst_ip == dst_ip) { + if dst_ip == NAT_DNS_IP || self.tcp_nat.iter().any(|f| f.dst_ip == dst_ip) { return; } let x = Ipv4Address::new(dst_ip[0], dst_ip[1], dst_ip[2], dst_ip[3]); @@ -413,52 +464,120 @@ impl NetBackend { let idx = match self .nat_flows .iter() - .position(|f| f.guest_ip == udp.src_ip && f.guest_port == udp.src_port) + .position(|f| !f.dns && f.guest_ip == udp.src_ip && f.guest_port == udp.src_port) { Some(i) => i, None => { - let sock = match UdpSocket::bind((std::net::Ipv4Addr::UNSPECIFIED, 0)) { - Ok(s) => s, - Err(e) => { - if net_trace_enabled() { - eprintln!("[whp-net-trace] nat: host udp bind failed: {e}"); - } - return; - } + let Some(i) = self.new_nat_flow(&udp, false) else { + return; }; - let _ = sock.set_nonblocking(true); - if net_trace_enabled() { - let s = udp.src_ip; - eprintln!( - "[whp-net-trace] nat: new outbound flow {}.{}.{}.{}:{}", - s[0], s[1], s[2], s[3], udp.src_port - ); - } - self.nat_flows.push(NatFlow { - guest_ip: udp.src_ip, - guest_port: udp.src_port, - guest_mac: udp.src_mac, - sock, - }); - self.nat_flows.len() - 1 + i } }; let dst = SocketAddr::from((udp.dst_ip, udp.dst_port)); let _ = self.nat_flows[idx].sock.send_to(&udp.payload, dst); } + /// 處理 guest 送往「約定 DNS」`10.0.2.3:53` 的查詢(接線層 drain_tx 分流後呼叫): + /// 把 payload 原樣 fanout 到 host 設定的所有 DNS 上游(首個回應者勝出——resolver 只取 + /// 第一個答案,多餘回應無害)。回程由 [`Self::nat_poll`] masquerade 成 `10.0.2.3:53`。 + pub fn nat_outbound_dns(&mut self, udp: super::nat::OutboundUdp) { + let upstreams = self.dns_upstreams(); + let idx = match self + .nat_flows + .iter() + .position(|f| f.dns && f.guest_ip == udp.src_ip && f.guest_port == udp.src_port) + { + Some(i) => i, + None => { + let Some(i) = self.new_nat_flow(&udp, true) else { + return; + }; + i + } + }; + for up in &upstreams { + let _ = self.nat_flows[idx].sock.send_to(&udp.payload, up); + } + // 上游快取可能已刷新(VPN 切換);flow 的回程過濾清單跟著更新。 + self.nat_flows[idx].upstreams = upstreams; + } + + /// 建一條出網 UDP NAT flow(bind host socket);bind 失敗回 None(該封包丟棄)。 + fn new_nat_flow(&mut self, udp: &super::nat::OutboundUdp, dns: bool) -> Option { + let sock = match UdpSocket::bind((std::net::Ipv4Addr::UNSPECIFIED, 0)) { + Ok(s) => s, + Err(e) => { + if net_trace_enabled() { + eprintln!("[whp-net-trace] nat: host udp bind failed: {e}"); + } + return None; + } + }; + let _ = sock.set_nonblocking(true); + if net_trace_enabled() { + let s = udp.src_ip; + let kind = if dns { "dns pivot" } else { "outbound" }; + eprintln!( + "[whp-net-trace] nat: new {kind} flow {}.{}.{}.{}:{}", + s[0], s[1], s[2], s[3], udp.src_port + ); + } + self.nat_flows.push(NatFlow { + guest_ip: udp.src_ip, + guest_port: udp.src_port, + guest_mac: udp.src_mac, + sock, + dns, + upstreams: Vec::new(), + }); + Some(self.nat_flows.len() - 1) + } + + /// DNS 上游(lazy 探測 + TTL 快取;見 [`discover_dns_upstreams`])。 + fn dns_upstreams(&mut self) -> Vec { + let stale = self + .dns_upstreams_at + .is_none_or(|t| t.elapsed() >= DNS_UPSTREAM_TTL); + if stale { + self.dns_upstreams = discover_dns_upstreams(); + self.dns_upstreams_at = Some(std::time::Instant::now()); + if net_trace_enabled() { + eprintln!("[whp-net-trace] dns: upstreams = {:?}", self.dns_upstreams); + } + } + self.dns_upstreams.clone() + } + + /// 測試用:直接設定 DNS 上游(並標記為新鮮,跳過探測)。 + #[cfg(test)] + fn set_dns_upstreams_for_test(&mut self, ups: Vec) { + self.dns_upstreams = ups; + self.dns_upstreams_at = Some(std::time::Instant::now()); + } + /// 把各 NAT flow 的 host socket 收到的外部回應合成 外部→guest 的 frame 注入 guest rx。 + /// DNS pivot flow:只收 fanout 過的上游來源(其餘丟棄),src masquerade 成 + /// `NAT_DNS_IP:53`(guest resolver 的 connected socket 才收得下)。 fn nat_poll(&mut self, phy: &mut VirtioNetPhy) { let mut buf = [0u8; 65535]; for f in &self.nat_flows { loop { match f.sock.recv_from(&mut buf) { Ok((n, SocketAddr::V4(from))) => { + let (ext_ip, ext_port) = if f.dns { + if !f.upstreams.contains(&SocketAddr::V4(from)) { + continue; // 非我們查詢的上游,丟棄 + } + (NAT_DNS_IP, 53) + } else { + (from.ip().octets(), from.port()) + }; let frame = super::nat::build_udp_reply( self.gateway_mac, f.guest_mac, - from.ip().octets(), - from.port(), + ext_ip, + ext_port, f.guest_ip, f.guest_port, &buf[..n], @@ -634,6 +753,88 @@ impl NetBackend { } } +/// 探測 DNS 上游,依序:`CHEFER_WHP_DNS`(逗號分隔 IPv4;測試/受限網路的明確覆寫)→ +/// Windows host 目前設定的 DNS(`GetNetworkParams`;跟著 VPN/公司網路走,等價 Docker +/// 讀 host resolv.conf)→ 都沒有才 fallback 公共 DNS(1.1.1.1/8.8.8.8,Docker 同款 +/// 行為)。fallback 不與 host DNS 並用:host DNS 存在時查詢不外流到第三方。 +fn discover_dns_upstreams() -> Vec { + if let Ok(v) = std::env::var("CHEFER_WHP_DNS") { + let list = parse_dns_override(&v); + if !list.is_empty() { + return list; + } + // 設了但解析不出任何位址 → 視同未設定,繼續探測。 + } + let mut list = host_dns_servers(); + // 保序去重 + 上限(GetNetworkParams 可能列多網卡重複項;fanout 不宜太寬)。 + let mut seen = Vec::new(); + list.retain(|a| { + let dup = seen.contains(a); + seen.push(*a); + !dup + }); + list.truncate(3); + if list.is_empty() { + list = vec![ + SocketAddr::from(([1, 1, 1, 1], 53)), + SocketAddr::from(([8, 8, 8, 8], 53)), + ]; + } + list +} + +/// 解析 `CHEFER_WHP_DNS`:逗號分隔的 IPv4(一律用 :53;無效項忽略)。 +fn parse_dns_override(v: &str) -> Vec { + v.split(',') + .filter_map(|s| s.trim().parse::().ok()) + .filter(|ip| !ip.is_unspecified()) + .map(|ip| SocketAddr::from((ip, 53))) + .collect() +} + +/// Windows host 目前設定的 DNS server(IPv4):IP Helper `GetNetworkParams` 的 +/// `DnsServerList`(系統層級、跨網卡的解析順序,正是「host 自己解名字會問誰」)。 +/// loopback 上游(本機跑 AdGuard 之類)也保留——helper 的 socket 在 host 端,連得到。 +#[cfg(windows)] +fn host_dns_servers() -> Vec { + use windows_sys::Win32::NetworkManagement::IpHelper::{FIXED_INFO_W2KSP1, GetNetworkParams}; + unsafe { + let mut len: u32 = 0; + // 第一次呼叫拿所需 buffer 大小(預期回 ERROR_BUFFER_OVERFLOW)。 + let _ = GetNetworkParams(std::ptr::null_mut(), &mut len); + if len == 0 { + return Vec::new(); + } + let mut buf = vec![0u8; len as usize]; + if GetNetworkParams(buf.as_mut_ptr().cast::(), &mut len) != 0 { + return Vec::new(); + } + let info = &*buf.as_ptr().cast::(); + let mut out = Vec::new(); + let mut node = std::ptr::addr_of!(info.DnsServerList); + while !node.is_null() { + // IP_ADDRESS_STRING 是以 NUL 結尾的點分十進位 ANSI 字串。 + let raw = + std::slice::from_raw_parts((*node).IpAddress.String.as_ptr().cast::(), 16); + let end = raw.iter().position(|&c| c == 0).unwrap_or(raw.len()); + if let Ok(txt) = std::str::from_utf8(&raw[..end]) + && let Ok(ip) = txt.parse::() + && !ip.is_unspecified() + { + out.push(SocketAddr::from((ip, 53))); + } + node = (*node).Next; + } + out + } +} + +/// 非 Windows(本 crate 的跨平台單元測試環境):無 host DNS 探測,交給上層 fallback。 +#[cfg(not(windows))] +fn host_dns_servers() -> Vec { + Vec::new() +} + #[cfg(test)] mod tests { use super::*; @@ -767,4 +968,199 @@ mod tests { } assert!(found, "NetBackend 應對 guest 發 ARP(UDP 埠轉發發起)"); } + + const GW_MAC: [u8; 6] = [0x52, 0x54, 0x00, 0x00, 0x00, 0x02]; + const GUEST_MAC: [u8; 6] = [0x52, 0x54, 0x00, 0x12, 0x34, 0x56]; + const GUEST_IP: [u8; 4] = [10, 0, 2, 15]; + + fn test_backend(phy: &mut VirtioNetPhy) -> NetBackend { + NetBackend::new( + GW_MAC, + Ipv4Address::new(10, 0, 2, 2), + Ipv4Address::new(10, 0, 2, 15), + phy, + ) + } + + /// 組一個 guest → 10.0.2.3:53 的查詢並解析成 OutboundUdp(重用 nat 的合成器)。 + fn dns_query_from_guest(src_port: u16, payload: &[u8]) -> super::super::nat::OutboundUdp { + let frame = super::super::nat::build_udp_reply( + GUEST_MAC, GW_MAC, GUEST_IP, src_port, NAT_DNS_IP, 53, payload, + ); + super::super::nat::parse_udp(&frame).unwrap() + } + + #[test] + fn smoltcp_answers_arp_for_nat_dns_ip() { + // guest(或 pasta)送查詢前會對 on-link 的 10.0.2.3 ARP;backend 的 iface 掛了 + // /32,應回 ARP reply——否則查詢連 L2 都出不去。 + let mut phy = VirtioNetPhy::new(); + let mut backend = test_backend(&mut phy); + + let mut arp = Vec::new(); + arp.extend_from_slice(&[0xff; 6]); // broadcast + arp.extend_from_slice(&GUEST_MAC); + arp.extend_from_slice(&[0x08, 0x06]); + arp.extend_from_slice(&[0x00, 0x01, 0x08, 0x00, 0x06, 0x04, 0x00, 0x01]); + arp.extend_from_slice(&GUEST_MAC); + arp.extend_from_slice(&GUEST_IP); + arp.extend_from_slice(&[0u8; 6]); + arp.extend_from_slice(&NAT_DNS_IP); // who-has 10.0.2.3 + phy.push_from_guest(arp); + + backend.poll(&mut phy, Instant::from_millis(0)); + let reply = phy + .pop_to_guest() + .expect("應回 ARP reply(10.0.2.3 由 iface 持有)"); + assert_eq!(&reply[12..14], &[0x08, 0x06]); + assert_eq!(&reply[20..22], &[0x00, 0x02], "opcode 應為 reply"); + assert_eq!(&reply[28..32], &NAT_DNS_IP, "sender IP 應為 10.0.2.3"); + } + + #[test] + fn dns_pivot_forwards_to_upstream_and_masquerades_reply() { + // 完整 UDP pivot 鏈(host 端):guest 查 10.0.2.3:53 → 上游收到原 payload → + // 上游回應 → 合成回 guest 的 frame src 必須是 10.0.2.3:53(masquerade)。 + let mut phy = VirtioNetPhy::new(); + let mut backend = test_backend(&mut phy); + + let upstream = UdpSocket::bind((std::net::Ipv4Addr::LOCALHOST, 0)).unwrap(); + upstream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + backend.set_dns_upstreams_for_test(vec![upstream.local_addr().unwrap()]); + + backend.nat_outbound_dns(dns_query_from_guest(40000, b"query-bytes")); + + let mut buf = [0u8; 512]; + let (n, flow_addr) = upstream.recv_from(&mut buf).expect("上游應收到查詢"); + assert_eq!(&buf[..n], b"query-bytes"); + upstream.send_to(b"answer-bytes", flow_addr).unwrap(); + + // 回程是真 OS socket,非同步;輪詢至 frame 出現。 + let mut reply = None; + for ms in 0..200 { + backend.poll(&mut phy, Instant::from_millis(ms)); + if let Some(f) = phy.pop_to_guest() { + reply = Some(f); + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + let p = super::super::nat::parse_udp(&reply.expect("應合成回 guest 的 DNS 回應")) + .expect("回程應為 IPv4/UDP"); + assert_eq!(p.src_ip, NAT_DNS_IP, "src 應 masquerade 成 10.0.2.3"); + assert_eq!(p.src_port, 53); + assert_eq!(p.dst_ip, GUEST_IP); + assert_eq!(p.dst_port, 40000); + assert_eq!(p.payload, b"answer-bytes"); + } + + #[test] + fn dns_pivot_drops_reply_from_non_upstream() { + // 只有 fanout 過的上游能回;其他來源(spoof)一律丟棄。 + let mut phy = VirtioNetPhy::new(); + let mut backend = test_backend(&mut phy); + + let upstream = UdpSocket::bind((std::net::Ipv4Addr::LOCALHOST, 0)).unwrap(); + upstream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + backend.set_dns_upstreams_for_test(vec![upstream.local_addr().unwrap()]); + + backend.nat_outbound_dns(dns_query_from_guest(40001, b"q")); + let mut buf = [0u8; 512]; + let (_, flow_addr) = upstream.recv_from(&mut buf).expect("上游應收到查詢"); + + // 惡意來源先送,正牌上游後送——只有正牌 payload 能到 guest。 + let spoofer = UdpSocket::bind((std::net::Ipv4Addr::LOCALHOST, 0)).unwrap(); + spoofer.send_to(b"spoofed", flow_addr).unwrap(); + std::thread::sleep(Duration::from_millis(50)); + upstream.send_to(b"legit", flow_addr).unwrap(); + + let mut frames = Vec::new(); + for ms in 0..200 { + backend.poll(&mut phy, Instant::from_millis(ms)); + while let Some(f) = phy.pop_to_guest() { + frames.push(f); + } + if !frames.is_empty() { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(frames.len(), 1, "spoof 應被丟棄、只留正牌回應"); + let p = super::super::nat::parse_udp(&frames[0]).unwrap(); + assert_eq!(p.payload, b"legit"); + assert_eq!(p.src_ip, NAT_DNS_IP); + } + + #[test] + fn dns_flow_keyed_separately_from_normal_flow() { + // 同一 guest src port 同時查外部 DNS(一般 NAT)與 10.0.2.3(pivot): + // 兩者不能共用 flow,否則 masquerade 會錯掛到一般回程上。 + let mut phy = VirtioNetPhy::new(); + let mut backend = test_backend(&mut phy); + backend.set_dns_upstreams_for_test(vec![SocketAddr::from(([127, 0, 0, 1], 9))]); + + // 一般出網(dst 用 loopback discard 埠,不真的期待回應)。 + let ext_frame = super::super::nat::build_udp_reply( + GUEST_MAC, + GW_MAC, + GUEST_IP, + 40002, + [127, 0, 0, 1], + 9, + b"x", + ); + backend.nat_outbound(super::super::nat::parse_udp(&ext_frame).unwrap()); + backend.nat_outbound_dns(dns_query_from_guest(40002, b"y")); + + assert_eq!(backend.nat_flows.len(), 2, "一般與 DNS pivot 應各一條 flow"); + assert!(backend.nat_flows.iter().any(|f| !f.dns)); + assert!(backend.nat_flows.iter().any(|f| f.dns)); + } + + #[test] + fn nat_dns_addr_is_never_evicted_or_released() { + // TCP NAT 動態 dst 塞滿 iface 位址表時,常駐的 10.0.2.3 不可被驅逐; + // release_dst_addr(10.0.2.3) 也必須是 no-op(ARP 應答依賴它)。 + let mut phy = VirtioNetPhy::new(); + let mut backend = test_backend(&mut phy); + let dns_cidr = IpCidr::new(IpAddress::Ipv4(Ipv4Address::new(10, 0, 2, 3)), 32); + assert!(backend.iface.ip_addrs().contains(&dns_cidr)); + + // 塞爆位址表(SMOLTCP_IFACE_MAX_ADDR_COUNT=64),迫使驅逐路徑運轉。 + for i in 0..100u32 { + let b = i.to_be_bytes(); + let _ = backend.ensure_dst_addr([1, b[1], b[2], b[3].max(1)]); + } + assert!( + backend.iface.ip_addrs().contains(&dns_cidr), + "10.0.2.3 不可被驅逐" + ); + + backend.release_dst_addr(NAT_DNS_IP); + assert!( + backend.iface.ip_addrs().contains(&dns_cidr), + "release 對 10.0.2.3 應為 no-op" + ); + } + + #[test] + fn dns_override_parsing() { + assert_eq!( + parse_dns_override("1.1.1.1, 8.8.8.8"), + vec![ + SocketAddr::from(([1, 1, 1, 1], 53)), + SocketAddr::from(([8, 8, 8, 8], 53)), + ] + ); + // 無效項忽略;0.0.0.0 排除;全空 → 空清單(呼叫端視同未設定)。 + assert_eq!( + parse_dns_override("junk, 0.0.0.0, 9.9.9.9"), + vec![SocketAddr::from(([9, 9, 9, 9], 53))] + ); + assert!(parse_dns_override("").is_empty()); + } } diff --git a/docs/DESIGN.md b/docs/DESIGN.md index fbb6cba..f91f290 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -230,6 +230,7 @@ bundle/ - old_names 遷移:新目錄不存在時,依序找同層舊名目錄,第一個存在者 rename 為新名。 - 埠代理: - **TCP**:對每個 `host != guest` 的 PortSpec 啟一條 thread:`listen 127.0.0.1:host → connect 127.0.0.1:guest` 雙向轉送(後端候選先 127.0.0.1 後 [::1])。`host == guest` 在 Linux 不代理(共享 netns)、在 Windows 加 best-effort IPv4 補橋。 + - **macOS(vz)**:runtime 的 `start_port_proxies` **整個略過**(TCP+UDP、含 host≠guest)——vz 的 guest 埠不會出現在 host localhost(VZ NAT 無 wslrelay 等自動轉送),host→guest 一律由 vz 後端取得 guest IP 後 relay `127.0.0.1:host → :guest`(實體 Mac 實測:runtime 在此綁 `127.0.0.1:host` 只會與 vz 後端的 relay 搶同一顆埠)。 - **UDP**:採 per-client session table relay(每個來源位址各開一條 upstream socket 並各起回程 thread;非「只記最後來源」)。 - Linux:`listen 127.0.0.1:host → 127.0.0.1:guest`(共享 netns)。 - Windows(wsl2)/ macOS(vz):因 WSL2 的 `wslrelay` 不轉 UDP(VZ NAT 同理不自動轉送),改由後端取得 VM 的對外 IPv4,host 端 relay `127.0.0.1:host → :guest`(含 `host == guest`,UDP 在 Windows 不會自然生效,故一律 relay);VM 內由 guest-agent 起 `:guest → 127.0.0.1:guest` 的橋接,補上「服務只綁 loopback」的情形(服務綁 0.0.0.0 時直接命中 `:guest`,橋接以 EADDRINUSE 略過)。此 UDP 的 host 端 relay 因需 VM IP,**由 wsl2/vz 後端啟動**,不在 runtime 的跨平台 `start_port_proxies`(後者在 Windows 略過 UDP)。 @@ -289,7 +290,7 @@ pub fn run_app(ctx: &AppRunContext) -> anyhow::Result; // 取第一個 Avai - **virtqueue**:split virtqueue(desc/avail/used ring 在 guest RAM,host 以 GPA 讀寫);feature 協商至少 `VIRTIO_F_VERSION_1`。used ring 更新後經 PIC 注入該裝置 IRQ(沿用既有 inject 機制)。ring 解析與 register state machine 為跨平台純邏輯,進 CI 單元測試。 - **裝置清單**:① **virtio-blk(bundle, ro)**+② **virtio-blk(data, rw)**——取代 vz 的 virtiofs。bundle/data 各以 **sector(512) 對齊的 tar image** 當 backing(`virtio::image::pack_dir`/`unpack_image`,純 Rust、跨平台可產生,免在 Windows host 備 mke2fs/mksquashfs);guest 端 busybox `tar` 展開到 tmpfs;data 於關機時把 image 解回 host 持久化。**已知取捨**:tar 需展開佔 guest RAM——對 data(小)合適,bundle(大)若 RAM 吃緊,後續可換唯讀 squashfs image。③ **virtio-net**——host→guest 埠轉發,host 端走 **純 Rust user-mode TCP/IP(smoltcp)**:helper 內以 smoltcp 當 guest 的 gateway(gateway `10.0.2.2`、guest 靜態 `10.0.2.15/24`,與 appliance init 約定),net 裝置兩個 queue(0=rx、1=tx)在 base `0xD000_0200` / IRQ 6。**埠轉發歸屬(WHP 專屬,與 vz 不同)**:smoltcp 的 guest IP 是 helper process 內的虛擬位址、host kernel 無路由可達,故 host→guest 轉發的 listener **必須由 helper 自身持有**——但 helper 並非綁使用者的 host 埠,而是按 WSL2 wslrelay 慣例把**每個 guest 埠暴露在 host `[::1]:`**(`--forward-tcp :` → `NetBackend::add_forward` 在 helper 內 bind `[::1]:listen`,再以 smoltcp TCP socket 橋接到 `guest_ip:guest`)。host≠guest 的對外 remap 仍交給 **chefer-runtime 既有的埠代理**(`proxy.rs`:bind `127.0.0.1:host` → 轉 `127.0.0.1`/`[::1]:guest`)——故 vmm-backend 只把要暴露的 guest 埠(去重)以 `:` 傳給 helper,**不可**像 vz 那樣直連 guest IP relay、也不重綁使用者 host 埠(會與 runtime proxy 撞埠)。綁 `[::1]` 而非 `127.0.0.1` 是為了同時相容 runtime 的 host==guest Windows 補橋(它佔 `127.0.0.1:guest` 並轉 `[::1]:guest`)。**UDP 同理**(`--forward-udp` → `add_udp_forward` 綁 `[::1]:listen` UDP,per-client smoltcp UDP socket 以 unique local port demux guest 回程),guest 端由 guest-agent 的 `start_vm_udp_bridges`(eth0→loopback UDP 橋接)承接。guest 主動對外(outbound NAT)已實作(見下方 M7):helper 的 `drain_tx` 把外部 dst 的 UDP/TCP 分流到 NAT 引擎(per-flow host socket),guest 內 `shared` 服務直走 eth0、`bridge` 服務經 pasta 到 eth0,皆可出網。④ console 沿用 ttyS0 + `/dev/kmsg` exit channel(§4 不變),暫不引入 virtio-console。 - **appliance 相容**:需要能在 WHP 環境開機的 appliance。kernel config 加 `CONFIG_VIRTIO_MMIO`/`_BLK`/`_NET`/`CONFIG_VIRTIO_MMIO_CMDLINE_DEVICES`;cmdline 追加各 `virtio_mmio.device=...`。**init 改為自適應**(偵測 `/dev/hvc0` → vz 路徑掛 virtiofs;否則 WHP 路徑掛 `/dev/vda`(bundle ro)+`/dev/vdb`(data rw)、console 用 ttyS0),維持「whp 與 vz 共用一份 appliance」。init 另負責 `bridge` 出網(pasta)的環境前提:開機早期 `switch_root` 到 tmpfs 根(rootfs 上 `pivot_root` EINVAL)、確保 `/dev/net/tun` 存在且 0666;kernel config 含 `CONFIG_TUN`。 - - **分階段里程碑(每個可獨立驗證;✅=已實機達成)**:✅ M1 transport(WHvEmulator 接線 + virtqueue)→ ✅ M2 virtio-blk(bundle ro)(guest 從 `/dev/vda` 讀 bundle 解 tar)→ ✅ M3 virtio-blk(data rw) 關機回寫(**實機達成**:helper 第二顆 virtio-blk vdb(base `0xD000_0400`/IRQ 7)以 `pack_dir(data_dir)` 補零到容量上限為 backing;guest 開機 `dd /dev/vdb | tar -x`、關機 `tar data_dir | dd of=/dev/vdb`,helper 關機 `unpack_image(vdb)→data_dir`;本機 WHP 實測 persist counter 跨重啟 1→2→3。**限制**:只在 guest 乾淨關機(服務全退出)時回寫,長駐 server 被 kill/timeout 不回寫;vdb 容量固定 `CHEFER_WHP_DATA_MIB`(預設 256MiB);tar 解壓不刪除 host 端已移除的檔)→ ✅ M4 virtio-net + host→guest 埠轉發(**實機達成**:net 裝置接上 run loop/MMIO 模擬器、smoltcp user-mode gateway、helper 綁 `[::1]:guest`、guest 靜態 IP `10.0.2.15`、guest-agent eth0→loopback TCP 橋接;本機 WHP 實測 redis bundle,host `redis PING` 經整條鏈得 `+PONG`)→ ✅ M5 appliance 自適應 init(偵測 /dev/hvc0 → vz virtiofs;否則 WHP `dd /dev/vda | tar -x`)+ kernel config + build → ✅ M6 端到端真 bundle(實機 `CHEFER_GUEST_EXIT=0`,`interface_mode: none` 服務)。**剩餘**:guest 主動對外 outbound NAT(🚧 分階段:✅ **M7-a 出網 UDP(實機達成)**——`whp-helper/src/virtio/nat.rs` 解析 guest IPv4/UDP frame + 合成回程 frame;net_backend NAT 引擎(per-flow host UDP socket)+ `drain_tx` 分流(外部 dst → NAT、其餘 → smoltcp)+ 回程注入 guest rx;本機 WHP 實測 `network: shared` 服務 `nslookup example.com 1.1.1.1` 經整條 NAT 鏈解析成功(`CHEFER_GUEST_EXIT=0`)。✅ **M7-b 出網 TCP(實機達成)**——drain_tx 對外部 dst 的 TCP 仍交 smoltcp,但 SYN 先 `nat_tcp_syn` 預註冊:把 dst IP 動態加進 iface `ip_addrs`(X/32;`.cargo/config.toml` 設 `SMOLTCP_IFACE_MAX_ADDR_COUNT=64` 提高容量 + idle dst LRU 驅逐)、建 smoltcp listen socket(dst,port)、**背景執行緒** connect host `TcpStream`(不阻塞 VM loop);`tcp_nat_pump` 完成連線後雙向橋接 smoltcp socket ↔ host stream;本機 WHP 實測 `network: shared` 服務 `wget http://example.com`(DNS UDP NAT 解析 + TCP NAT 連 104.20.x:80)成功(`CHEFER_GUEST_EXIT=0`)。✅ **M7-c bridge 模式出網(實機達成,預設模式全通)**——不需取代 pasta:與其他後端同一條 pasta 路徑(app netns → pasta tap → guest root netns socket → eth0 → helper NAT)。實機打通靠三個 guest 端修正:① appliance kernel 補 `CONFIG_TUN`(x86_64 defconfig 不含,pasta 無 tap 可建);② appliance init 補 `/dev/net/tun` 節點 0666(devtmpfs 對 misc 裝置預設 0600,pasta 以非特權 uid 開不了)並於開機早期 `switch_root` 到 tmpfs 根(initramfs 的 rootfs 上 `pivot_root` 一律 EINVAL,pasta 自我沙箱會直接退出——此問題 vz 同 appliance 也會中,一併修掉);③ guest-agent `start_pasta` 對 exec `EACCES` 補救(Windows host 打包無法記錄 unix 執行位 → bundle 內 pasta 是 0644;複製到 tmp 補 0755 重試)。本機 WHP 實測預設 `bridge` app:alpine 服務 `wget http://example.com`(DNS UDP + HTTP TCP 經 pasta→NAT 鏈)`CHEFER_GUEST_EXIT=0`;redis bundle 宣告埠 `+PONG`、未宣告埠拒絕。)。剩餘:GUI 顯示通道(virtio-gpu,另一條線)。 + - **分階段里程碑(每個可獨立驗證;✅=已實機達成)**:✅ M1 transport(WHvEmulator 接線 + virtqueue)→ ✅ M2 virtio-blk(bundle ro)(guest 從 `/dev/vda` 讀 bundle 解 tar)→ ✅ M3 virtio-blk(data rw) 關機回寫(**實機達成**:helper 第二顆 virtio-blk vdb(base `0xD000_0400`/IRQ 7)以 `pack_dir(data_dir)` 補零到容量上限為 backing;guest 開機 `dd /dev/vdb | tar -x`、關機 `tar data_dir | dd of=/dev/vdb`,helper 關機 `unpack_image(vdb)→data_dir`;本機 WHP 實測 persist counter 跨重啟 1→2→3。**限制**:只在 guest 乾淨關機(服務全退出)時回寫,長駐 server 被 kill/timeout 不回寫;vdb 容量固定 `CHEFER_WHP_DATA_MIB`(預設 256MiB);tar 解壓不刪除 host 端已移除的檔)→ ✅ M4 virtio-net + host→guest 埠轉發(**實機達成**:net 裝置接上 run loop/MMIO 模擬器、smoltcp user-mode gateway、helper 綁 `[::1]:guest`、guest 靜態 IP `10.0.2.15`、guest-agent eth0→loopback TCP 橋接;本機 WHP 實測 redis bundle,host `redis PING` 經整條鏈得 `+PONG`)→ ✅ M5 appliance 自適應 init(偵測 /dev/hvc0 → vz virtiofs;否則 WHP `dd /dev/vda | tar -x`)+ kernel config + build → ✅ M6 端到端真 bundle(實機 `CHEFER_GUEST_EXIT=0`,`interface_mode: none` 服務)。**剩餘**:guest 主動對外 outbound NAT(🚧 分階段:✅ **M7-a 出網 UDP(實機達成)**——`whp-helper/src/virtio/nat.rs` 解析 guest IPv4/UDP frame + 合成回程 frame;net_backend NAT 引擎(per-flow host UDP socket)+ `drain_tx` 分流(外部 dst → NAT、其餘 → smoltcp)+ 回程注入 guest rx;本機 WHP 實測 `network: shared` 服務 `nslookup example.com 1.1.1.1` 經整條 NAT 鏈解析成功(`CHEFER_GUEST_EXIT=0`)。✅ **M7-b 出網 TCP(實機達成)**——drain_tx 對外部 dst 的 TCP 仍交 smoltcp,但 SYN 先 `nat_tcp_syn` 預註冊:把 dst IP 動態加進 iface `ip_addrs`(X/32;`.cargo/config.toml` 設 `SMOLTCP_IFACE_MAX_ADDR_COUNT=64` 提高容量 + idle dst LRU 驅逐)、建 smoltcp listen socket(dst,port)、**背景執行緒** connect host `TcpStream`(不阻塞 VM loop);`tcp_nat_pump` 完成連線後雙向橋接 smoltcp socket ↔ host stream;本機 WHP 實測 `network: shared` 服務 `wget http://example.com`(DNS UDP NAT 解析 + TCP NAT 連 104.20.x:80)成功(`CHEFER_GUEST_EXIT=0`)。✅ **M7-c bridge 模式出網(實機達成,預設模式全通)**——不需取代 pasta:與其他後端同一條 pasta 路徑(app netns → pasta tap → guest root netns socket → eth0 → helper NAT)。實機打通靠三個 guest 端修正:① appliance kernel 補 `CONFIG_TUN`(x86_64 defconfig 不含,pasta 無 tap 可建);② appliance init 補 `/dev/net/tun` 節點 0666(devtmpfs 對 misc 裝置預設 0600,pasta 以非特權 uid 開不了)並於開機早期 `switch_root` 到 tmpfs 根(initramfs 的 rootfs 上 `pivot_root` 一律 EINVAL,pasta 自我沙箱會直接退出——此問題 vz 同 appliance 也會中,一併修掉);③ guest-agent `start_pasta` 對 exec `EACCES` 補救(Windows host 打包無法記錄 unix 執行位 → bundle 內 pasta 是 0644;複製到 tmp 補 0755 重試)。本機 WHP 實測預設 `bridge` app:alpine 服務 `wget http://example.com`(DNS UDP + HTTP TCP 經 pasta→NAT 鏈)`CHEFER_GUEST_EXIT=0`;redis bundle 宣告埠 `+PONG`、未宣告埠拒絕。✅ **M7-d 容器內 DNS(10.0.2.3 pivot;實機達成)**——後查(實體 Mac vz 工作時發現):M7-b/c 當時容器內**沒有任何 resolv.conf 來源**(裸 alpine 不自帶、repo 內無注入程式碼;musl 缺檔會 fallback `127.0.0.1:53`,封包根本到不了 NAT——本機以 alpine 拔掉 resolv.conf 實證 `wget: bad address`),故當時的 wget 解析必是測試另行提供了 nameserver(未記錄入 repo);NAT 轉發機制的驗證仍有效,但「裸 image 開箱即可解析」**尚未成立**。正式來源已補上:kernel cmdline `ip=` dns0=`10.0.2.3` + helper DNS pivot(見 §「容器內 DNS」),host 端邏輯有跨平台單元測試;**本機 WHP 實機驗證通過**(2026-07-11,Windows 11 Pro 26200:裸 alpine:3.20、不含任何 resolv.conf 手段,預設 `bridge` 與 `shared` 皆容器內斷言 nslookup Server=`10.0.2.3` + `wget http://example.com`,`CHEFER_GUEST_EXIT=0`;trace `dns: upstreams` = host Wi-Fi DNS `192.168.1.1`(GetNetworkParams 探測正確),`CHEFER_WHP_DNS=1.1.1.1` 覆寫後 upstreams 跟隨;VPN/內網 hostname 情境未涵蓋——驗證機無 VPN,詳 whp-virtio-roadmap M7-d)。)。剩餘:GUI 顯示通道(virtio-gpu,另一條線)。 - **驗證邊界(誠實)**:同 vz——**GitHub runner 開不了 WHP**,transport/virtqueue 純邏輯進 CI 單元測試,真開 VM 的 e2e 只能在實機(Windows + 硬體虛擬化 + WHP 功能)手動跑。前置:`CHEFER_BACKEND=whp` 後端覆寫開關(已實作)讓有 WSL 的機器能選到 whp。 - **macOS**(`vz`,Apple Virtualization.framework):macOS 沒有現成 Linux,必須自行開一台輕量 Linux micro-VM 來跑容器。設計如下(**狀態:契約已定 + 跨平台純邏輯已實作並測試;Linux appliance 建置與 QEMU E2E 已納入 scripts/CI;開機改採內附 Swift helper(見下),其 swiftc 編譯與 Rust 後端 compile-check 已納入 CI,VM 真正開機仍待在實體 Mac 上驗證**——在實機驗證通過前 `availability()` 預設 `Unavailable`,需 `CHEFER_VZ_EXPERIMENTAL=1` 才啟用,不偽稱可執行)。 - **Appliance(kit 內附預編 kernel)**:kit 與 macOS 目標的 bundle 內附一份精簡 Linux 開機組合: @@ -302,9 +303,9 @@ pub fn run_app(ctx: &AppRunContext) -> anyhow::Result; // 取第一個 Avai - **Console 標記契約(host 解析 guest 狀態的唯一管道)**:appliance `init` 在 hvc0 印出兩個標記,host 端以子字串比對解析(`vz_util::parse_guest_exit_code` / `parse_guest_ip`,取最後一個、容忍前綴): - `CHEFER_GUEST_EXIT=`:guest-agent 結束後印出,隨即關機 → host 以此作為整個 app 的 exit code。 - `CHEFER_GUEST_IP=`:開機掛載後、跑 app 前印出 guest eth0 的對外 IPv4,供 host 規劃 host→guest 埠轉發。 - - **網路 / 埠映射**:`VZNATNetworkDeviceAttachment` 給 guest NAT IP(host 在同一 NAT 子網可直接連到 guest);但 host→guest **不自動轉送**,故所有宣告的 ports(TCP 與 UDP)都由 vz 後端在 host 端 relay `127.0.0.1:host → :guest`(`guest_ip` 取自上面的 console 標記;轉發清單由 `vz_util::forward_ports` 規劃)。UDP 另以 `--udp-bridge` 叫 guest-agent 在 VM 內補 eth0→loopback 橋接(appliance init 已帶此旗標,guest 側就緒;**macOS host 端 relay 與 vz 開機 shim 同屬待實機驗證項**)。 + - **網路 / 埠映射**:`VZNATNetworkDeviceAttachment` 給 guest NAT IP(host 在同一 NAT 子網可直接連到 guest);但 host→guest **不自動轉送**,故所有宣告的 ports(TCP 與 UDP)都由 vz 後端在 host 端 relay `127.0.0.1:host → :guest`(`guest_ip` 取自上面的 console 標記;轉發清單由 `vz_util::forward_ports` 規劃)。UDP 另以 `--udp-bridge` 叫 guest-agent 在 VM 內補 eth0→loopback 橋接(appliance init 已帶此旗標)。**已於實體 Apple Silicon 驗證(2026-07)**:vz 開機 shim、TCP relay(host `curl` 通)與 UDP relay(host datagram 進到 app netns 服務)皆實測通過。 - **程式碼簽章 / entitlement**:呼叫 Virtualization.framework 的程序是 **`chefer-vz-helper`**(非 chefer-runtime 本身),故 helper 在 macOS 上**必須**以 `com.apple.security.virtualization` entitlement 簽章,否則建立 VM 會在執行期失敗。release 流程建好 helper 後即以此 entitlement 簽章再嵌進 kit/bundle(macho 的簽章隨檔案複製保留,執行期解壓後仍有效);自行於 Mac 測試時可 ad-hoc 簽:`codesign --entitlements vz-helper.entitlements -s - chefer-vz-helper-`。 - - **實驗性開關(誠實回報)**:在實體 Mac 驗證通過前,`availability()` 預設維持 `Unavailable`;提供 `CHEFER_VZ_EXPERIMENTAL=1` 讓使用者在自己的 Mac 上明確選擇啟用 vz 後端做驗證(訊息會說明這是未驗證路徑)。驗證通過後才移除此 gate、預設可用。 + - **實驗性開關(誠實回報)**:`availability()` 預設維持 `Unavailable`,需 `CHEFER_VZ_EXPERIMENTAL=1` 明確啟用。**實機驗證已於 2026-07 在 Apple Silicon(macOS 26)完成**(開機/exit code/TCP+UDP 埠轉發/bridge 出網+DNS/多服務/persist/GUI 顯示與剪貼簿);把 gate 放寬成預設可用屬**改變對外預設行為**,另行決策後再動。 - **純邏輯(跨平台可測,`vz_util.rs` 內)**:appliance 檔在 bundle 內的查找(`layout::vm_dir` / `kernel_name` / `initramfs_name`)、kernel command line 組裝、VM 資源(CPU/RAM)計算、guest 路徑映射、錯誤訊息——皆為純函式並有單元測試(在 Windows/Linux 上即可跑)。 - **Swift helper(`vz-helper/`,僅 macOS 編譯)**:以 swiftc 編成 `chefer-vz-helper-`,驅動上述組態並開機。只能在實體 Mac 驗證 VM 真的開得起來(GHA macOS runner 為巢狀虛擬化、開不了 VZ guest,CI 僅 swiftc compile-check helper、cargo compile-check Rust 後端)。Rust 後端找 helper 的順序:bundle 內 `agents/chefer-vz-helper-` > `CHEFER_VZ_HELPER` env(供實機驗證時直接指向自建 helper,免整條 release 管線)。 - **Appliance 建置**:`vm/chefer-vmlinuz-*` 與 `vm/chefer-initramfs-*` 由獨立的 Linux 建置流程產生,並可在 **Linux + QEMU**(同樣 virtio 開機)上做 E2E 驗證,與 macOS 無關——這是讓 vz 後端可信的關鍵前置,且不需 Mac。 @@ -318,7 +319,7 @@ pub fn run_app(ctx: &AppRunContext) -> anyhow::Result; // 取第一個 Avai - **Host(vz 後端,`#[cfg(target_os = "macos")]`)——已實作於 `vz-helper/main.swift` 的 `--gui` 模式**:VM 組態加 `VZVirtioGraphicsDeviceConfiguration`(一個 scanout,預設 1280×800、`CHEFER_VZ_GUI_SIZE=WxH` 覆寫)+ `VZUSBKeyboardConfiguration` + `VZUSBScreenCoordinatePointingDeviceConfiguration`(絕對座標,同 WHP 的 tablet 選擇);開一個 AppKit 視窗(標題 = app 名,`--gui-title`)承載 `VZVirtualMachineView` 綁該 VM(VM 綁主 queue、`NSApplication` 事件迴圈)→ 顯示 guest framebuffer,VZ 自動把鍵鼠 HID 轉進 guest。**關窗 = app 結束**(與 WHP 同語意;先試 `vm.stop`、2 秒保底退出,runtime 對「helper 乾淨退出且無 exit 標記」回 0)。vz.rs 依 manifest 有 gui/both 服務傳 `--gui`/`--gui-title`(`vz_util::helper_args`,含單元測試)。GUI 模式下 console 經 helper 內部 pipe tee 直通 stdout(runtime 標記解析不變)並掃 `CHEFER_GUEST_IP` 以啟動剪貼簿;非 gui 維持 headless 原路徑(console 直寫 stdout)。 - **生命週期 / 輸入**:`cage` 內 app 結束 → 沿用 guest-agent 既有「介面服務結束=收掉 app」→ VM 關、視窗關。鍵鼠走 VZ→USB HID→evdev→cage,免額外處理。 - **剪貼簿(host↔guest,vz 與 whp 共用設計)——vz host 端已實作(`ClipboardHost`,`vz-helper/main.swift`)**:**不走 vsock**(whp 得再寫一個 virtio-vsock 裝置模型,成本高)——改走**既有網路通道**:guest-agent 起剪貼簿同步服務(guest 側以 wl-clipboard 對接 cage 的 Wayland 剪貼簿;與 WHP 完全共用 `clipboard.rs`),host 側經該後端既有的 host→guest 通道連入(vz:**直連 guest IP:55381**(NAT 可達,IP 自 console `CHEFER_GUEST_IP` 標記);whp:helper 的 `[::1]` 轉發),雙向同步。安全:以 **kernel cmdline 帶入的隨機 token** 握手(vz helper 以 `SecRandomCopyBytes` 產生、附 `chefer.clip_token`/`chefer.clip_port`),未帶 token 的連線一律拒絕。線協定與 WHP 同版:token 行 + ``(kind 0=text、1=PNG,上限 32MiB);macOS 端 NSPasteboard 讀寫 `.png`(現代 app)與 `.tiff`(傳統 app,`NSBitmapImageRep` 轉換——對應 whp 的 "PNG"/CF_DIB 雙格式),回音以 changeCount + (kind,payload) 記憶抑制。**`.waiting` 可見性(實機發現後補)**:NWConnection 連 guest:55381 偶發卡在 `.waiting` 數十秒或永不成功(同一時間 shell 的 `nc` 可秒連),疑為 macOS「區域網路」隱私權對 ad-hoc 簽章 helper 的閘門;此狀態原本落在 stateUpdateHandler 的 default 分支完全靜默。現在 helper 對 `.waiting` 印一次性 stderr 提示(引導到 系統設定 → 隱私權與安全性 → 區域網路 放行)並在 3 秒未 ready 時棄連重連(新連線常可立即成功);`build-vz-helper.sh` 另支援 `CHEFER_CODESIGN_IDENTITY` 以穩定簽章身分讓 TCC 記住授權(預設仍 ad-hoc)。 - - **分期**:① vz 後端先點亮(非 GUI)✅ → ② appliance 加 virtio-gpu + cage + Xwayland,先在 **QEMU** 驗 GUI app 能算繪 ✅ → ③ 真 Mac 接 `VZVirtualMachineView` + HID —— **host 側程式已完成(`--gui` 模式,CI swiftc compile-check 把關),端到端顯示/輸入待實體 Mac 驗證** → ④ 剪貼簿/縮放 —— **剪貼簿 host 端已完成(同上待實機)**;**縮放(動態解析度)已實作**:macOS 14+ 開 `.resizable` 視窗 + `VZVirtualMachineView.automaticallyReconfiguresDisplay`(VZ 對 guest virtio-gpu 發顯示重組態、guest DRM hotplug 換模式、cage/wlroots 跟隨;macOS 13 無此 API → 維持固定尺寸)。GHA macOS runner 開不了 VZ guest,③④ 的執行驗證 gated on 實體 Mac——**一鍵驗證腳本 `scripts/vz-smoke.sh`**(開機/exit code 傳播/埠轉發自動斷言 + GUI 手動檢核清單),拿到真 Mac 跑一次即可收掉全部待驗項。 + - **分期**:① vz 後端先點亮(非 GUI)✅ → ② appliance 加 virtio-gpu + cage + Xwayland,先在 **QEMU** 驗 GUI app 能算繪 ✅ → ③ 真 Mac 接 `VZVirtualMachineView` + HID —— ✅ **實體 Apple Silicon 驗證通過(2026-07)**:視窗顯示、X11 client 連線算繪、compositor 8/8 次開機全部就緒(`WLR_LIBINPUT_NO_DEVICES` boot-race 修復在 vz 上成立);鍵鼠實際輸入(VZ 原生 HID 注入)與關窗語意仍屬人工檢核項 → ④ 剪貼簿/縮放 —— ✅ **剪貼簿實機驗證**:文字雙向、PNG guest→host(NSPasteboard 位元組相符)、PNG host→guest(線上送達)皆通過;**縮放(動態解析度)實測結論(兩段式)**:`automaticallyReconfiguresDisplay` 確實把視窗**像素**尺寸推進 guest——**開機時** guest 模式=視窗 Retina backing(1280×800 點視窗 → guest 2560×1600;`CHEFER_VZ_GUI_SIZE=1024x600` → 2048×1200);但**執行中拖拉視窗不會 re-modeset、只有畫面縮放**(實機驗證:拖拉全程 guest 維持 2560×1600)——與 WHP 相同的 guest 端限制:`cage` kiosk compositor 啟動後不跟隨模式變更(修法在 guest 側,例如讓 compositor 對 virtio-gpu hotplug 換 preferred mode;host 端已無事可做)。**預設取捨(實測後定案)**:guest 不 re-modeset 的情況下開 `automaticallyReconfiguresDisplay` 反而讓 view 停止縮放(等一個不會來的 re-modeset,拖拉時畫面尺寸不同步),且它推給 guest 的是 Retina **實體像素**尺寸、Linux guest 無 HiDPI 概念 → UI/游標縮半——故預設 **scanout=視窗「點」數、關 `automaticallyReconfiguresDisplay`、由 view 等比縮放**(Retina 上 2× 放大略軟但尺寸正確、拖拉即時跟手;偏離長寬比會 letterbox),`CHEFER_VZ_DYNAMIC_RESOLUTION=1`(macOS 14+)可切回真動態解析度;等 guest compositor 支援 re-modeset + HiDPI output scale 再翻預設。macOS 13 無 `automaticallyReconfiguresDisplay` → 維持固定尺寸。實際打字點擊/關窗語意留人工檢核(`scripts/vz-smoke.sh --gui` 清單)。**已知問題(實機發現,均已補修)**:(a) helper 對 guest:55381 的剪貼簿連線偶發卡在 NWConnection 靜默 `.waiting`——已補一次性提示 + 3 秒棄連重連 + `CHEFER_CODESIGN_IDENTITY` 穩定簽章(見上「剪貼簿」段的 `.waiting` 可見性);(b) 對 runtime 行程單發 SIGINT/SIGTERM(非終端機 Ctrl+C 的整個 process group)時 helper/VM 會殘留(`vz-smoke.sh` 的 `kill -INT` 即觸發)——已補 stdin liveness 契約(helper 讀 stdin EOF 自我了結,見上「Helper 生命週期」;WHP 側另有 Job Object 雙保險,見 §6 whp 節)。 - **替代(不採用)**:X11 → 要求使用者裝 **XQuartz**、X11 經 vsock 轉發——較快但破壞零安裝、過 VM 邊界延遲高、XQuartz 老舊,故不採。 - **GUI overlay 打包契約(vz 與 whp 共用)**:cage + Xwayland + Mesa(llvmpipe) 及其依賴閉包太肥(估數十 MB),**不放進基礎 initramfs**——做成獨立 kit 產物 **`chefer-gui-overlay-.sqfs`**(rootfs subtree 的 **squashfs**,zstd 壓縮;由 `scripts/build-gui-overlay.sh` 於容器內自 Alpine 套件以 `mksquashfs` 收集,release workflow 建置、`verify-release-kit` 檢查)。**squashfs 而非 tar.zst**:唯讀、可直接掛載、頁面按需載入 → guest 免每次開機解壓 200MB+。 @@ -387,6 +388,8 @@ AppCipe 新增 app 級欄位 **`network`**(appcipe-spec enum,serde rename > > **VM guest(appliance)跑 pasta 的三個前提**(缺一即 `bridge` 靜默降級 `internal`):kernel `CONFIG_TUN`;`/dev/net/tun` 節點對非特權 uid 可開(appliance init 設 0666);根檔案系統不能是 initramfs 的 rootfs(pasta 自我沙箱 `pivot_root` 在 rootfs 上一律 EINVAL——appliance init 開機早期 `switch_root` 到 tmpfs 根解掉)。另外 Windows host 打包的 bundle 記錄不了 unix 執行位,guest-agent 對 pasta exec `EACCES` 會複製到 tmp 補 0755 重試(`netns.rs` `start_pasta`)。 +> **容器內 DNS(resolv.conf 注入;實體 Mac vz 實測補上)**:基底 image(如 alpine)多不自帶 `/etc/resolv.conf`(Docker 是由 daemon 於執行期注入,chefer 取代 Docker 就得自己做),缺了它服務內任何 hostname 解析都倒在預設的 `127.0.0.1:53` → 直接失敗(bridge 出網「通了但 DNS 全掛」)。做法比照 Docker:guest-agent 在 `exec.rs build_plan`(fork 前、仍在 root netns)把**執行環境**(VM root / 原生 host)的 `/etc/resolv.conf` **內容實體化**到 temp 檔,再以既有 **bind mount** 機制掛進每個服務的容器 `/etc/resolv.conf`。兩個坑決定了這個形狀:fork 前直接寫進 rootfs 會被孫行程稍後才蓋上的 overlay 遮住(bind 在 overlay 之後套用才可見);直接 bind `/etc/resolv.conf` 也不行——vz/QEMU 下它是 symlink → `/proc/net/pnp`,孫行程 bind 時已 setns 進 app netns,該 netns 的 `/proc/net` 沒有 pnp → ENOENT。一律蓋掉 image 自帶檔(那通常是 build 環境殘留如 Docker 的 `127.0.0.11`);執行環境沒有該檔或內容為空時不注入、維持原狀。VM 路徑的來源:appliance init 把 `/etc/resolv.conf` symlink 到 `/proc/net/pnp`(kernel ipconfig 存放 nameserver 之處;**靜態 `ip=` 也會建此檔**,只是無 dns 欄時只有 `#MANUAL` 沒有 nameserver 行)——vz/QEMU 走 `ip=dhcp`(nameserver 來自 DHCP:VZ NAT gateway / slirp `10.0.2.3`,兩者本身都提供 DNS 轉發);**WHP** 由 kernel cmdline `ip=` 的 **dns0 欄帶約定 DNS `10.0.2.3`**(slirp 慣例位址,`whp_util::kernel_command_line`),helper 端 `net_backend` 對應提供 **DNS pivot**:iface 掛 `10.0.2.3/32` 應答 ARP(常駐、不受 TCP NAT 位址表驅逐),drain_tx 把送往 `10.0.2.3:53` 的 UDP 查詢原樣 fanout 到 **host 目前設定的 DNS 上游**(`GetNetworkParams`,60s TTL 快取跟隨 VPN 切換;`CHEFER_WHP_DNS` 逗號分隔 IPv4 可覆寫;都沒有才 fallback `1.1.1.1`/`8.8.8.8`,不與 host DNS 並用),回程只收上游來源並把 src masquerade 回 `10.0.2.3:53`(guest resolver 是 connected socket,src 不符會被丟);TCP:53 的 SYN(resolver 對截斷回應的 fallback)走既有 TCP NAT 但 host 端改連第一個上游。**WHP 的 DNS pivot 已實機驗證**(2026-07-11,Windows 11 + WHP,裸 alpine bridge/shared/覆寫三變因;詳 whp-virtio-roadmap M7-d 驗證記錄)。 + ### GPU passthrough(opt-in `gpu`)— 設計 服務可選 per-service 欄位 **`gpu`**(`chefer_bundle::GpuRequest`,spec 與 manifest 共用同型別):`false`=關(預設)、`true`=全部 GPU、字串 **`"all"`/`"none"`**(Docker `--gpus all` 慣用別名,不分大小寫,等同 `true`/`false`)、`[0, 2]`=只綁指定 NVIDIA 卡。手動 `Deserialize` 接受這四種寫法、序列化採 untagged(`All(true)`→`true`、`Devices`→`[…]`;`"all"` 只是輸入糖,manifest 存正規形),無效字串(如 `gpu: yes`)明確報錯;舊布林寫法照樣解析,**故 spec/manifest 版號不動**。開啟後,guest-agent 於該服務的容器啟動時把 **host 既有的 GPU 裝置節點與(WSL2)host 驅動 userspace libs** bind 進容器——讓容器內的 app 能做 GPU 計算(CUDA/ROCm/OpenCL/Vulkan)與硬體算繪/視訊編解碼。**預設關**:不開時容器 `/dev` 維持固定 allowlist(`null`/`zero`/`random`/`urandom`/`tty` + pts/shm),不破壞既有隔離。 diff --git a/docs/HANDOFF-macos-vz.md b/docs/HANDOFF-macos-vz.md index 2771fa5..cb558e2 100644 --- a/docs/HANDOFF-macos-vz.md +++ b/docs/HANDOFF-macos-vz.md @@ -1,18 +1,20 @@ # Handoff:在 Mac 上驗證 macOS vz 後端 +> **✅ 狀態:驗證已完成(2026-07-11,Apple Silicon / macOS 26)。** 核心路徑(開機/exit code/TCP+UDP 埠轉發)、bridge 出網(pasta+DNS)、多服務/persist、GUI(顯示、剪貼簿文字雙向+PNG)全數通過;動態解析度實測為**兩段式**:開機時 guest 模式=視窗 Retina 像素尺寸(`CHEFER_VZ_GUI_SIZE` 生效),但拖拉時只縮放不 re-modeset(cage 啟動後不跟模式變更,與 WHP 同一 guest 端限制)。結果已回填 README 與 DESIGN §6。實機揪出並修掉四顆 bug:extract 不還原 exec bit、release 的 aarch64 initramfs 內嵌 x86-64 busybox、runtime 埠代理與 vz relay 搶埠、容器缺 `/etc/resolv.conf`(bridge 出網通但 DNS 全掛)。仍待人工的互動項:實際打字點擊(HID)、拖拉縮放、關窗語意;兩個已知問題(剪貼簿連線偶發靜默 `.waiting`、對 runtime 單發 signal 會殘留 helper)見 DESIGN §6。以下原文保留供重跑參考,**過時處以「實測更正」標註**。 + 這份是給「拿到一台實體 Mac、要把 chefer 的 macOS(Virtualization.framework / vz)路徑收尾」的接手文件。程式碼側都寫完了,剩下的**只差在真 Mac 上跑一次驗證**——GitHub 的 macOS runner 是巢狀虛擬化、開不了 VZ guest,所以 CI 只能編譯+簽章,端到端一定要實機。 ## TL;DR ```bash -# 1. 前置:Xcode CLT、Rust、(建 guest-agent 用)cross + Docker;備一份含 appliance/overlay 的 kit +# 1. 前置:Xcode CLT、Rust(含 -unknown-linux-musl target;免 cross/Docker);備一份含 appliance/overlay 的 kit # 2. 一鍵驗證(非 GUI 的核心路徑:開機 / exit code 傳播 / 埠轉發) bash scripts/vz-smoke.sh --kit-dir <放 appliance 的資料夾> # 3. 加 --gui 再驗顯示 / HID / 剪貼簿 / 動態解析度 / 關窗語意(會列手動檢核清單) bash scripts/vz-smoke.sh --kit-dir <...> --gui ``` -腳本自己會**從當前原始碼**建 `chefer-vz-helper`(swiftc + virtualization entitlement 簽章)、`chefer-cli`、`chefer-runtime`、以及 `guest-agent`(cross,含本輪的 GUI 修復),只從 `--kit-dir` 拿兩個「需要 Docker 才建得出、且本輪沒改」的 Linux 產物:**appliance kernel/initramfs** 與 **GUI overlay squashfs**。 +腳本自己會**從當前原始碼**建 `chefer-vz-helper`(swiftc + virtualization entitlement 簽章)、`chefer-cli`、`chefer-runtime`、以及 `guest-agent`(原生 cargo musl build,有 cross 則優先 cross),只從 `--kit-dir` 拿兩個「需要 Docker 才建得出」的 Linux 產物:**appliance kernel/initramfs** 與 **GUI overlay squashfs**(注意:≤ v0.4.0 release kit 的這兩樣不可用,見下方「實測更正」)。 --- @@ -35,7 +37,7 @@ roadmap 上其餘純硬體項(與 Mac 無關,不在本 handoff):AMD/Inte | macOS **13+**(GUI 動態解析度需 **14+**) | Virtualization.framework / virtiofs | Apple Silicon 或 Intel 皆可 | | Xcode Command Line Tools | `swiftc` + `codesign`(建/簽 vz-helper) | `xcode-select --install` | | Rust toolchain | 建 cli/runtime | https://rustup.rs | -| **cross + Docker** | 從原始碼建 `guest-agent`(含 GUI 修復) | `cargo install cross`;沒有的話腳本退回 kit 的 guest-agent 並警告 | +| musl target(`rustup target add -unknown-linux-musl`) | 從原始碼建 `guest-agent`(純 Rust + rust-lld,**免 cross/Docker**) | 腳本會自動 `rustup target add`;有 cross 則優先用 cross | | 一份 **kit**(appliance + overlay) | vz-smoke 從這裡拿 vmlinuz/initramfs/overlay | 見下節 | ### 取得 kit(appliance + gui-overlay) @@ -46,9 +48,9 @@ roadmap 上其餘純硬體項(與 Mac 無關,不在本 handoff):AMD/Inte - `chefer-gui-overlay-.sqfs`(僅 `--gui` 需要) - `pasta-`(選用;`--gui` 的 bridge 出網要它,缺了會降級成 internal) -**這幾個本輪都沒改**,所以最快是抓 [latest release](https://github.com/TimLai666/chefer/releases/latest) 的 **apple-darwin kit**(`chefer__-apple-darwin.tar.gz`,解開後有 `kit/`),指 `--kit-dir` 到那個 `kit/` 即可。 +~~**這幾個本輪都沒改**,所以最快是抓 [latest release](https://github.com/TimLai666/chefer/releases/latest) 的 **apple-darwin kit**~~ **實測更正(2026-07)**:≤ v0.4.0 的 release kit **不能**直接用——(a) `chefer-initramfs-aarch64` 內嵌 x86-64 busybox(交叉建置 bug,guest 直接 ENOEXEC panic;已修 `scripts/appliance/build-inside-container.sh`);(b) kernel 缺 `SQUASHFS`/`BLK_DEV_LOOP`(GUI overlay 掛不起來);(c) overlay 是舊 `.tar.zst` 格式(現行 `.sqfs`)。請用 Docker(OrbStack 亦可)從當前原始碼重建 appliance + overlay(指令見下);`pasta-` 可沿用 release kit。 -> ⚠️ **重點**:release 的 kit 也含一份 `guest-agent-`,但那是**舊版、不含本輪的 GUI 修復**(尤其 boot-race 的 `WLR_LIBINPUT_NO_DEVICES=1`)。vz-smoke 已改成**優先用 cross 從當前原始碼自建 guest-agent**、忽略 kit 的舊版。所以請務必裝 `cross`(否則會退回舊 guest-agent,vz GUI 可能踩到已修好的競態)。 +> ⚠️ **重點**:release 的 kit 也含一份 `guest-agent-`,但那是**舊版、不含本輪的 GUI 修復**(尤其 boot-race 的 `WLR_LIBINPUT_NO_DEVICES=1`)。vz-smoke 會**優先從當前原始碼自建 guest-agent**、忽略 kit 的舊版。**實測更正(2026-07):不需要 cross/Docker**——guest-agent 是純 Rust + rust-lld(`.cargo/config.toml` 已設 linker),`rustup target add -unknown-linux-musl` 後用原生 `cargo build --target -unknown-linux-musl --release -p guest-agent` 即可(vz-smoke 已支援此原生 fallback)。 想完全自建 kit(不抓 release): ```bash diff --git a/docs/whp-virtio-roadmap.md b/docs/whp-virtio-roadmap.md index 0772962..89799dc 100644 --- a/docs/whp-virtio-roadmap.md +++ b/docs/whp-virtio-roadmap.md @@ -67,6 +67,43 @@ 4. **在沒有 WSL 或設 `CHEFER_BACKEND=whp` 的 Windows** 跑單檔,驗 redis + app 起來、埠可連、 `CHEFER_GUEST_EXIT` 正確回傳、data 持久化。 +### M7-d — 容器內 DNS(10.0.2.3 pivot)✅ 實機驗證通過(2026-07-11) + +背景:M7-b/c 記錄的 `wget http://example.com` 成功**不代表**裸 image 開箱可解析——當時 +容器內沒有任何 resolv.conf 來源(裸 alpine 不自帶;musl 缺檔 fallback `127.0.0.1:53`, +到不了 NAT),解析必是測試時另行給了 nameserver。正式來源已補(設計見 DESIGN +§「容器內 DNS」): + +- guest 側:kernel cmdline `ip=` 尾追 dns0=`10.0.2.3`(`whp_util::kernel_command_line`)→ + `/proc/net/pnp` 出現 `nameserver 10.0.2.3` → appliance init symlink `/etc/resolv.conf` → + guest-agent `exec.rs` 注入每個容器(vz/QEMU 同一條既有鏈)。 +- host 側:helper `net_backend` 的 DNS pivot——iface 掛 `10.0.2.3/32` 應答 ARP, + `10.0.2.3:53` 的 UDP fanout 到 host 設定的 DNS(`GetNetworkParams`、60s 快取、 + `CHEFER_WHP_DNS` 覆寫、無上游才 fallback 公共 DNS),回程 masquerade 回 + `10.0.2.3:53`;TCP:53 SYN 走 TCP NAT 但改連第一個上游。 + host 端邏輯已有跨平台單元測試(`net_backend::tests::dns_*`),Windows 專屬的 + `GetNetworkParams` 僅過 cross-compile check。 + +**實機驗證記錄(2026-07-11,Windows 11 Pro 26200 + WHP,host DNS = Wi-Fi `192.168.1.1`)**: + +- kit:本分支重建 `chefer-runtime`/`chefer-whp-helper`/musl `guest-agent`/appliance + initramfs(init 含 resolv.conf symlink 區塊;kernel 沿用 v6.6.32 既有 vmlinuz——config + 未變不必重編)+ `scripts/build-pasta.sh` 產靜態 pasta(passt tag 2026_06_11.a9c61ff)。 +- 測試 app:裸 `alpine:3.20`、**不含任何 resolv.conf 手段**、`interface_mode: none`, + `cmd: sh -c "nslookup example.com | grep -F 10.0.2.3 && wget -O- http://example.com >/dev/null"` + ——WHP console 看不到服務 stdout,故「nslookup Server = 10.0.2.3」以容器內 grep 斷言 + 進 exit code。 +- 結果(`CHEFER_BACKEND=whp CHEFER_WHP_NET_TRACE=1` 跑單檔): + 1. 預設 `bridge`:exit 0(`CHEFER_GUEST_EXIT=0`);trace 見 + `dns: upstreams = [192.168.1.1:53]`(與 host `ipconfig` 一致,GetNetworkParams 探測 + 正確)、`nat: new dns pivot flow 10.0.2.15:*` 兩條、`nat-tcp: new flow … :80`。 + 2. `network: shared`:exit 0,同樣 pivot flow(不經 pasta 的路徑)。 + 3. `CHEFER_WHP_DNS=1.1.1.1`:exit 0,trace upstreams 變 `[1.1.1.1:53]`(覆寫生效)。 +- **未涵蓋**:(c) VPN/公司內網 hostname 解析(驗證機當時無 VPN)——pivot 對內網 DNS 的 + 價值路徑(上游=VPN DNS)尚待有 VPN 環境時順手補驗。 +- 陷阱重演提醒:initramfs 重建時 init 必須去 CRLF(`build-inside-container.sh` 已處理; + 自組重建管線漏掉會 PID 1 exit 127 kernel panic,本次實測踩過一次)。 + ### M8 — GUI(virtio-gpu + cage overlay;契約見 DESIGN §6「WHP GUI」與「GUI overlay 打包契約」) M1-M7(blk/persist/net/NAT 含預設 bridge)皆已實機完成後,WHP 僅剩這條大線。 diff --git a/scripts/appliance/init b/scripts/appliance/init index a3e541e..0411882 100644 --- a/scripts/appliance/init +++ b/scripts/appliance/init @@ -122,6 +122,17 @@ if [ ! -e /dev/net/tun ]; then fi chmod 0666 /dev/net/tun 2>/dev/null || true +# DNS:kernel ipconfig 把 nameserver 放在 /proc/net/pnp、不會自己寫 /etc/resolv.conf—— +# 比照慣例接上 symlink。guest-agent 會把這份 resolv.conf 的內容注入各服務的容器 rootfs +# (等價於 Docker 對容器注入 resolv.conf;見 exec.rs)。三條 VM 路徑同一機制: +# vz/QEMU 用 `ip=dhcp`(nameserver 來自 DHCP:VZ NAT gateway / slirp 10.0.2.3); +# WHP 的靜態 `ip=` 也會建 /proc/net/pnp,nameserver 來自 cmdline 的 dns0 欄 +# (=10.0.2.3,helper 的 DNS pivot;見 vmm-backend whp_util::kernel_command_line)。 +if [ ! -s /etc/resolv.conf ] && [ -f /proc/net/pnp ]; then + mkdir -p /etc + ln -sf /proc/net/pnp /etc/resolv.conf 2>/dev/null || true +fi + machine="$(uname -m)" case "$machine" in x86_64) arch="x86_64" ;; diff --git a/scripts/qemu-e2e.sh b/scripts/qemu-e2e.sh index 74ae6bd..e7becca 100644 --- a/scripts/qemu-e2e.sh +++ b/scripts/qemu-e2e.sh @@ -75,6 +75,7 @@ DOCKER cat >"$dir/server.py" <<'PY' import json import os +import socket import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -126,6 +127,22 @@ class Handler(BaseHTTPRequestHandler): else: self.send_text(404, "missing\n") return + if parsed.path == "/dns": + # 容器內 DNS 鏈驗證(DESIGN「容器內 DNS」):/etc/resolv.conf 應為 guest-agent + # 注入的內容——QEMU slirp 下 nameserver 必為 10.0.2.3(pnp→init symlink→注入; + # image 殘留不會是這個值)——且外部 hostname 真的解析得動(slirp DNS proxy)。 + try: + rc = Path("/etc/resolv.conf").read_text(encoding="utf-8") + ns = next( + line.split()[1] + for line in rc.splitlines() + if line.startswith("nameserver") + ) + addr = socket.getaddrinfo("example.com", 80)[0][4][0] + self.send_text(200, f"{ns}|{addr}\n") + except Exception as exc: # 失敗細節回給 host 端斷言訊息 + self.send_text(500, f"dns check failed: {exc!r}\n") + return if parsed.path == "/mount": # 讀回多個 bind mount 的內容,驗證 overlay rootfs 之上的 bind 確實生效。 parts = [] @@ -578,6 +595,18 @@ main() { [[ "$mounts_out" == "bind-one|bind-two" ]] \ || die "multi bind-mount atop the overlay rootfs failed: expected 'bind-one|bind-two', got '${mounts_out}'" note "multi bind-mount atop overlay verified: ${mounts_out}" + # 容器內 DNS(resolv.conf 注入鏈,DESIGN「容器內 DNS」):nameserver 應為 slirp 的 + # 10.0.2.3(證明內容來自 pnp→init symlink→guest-agent 注入,而非 image 殘留),且 + # example.com 真的解析得出。外部 DNS 在 CI 偶有抖動,重試幾次再判死。 + local dns_out="" + for _ in 1 2 3; do + dns_out="$(curl -fsS "http://127.0.0.1:${host_port}/dns" || true)" + [[ "$dns_out" == "10.0.2.3|"* ]] && break + sleep 2 + done + [[ "$dns_out" == "10.0.2.3|"* ]] \ + || die "container DNS chain failed: expected 'nameserver 10.0.2.3' + working resolution, got '${dns_out}'" + note "container DNS (resolv.conf injection) verified: ${dns_out}" curl -fsS "http://127.0.0.1:${host_port}/write?value=qemu-first-run" >/dev/null curl -fsS "http://127.0.0.1:${host_port}/shutdown" >/dev/null || true wait_qemu_exit_code 0 "$first_log" "first run" diff --git a/scripts/vz-smoke.sh b/scripts/vz-smoke.sh index 18f3434..9e906c6 100644 --- a/scripts/vz-smoke.sh +++ b/scripts/vz-smoke.sh @@ -56,17 +56,23 @@ note "2/5 編譯 chefer-cli + chefer-runtime(release)" cargo build --release -p chefer-cli -p chefer-runtime --manifest-path "$root/Cargo.toml" # guest-agent 走 bundle 內嵌,且 GUI 修復(WLR_LIBINPUT_NO_DEVICES 等)在 guest-agent 裡—— -# **從原始碼建當前 main 的 guest-agent**(cross,如 release.yml),別用可能過舊的 kit binary。 -# 沒有 cross(需 Docker)時退回 kit 的 guest-agent,並大聲警告可能缺當前修復。 +# **從原始碼建當前 main 的 guest-agent**,別用可能過舊的 kit binary。 +# guest-agent 是純 Rust + rust-lld(.cargo/config.toml 已設 linker),macOS host 原生 +# `cargo build --target -unknown-linux-musl` 即可(實體 Mac 驗證時實測),免 cross/Docker; +# 有 cross 就照 release.yml 用 cross。兩者皆不可行才退回 kit 的 guest-agent 並大聲警告。 musl_target="${arch}-unknown-linux-musl" fresh_agent="" if command -v cross >/dev/null 2>&1; then note "2b/5 cross build guest-agent($musl_target,含當前 GUI 修復)" ( cd "$root" && cross build --release --target "$musl_target" -p guest-agent ) fresh_agent="$root/target/$musl_target/release/guest-agent" +elif command -v rustup >/dev/null 2>&1 && rustup target add "$musl_target" >/dev/null 2>&1; then + note "2b/5 原生 cargo build guest-agent($musl_target,rust-lld,免 cross/Docker)" + ( cd "$root" && cargo build --release --target "$musl_target" -p guest-agent ) + fresh_agent="$root/target/$musl_target/release/guest-agent" else - echo "vz-smoke: warning: 找不到 cross(cargo install cross,需 Docker)——改用 kit 的 guest-agent-$arch," >&2 - echo " 它可能早於當前 main 的 GUI 修復(如 WLR_LIBINPUT_NO_DEVICES boot-race fix)。要驗證當前碼請裝 cross。" >&2 + echo "vz-smoke: warning: 無 cross 也無法以 rustup 加 $musl_target target——改用 kit 的 guest-agent-$arch," >&2 + echo " 它可能早於當前 main 的 GUI 修復(如 WLR_LIBINPUT_NO_DEVICES boot-race fix)。" >&2 fi note "3/5 組 smoke kit(runtime + helper + appliance$([[ $gui == 1 ]] && echo ' + gui overlay'))" @@ -125,7 +131,8 @@ services: platform: $platform interface_mode: none ports: ["18080:8080"] - cmd: ["sh", "-c", "echo VZ_SMOKE_SERVICE_UP; httpd -f -p 8080"] + # alpine 基底沒有 httpd(在 busybox-extras),用 busybox nc 湊一個最小 HTTP 服務。 + cmd: ["sh", "-c", "echo VZ_SMOKE_SERVICE_UP; while true; do printf 'HTTP/1.0 200 OK\\r\\n\\r\\nok\\n' | nc -l -p 8080; done"] YML "$root/target/release/chefer-cli" build "$work/smoke.yml" --out "$work/dist" exe="$work/dist/vz-smoke/vz-smoke_${arch}-apple-darwin" @@ -152,6 +159,9 @@ done kill -INT "$app_pid" 2>/dev/null || true wait "$app_pid" 2>/dev/null || true trap - EXIT +# 對 runtime 單發 SIGINT(非終端機 Ctrl+C 的整個 process group)不會帶走 helper/VM, +# 實測會殘留吃 CPU——收尾把本次 work dir 下的 helper 一併清掉。 +pkill -f "$work/.*chefer-vz-helper" 2>/dev/null || true grep -q "CHEFER_GUEST_IP=" "$log" || die "console 沒出現 CHEFER_GUEST_IP 標記(埠轉發不會啟動);log: $log" [[ $ok_port == 1 ]] || die "TCP 埠轉發失敗(curl 127.0.0.1:18080 不通);log: $log" note "驗證二通過:服務常駐 ✓ CHEFER_GUEST_IP 標記 ✓ TCP 轉發 ✓" @@ -177,8 +187,10 @@ YML [ ] 出現以 app 名為標題的視窗,xclock 指針在動(VZVirtualMachineView 顯示) [ ] 滑鼠移動/點擊、鍵盤輸入進得了 guest(HID) [ ] host 複製文字 → guest 內可貼上;guest 複製 → host 可貼上(剪貼簿,含 PNG 圖) - [ ] macOS 14+:拖拉視窗角改變大小 → guest 畫面跟著換解析度(動態解析度) [ ] 關窗 → app 乾淨結束、行程退出(關窗 = app 結束語意) + (動態解析度已實測收斂,不必再驗:開機時 guest 模式=視窗 Retina 像素尺寸、 + CHEFER_VZ_GUI_SIZE 生效;拖拉時只縮放不 re-modeset——cage 啟動後不跟模式變更, + 與 WHP 同一 guest 端限制,見 DESIGN §6。) CHECK CHEFER_VZ_EXPERIMENTAL=1 "$work/dist/vz-gui-smoke/vz-gui-smoke_${arch}-apple-darwin" --extract-dir "$work/extract-gui" note "GUI app 已結束(exit $?)——請依上方清單回填結果" diff --git a/vz-helper/main.swift b/vz-helper/main.swift index a2c741e..a5582ce 100644 --- a/vz-helper/main.swift +++ b/vz-helper/main.swift @@ -173,7 +173,19 @@ config.memoryBalloonDevices = [VZVirtioTraditionalMemoryBalloonDeviceConfigurati // GUI:virtio-gpu 一個 scanout + USB 鍵盤 + 絕對座標指標(VZVirtualMachineView 自動把 // HID 事件轉進這些裝置;絕對座標同 WHP 的 tablet 選擇,避免滑鼠捕捉問題)。 +// +// 動態解析度開關(實體 Mac 實測後的取捨):guest 的 cage kiosk compositor 啟動後 +// **不跟隨**模式變更(見 DESIGN §6 ④)——此時開 automaticallyReconfiguresDisplay 反而 +// 讓 view 停止縮放、等一個永遠不會來的 guest re-modeset,拖拉視窗時畫面尺寸不同步。 +// 且它推給 guest 的是 Retina **實體像素**尺寸(點 ×2),Linux guest 無 HiDPI 概念、 +// cage 也設不了 output scale → 游標與 UI 內容都只剩一半大(實測使用者直接抱怨)。 +// 故預設:scanout = 視窗「點」數(guest 看到 WxH 的螢幕、內容尺寸與一般視窗一致), +// automaticallyReconfiguresDisplay 關、由 view 縮放(Retina 上 2× 放大,略軟但尺寸正確; +// 拖拉視窗即時跟手)。等 guest compositor 支援 re-modeset + HiDPI output scale 後, +// 以 CHEFER_VZ_DYNAMIC_RESOLUTION=1 切回真動態解析度(macOS 14+)。 let (guiW, guiH) = guiSize() +let dynamicResolution = + ProcessInfo.processInfo.environment["CHEFER_VZ_DYNAMIC_RESOLUTION"] == "1" if guiMode { let gpu = VZVirtioGraphicsDeviceConfiguration() gpu.scanouts = [ @@ -498,19 +510,24 @@ if guiMode { view.virtualMachine = vm view.autoresizingMask = [.width, .height] - // 視窗預設 1:1 scanout 尺寸。動態解析度(DESIGN ④「縮放」):macOS 14+ 的 - // VZVirtualMachineView 支援 automaticallyReconfiguresDisplay——使用者調整視窗 - // 尺寸時由 VZ 對 guest 的 virtio-gpu 發顯示重組態,guest DRM hotplug 換模式、 - // cage/wlroots 跟隨新 output 尺寸 → 開放 .resizable。macOS 13 無此 API, - // 維持固定尺寸(resize 只會縮放失真,故不開)。 + // 視窗以「點」開 guiW×guiH、scanout 同為 guiW×guiH → guest 內容尺寸與一般視窗 + // 一致(Retina 上由 view 2× 放大,略軟但游標/UI 大小正確)。預設 + // automaticallyReconfiguresDisplay **關**:view 縮放跟隨視窗(拖拉即時跟手)—— + // guest 的 cage 不跟模式變更,開了反而畫面尺寸不同步、且內容縮成一半(實測)。 + // CHEFER_VZ_DYNAMIC_RESOLUTION=1 + macOS 14+ 才走真動態解析度。 let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: CGFloat(guiW), height: CGFloat(guiH)), - styleMask: [.titled, .closable, .miniaturizable], + styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false) - if #available(macOS 14.0, *) { + if #available(macOS 14.0, *), dynamicResolution { view.automaticallyReconfiguresDisplay = true - window.styleMask.insert(.resizable) + } else { + // guest 不 re-modeset(cage 固定模式)→ view 等比縮放;視窗內容區若偏離 guest + // 的長寬比會 letterbox 出黑邊(實測:拖到近全螢幕時標題列/選單列吃掉高度、比例 + // 跑掉)。鎖定內容區長寬比=guest 比例,拖拉永遠無縫。真動態解析度模式下不鎖 + // (guest 會跟著換模式,自由比例才有意義)。 + window.contentAspectRatio = NSSize(width: CGFloat(guiW), height: CGFloat(guiH)) } window.title = guiTitle window.contentView = view