From db119089cd9c5bfbf3a19518e480429e4074dd7c Mon Sep 17 00:00:00 2001 From: czmDeRepository <56431414+czmDeRepository@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:12:31 +0800 Subject: [PATCH 1/3] fix: use CNI MAC for macOS guests Read the live eth0 MAC from the CNI network namespace before constructing the QEMU spec. This keeps guest frames aligned with the address allowed by macspoofchk while retaining the SMBIOS ROM identity. --- cmd/vm/lifecycle.go | 7 ++++++- cmd/vm/net_linux.go | 43 +++++++++++++++++++++++++++++++++++++++- cmd/vm/net_linux_test.go | 10 ++++++++++ cmd/vm/net_other.go | 2 ++ docs/networking.md | 9 +++++++-- 5 files changed, 67 insertions(+), 4 deletions(-) diff --git a/cmd/vm/lifecycle.go b/cmd/vm/lifecycle.go index f905beb..a8343fb 100644 --- a/cmd/vm/lifecycle.go +++ b/cmd/vm/lifecycle.go @@ -154,7 +154,9 @@ func (h *Handler) create(cmd *cobra.Command, image string) (*record, error) { if r.DataDisks, err = createDataDisks(ctx, dir, diskSpecs); err != nil { return nil, err } - // OpenCore before networking: a random SMBIOS sets r.MAC = ROM, which prepareNet keeps as the guest MAC. + // OpenCore before networking: a random SMBIOS seeds r.MAC from ROM. CNI + // replaces only the guest NIC MAC with its anti-spoof-approved address; the + // SMBIOS record retains the unique ROM identity. randomSMBIOS, _ := cmd.Flags().GetBool("random-smbios") if err = prepareOpenCore(ctx, dir, oc, randomSMBIOS, r); err != nil { return nil, err @@ -177,6 +179,9 @@ func (h *Handler) launch(cmd *cobra.Command, dir string, r *record) error { logger.Warnf(ctx, "set kvm ignore_msrs for AMD: %v", err) } } + if err := syncGuestMAC(ctx, r); err != nil { + return err + } spec := qemu.Spec{ Name: r.Name, Disk: r.Disk, OpenCore: r.OpenCore, OVMFCode: r.OVMFCode, OVMFVars: r.OVMFVars, CPUs: r.CPUs, Memory: r.Memory, VNCDisp: r.VNCDisp, SSHPort: r.SSHPort, MAC: r.MAC, VNCPass: r.VNCPass, diff --git a/cmd/vm/net_linux.go b/cmd/vm/net_linux.go index 2910fe9..08cb88b 100644 --- a/cmd/vm/net_linux.go +++ b/cmd/vm/net_linux.go @@ -5,8 +5,10 @@ package vm import ( "cmp" "context" + "encoding/json" "errors" "fmt" + "net" "os/exec" "path/filepath" @@ -80,7 +82,8 @@ func provisionNet(cmd *cobra.Command, r *record) (tap, netns, mac string, err er if len(cfgs) == 0 { return "", "", "", errors.New("network add returned no NIC") } - // SMBIOS ROM wins as the guest MAC; cocoon's generated MAC is only a fallback + // SMBIOS ROM wins initially; launch re-reads CNI's actual eth0 MAC after + // the plugin has installed its per-veth anti-spoof rule. mac = cmp.Or(r.MAC, cfgs[0].MAC) return cfgs[0].TAP, nsPath, mac, nil } @@ -169,6 +172,44 @@ func ensureNetnsLoopback(ctx context.Context, r *record) { _ = exec.Command("ip", "netns", "exec", ns, "ip", "link", "set", "lo", "up").Run() } +// syncGuestMAC makes QEMU use the MAC the CNI bridge plugin assigned to eth0. +// macspoofchk installs a fixed allow rule for that address during CNI ADD, so +// using the SMBIOS ROM MAC would drop DHCP before it reaches cni0. Reading the +// live netns also migrates records created by older cocoon-macos versions. +func syncGuestMAC(ctx context.Context, r *record) error { + if r.Netns == "" { + return nil + } + ns := filepath.Base(r.Netns) + out, err := exec.CommandContext(ctx, "ip", "netns", "exec", ns, "ip", "-j", "link", "show", "dev", "eth0").Output() + if err != nil { + return fmt.Errorf("inspect CNI eth0 in %s: %w", ns, err) + } + mac, err := parseLinkMAC(out) + if err != nil { + return fmt.Errorf("inspect CNI eth0 in %s: %w", ns, err) + } + r.MAC = mac + return nil +} + +func parseLinkMAC(data []byte) (string, error) { + var links []struct { + Address string `json:"address"` + } + if err := json.Unmarshal(data, &links); err != nil { + return "", fmt.Errorf("decode ip link JSON: %w", err) + } + if len(links) != 1 || links[0].Address == "" { + return "", fmt.Errorf("expected one interface with an address, got %d", len(links)) + } + hw, err := net.ParseMAC(links[0].Address) + if err != nil { + return "", fmt.Errorf("parse MAC %q: %w", links[0].Address, err) + } + return hw.String(), nil +} + // launchCmd builds the qemu exec, wrapped in `ip netns exec` for CNI so -netdev tap finds the in-netns TAP (the fork-safe, cgo-free way to daemonize into a netns). func launchCmd(r *record, args []string) *exec.Cmd { if r.Netns != "" { diff --git a/cmd/vm/net_linux_test.go b/cmd/vm/net_linux_test.go index fbe28dd..cec6461 100644 --- a/cmd/vm/net_linux_test.go +++ b/cmd/vm/net_linux_test.go @@ -20,3 +20,13 @@ func TestNetConfScope(t *testing.T) { t.Errorf("NetnsPrefix() = %q, want %q", got, want) } } + +func TestParseLinkMAC(t *testing.T) { + mac, err := parseLinkMAC([]byte(`[{"ifindex":2,"ifname":"eth0","address":"AE:77:7B:3B:49:88"}]`)) + if err != nil { + t.Fatalf("parseLinkMAC: %v", err) + } + if want := "ae:77:7b:3b:49:88"; mac != want { + t.Errorf("MAC = %q, want %q", mac, want) + } +} diff --git a/cmd/vm/net_other.go b/cmd/vm/net_other.go index d3982aa..a07aee5 100644 --- a/cmd/vm/net_other.go +++ b/cmd/vm/net_other.go @@ -26,3 +26,5 @@ func launchCmd(_ *record, args []string) *exec.Cmd { } func ensureNetnsLoopback(_ context.Context, _ *record) {} + +func syncGuestMAC(_ context.Context, _ *record) error { return nil } diff --git a/docs/networking.md b/docs/networking.md index 979f4d7..64c7a5e 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -11,8 +11,13 @@ `tap`/`bridge`/`cni` make a macOS VM join the **same** forwarding plane as cocoon's Cloud Hypervisor / Firecracker VMs on the node, so the guest can DHCP a **real LAN IP** from the upstream -network. The guest NIC MAC stays equal to the SMBIOS ROM. Auto-create (`bridge`/`cni`) is Linux-only -(needs `CAP_NET_ADMIN`); `user` and a pre-created `--tap` work everywhere. +network. In `tap` and `bridge` mode the guest NIC MAC stays equal to the SMBIOS ROM. Auto-create +(`bridge`/`cni`) is Linux-only (needs `CAP_NET_ADMIN`); `user` and a pre-created `--tap` work +everywhere. + +In `cni` mode, the guest NIC uses the MAC allocated by the CNI bridge plugin rather than the SMBIOS +ROM value. The plugin installs a per-veth anti-spoof rule during CNI ADD, so QEMU must use that same +address for DHCP traffic to reach `cni0`. The SMBIOS ROM identity remains unique and unchanged. Auto-created devices carry cocoon-macos's own host name family (`net_scope` `cm`: TAPs `cm-`, netns `cm-`), so a cocoon daemon's GC on the same node never reads a live From aeaf5080f0c5c731df6299ad2ab3911bf9d7fb92 Mon Sep 17 00:00:00 2001 From: czmDeRepository <56431414+czmDeRepository@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:32:51 +0800 Subject: [PATCH 2/3] review: use CNI-provided guest MAC --- cmd/vm/lifecycle.go | 7 +----- cmd/vm/net_linux.go | 47 ++++------------------------------------ cmd/vm/net_linux_test.go | 10 --------- cmd/vm/net_other.go | 2 -- cmd/vm/utils.go | 4 +--- docs/index.md | 2 +- docs/networking.md | 5 ++--- qemu/launch.go | 2 +- qemu/smbios.go | 4 ++-- 9 files changed, 12 insertions(+), 71 deletions(-) diff --git a/cmd/vm/lifecycle.go b/cmd/vm/lifecycle.go index a8343fb..c3745ec 100644 --- a/cmd/vm/lifecycle.go +++ b/cmd/vm/lifecycle.go @@ -154,9 +154,7 @@ func (h *Handler) create(cmd *cobra.Command, image string) (*record, error) { if r.DataDisks, err = createDataDisks(ctx, dir, diskSpecs); err != nil { return nil, err } - // OpenCore before networking: a random SMBIOS seeds r.MAC from ROM. CNI - // replaces only the guest NIC MAC with its anti-spoof-approved address; the - // SMBIOS record retains the unique ROM identity. + // OpenCore seeds the default guest MAC from ROM before network provisioning. randomSMBIOS, _ := cmd.Flags().GetBool("random-smbios") if err = prepareOpenCore(ctx, dir, oc, randomSMBIOS, r); err != nil { return nil, err @@ -179,9 +177,6 @@ func (h *Handler) launch(cmd *cobra.Command, dir string, r *record) error { logger.Warnf(ctx, "set kvm ignore_msrs for AMD: %v", err) } } - if err := syncGuestMAC(ctx, r); err != nil { - return err - } spec := qemu.Spec{ Name: r.Name, Disk: r.Disk, OpenCore: r.OpenCore, OVMFCode: r.OVMFCode, OVMFVars: r.OVMFVars, CPUs: r.CPUs, Memory: r.Memory, VNCDisp: r.VNCDisp, SSHPort: r.SSHPort, MAC: r.MAC, VNCPass: r.VNCPass, diff --git a/cmd/vm/net_linux.go b/cmd/vm/net_linux.go index 08cb88b..e51141b 100644 --- a/cmd/vm/net_linux.go +++ b/cmd/vm/net_linux.go @@ -5,10 +5,8 @@ package vm import ( "cmp" "context" - "encoding/json" "errors" "fmt" - "net" "os/exec" "path/filepath" @@ -82,9 +80,10 @@ func provisionNet(cmd *cobra.Command, r *record) (tap, netns, mac string, err er if len(cfgs) == 0 { return "", "", "", errors.New("network add returned no NIC") } - // SMBIOS ROM wins initially; launch re-reads CNI's actual eth0 MAC after - // the plugin has installed its per-veth anti-spoof rule. - mac = cmp.Or(r.MAC, cfgs[0].MAC) + mac = cfgs[0].MAC + if r.NetMode != netCNI { + mac = cmp.Or(r.MAC, mac) + } return cfgs[0].TAP, nsPath, mac, nil } @@ -172,44 +171,6 @@ func ensureNetnsLoopback(ctx context.Context, r *record) { _ = exec.Command("ip", "netns", "exec", ns, "ip", "link", "set", "lo", "up").Run() } -// syncGuestMAC makes QEMU use the MAC the CNI bridge plugin assigned to eth0. -// macspoofchk installs a fixed allow rule for that address during CNI ADD, so -// using the SMBIOS ROM MAC would drop DHCP before it reaches cni0. Reading the -// live netns also migrates records created by older cocoon-macos versions. -func syncGuestMAC(ctx context.Context, r *record) error { - if r.Netns == "" { - return nil - } - ns := filepath.Base(r.Netns) - out, err := exec.CommandContext(ctx, "ip", "netns", "exec", ns, "ip", "-j", "link", "show", "dev", "eth0").Output() - if err != nil { - return fmt.Errorf("inspect CNI eth0 in %s: %w", ns, err) - } - mac, err := parseLinkMAC(out) - if err != nil { - return fmt.Errorf("inspect CNI eth0 in %s: %w", ns, err) - } - r.MAC = mac - return nil -} - -func parseLinkMAC(data []byte) (string, error) { - var links []struct { - Address string `json:"address"` - } - if err := json.Unmarshal(data, &links); err != nil { - return "", fmt.Errorf("decode ip link JSON: %w", err) - } - if len(links) != 1 || links[0].Address == "" { - return "", fmt.Errorf("expected one interface with an address, got %d", len(links)) - } - hw, err := net.ParseMAC(links[0].Address) - if err != nil { - return "", fmt.Errorf("parse MAC %q: %w", links[0].Address, err) - } - return hw.String(), nil -} - // launchCmd builds the qemu exec, wrapped in `ip netns exec` for CNI so -netdev tap finds the in-netns TAP (the fork-safe, cgo-free way to daemonize into a netns). func launchCmd(r *record, args []string) *exec.Cmd { if r.Netns != "" { diff --git a/cmd/vm/net_linux_test.go b/cmd/vm/net_linux_test.go index cec6461..fbe28dd 100644 --- a/cmd/vm/net_linux_test.go +++ b/cmd/vm/net_linux_test.go @@ -20,13 +20,3 @@ func TestNetConfScope(t *testing.T) { t.Errorf("NetnsPrefix() = %q, want %q", got, want) } } - -func TestParseLinkMAC(t *testing.T) { - mac, err := parseLinkMAC([]byte(`[{"ifindex":2,"ifname":"eth0","address":"AE:77:7B:3B:49:88"}]`)) - if err != nil { - t.Fatalf("parseLinkMAC: %v", err) - } - if want := "ae:77:7b:3b:49:88"; mac != want { - t.Errorf("MAC = %q, want %q", mac, want) - } -} diff --git a/cmd/vm/net_other.go b/cmd/vm/net_other.go index a07aee5..d3982aa 100644 --- a/cmd/vm/net_other.go +++ b/cmd/vm/net_other.go @@ -26,5 +26,3 @@ func launchCmd(_ *record, args []string) *exec.Cmd { } func ensureNetnsLoopback(_ context.Context, _ *record) {} - -func syncGuestMAC(_ context.Context, _ *record) error { return nil } diff --git a/cmd/vm/utils.go b/cmd/vm/utils.go index 42bd11b..a363fa2 100644 --- a/cmd/vm/utils.go +++ b/cmd/vm/utils.go @@ -99,9 +99,7 @@ func applyNet(cmd *cobra.Command, r *record) error { if err != nil { return err } - if r.MAC == "" { - r.MAC = mac - } + r.MAC = mac if netTap != "" { r.Tap, r.Netns, r.TapOwned = netTap, netns, userTap == "" } diff --git a/docs/index.md b/docs/index.md index 1b4b273..60d9e38 100644 --- a/docs/index.md +++ b/docs/index.md @@ -46,7 +46,7 @@ cocoon-macos CLI ──► image: pull the golden macOS qcow2 from ghcr (paralle - **COW overlays** — an instant copy-on-write clone of the immutable golden base per VM - **Per-VM Apple identity** — `--random-smbios` injects a unique serial/MLB/UUID/ROM - (guest MAC = ROM) into a per-VM OpenCore, so clones never share a serial + into a per-VM OpenCore; the ROM is the guest MAC outside CNI - **CNI networking with TC redirect** — `--net cni` joins cocoon's forwarding plane so the guest DHCPs a real LAN IP; also `user`/`tap`/`bridge` - **Reachable VNC** — loopback VNC on user/tap/bridge; a host-side proxy fronts diff --git a/docs/networking.md b/docs/networking.md index 64c7a5e..75b4faf 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -15,9 +15,8 @@ network. In `tap` and `bridge` mode the guest NIC MAC stays equal to the SMBIOS (`bridge`/`cni`) is Linux-only (needs `CAP_NET_ADMIN`); `user` and a pre-created `--tap` work everywhere. -In `cni` mode, the guest NIC uses the MAC allocated by the CNI bridge plugin rather than the SMBIOS -ROM value. The plugin installs a per-veth anti-spoof rule during CNI ADD, so QEMU must use that same -address for DHCP traffic to reach `cni0`. The SMBIOS ROM identity remains unique and unchanged. +In `cni` mode, the guest NIC uses the MAC CNI assigned to `eth0` because `macspoofchk` allow-lists +that address; the SMBIOS ROM identity remains unchanged. Auto-created devices carry cocoon-macos's own host name family (`net_scope` `cm`: TAPs `cm-`, netns `cm-`), so a cocoon daemon's GC on the same node never reads a live diff --git a/qemu/launch.go b/qemu/launch.go index 524bf7d..3ff2c1c 100644 --- a/qemu/launch.go +++ b/qemu/launch.go @@ -36,7 +36,7 @@ type Spec struct { OpenCore string OVMFCode string OVMFVars string - MAC string // set to the SMBIOS ROM for --random-smbios + MAC string // guest NIC MAC: SMBIOS ROM by default, CNI eth0 in CNI mode Tap string // pre-created host TAP; set => -netdev tap (bridged/routed), empty => user-mode SLIRP MonSock string diff --git a/qemu/smbios.go b/qemu/smbios.go index 591f90c..8e8812d 100644 --- a/qemu/smbios.go +++ b/qemu/smbios.go @@ -17,7 +17,7 @@ type SMBIOS struct { Serial string `json:"serial"` // SystemSerialNumber MLB string `json:"mlb"` // board serial UUID string `json:"uuid"` // SystemUUID - ROM string `json:"rom"` // 6-byte ROM as hex; also the guest en0 MAC + ROM string `json:"rom"` // 6-byte ROM as hex; default guest NIC MAC outside CNI } // RandomSMBIOS generates a unique per-VM identity (fixed model + random serial/MLB/UUID/ROM). @@ -41,7 +41,7 @@ func RandomSMBIOS() (SMBIOS, error) { return SMBIOS{Model: smbiosModel, Serial: serial, MLB: mlb, UUID: uuid, ROM: rom}, nil } -// MAC returns the ROM formatted as the guest NIC MAC (so en0 == ROM, as iServices expects). +// MAC returns the ROM formatted as the default guest NIC MAC; CNI supplies its own runtime MAC. func (s SMBIOS) MAC() string { b, err := hex.DecodeString(s.ROM) if err != nil || len(b) != 6 { From b4e72be86738bfb882e2990fe3c10a417ec64603 Mon Sep 17 00:00:00 2001 From: czmDeRepository <56431414+czmDeRepository@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:14:50 +0800 Subject: [PATCH 3/3] docs: clarify CNI guest MAC behavior --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fb79541..519730a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ macOS VM engine for x86 Linux/KVM, built on [cocoon](https://github.com/cocoonst - **Fully-automated macOS install** — a GitHub Action boots OpenCore, erases APFS, drives the installer by OCR, and publishes a golden qcow2 (Tahoe 26 / Sequoia 15) to ghcr; the `:26` image is **SSH-ready** (`cocoon`/`cocoon`) - **Docker-like CLI** — `create`, `run`, `start`, `stop`, `list`, `inspect`, `console`, `rm`, `snapshot`, `restore`, `clone` - **Parallel Range image pull** — the multi-GB qcow2 is pulled in 8 concurrent HTTP Range chunks with an sha256 digest check (oras-go for auth; no `oras` binary needed) -- **Per-VM Apple identity** — `--random-smbios` injects a unique serial/MLB/UUID/ROM (guest MAC = ROM) into a per-VM OpenCore, so clones never share a serial +- **Per-VM Apple identity** — `--random-smbios` injects a unique serial/MLB/UUID/ROM into a per-VM OpenCore; the ROM is the guest MAC outside CNI, so clones never share a serial - **CNI networking with TC redirect** — `--net cni` joins cocoon's forwarding plane so the guest DHCPs a real LAN IP; also user/tap/bridge; reachable VNC (launch-scoped, password-gated on CNI) - **Snapshot, clone & data disks** — offline qcow2-internal snapshots, CoW clones that cold-boot a fresh Apple identity, and up to 4 extra AHCI data disks - **Intel & AMD, built on cocoon** — one boot recipe boots both hosts; imports cocoon's `cloudimg` store, `network` plane, and copy-on-write conventions