From d86537f42de31d1fc2f5fcb0c3a2cd7d3ffa2436 Mon Sep 17 00:00:00 2001 From: CMGS Date: Fri, 3 Jul 2026 21:32:17 +0800 Subject: [PATCH 1/8] feat(vm): reach a --net cni VM's VNC over a host-side proxy QEMU runs inside the CNI netns, so its -vnc 127.0.0.1 bind is unreachable off-box. Bind it to a unix socket instead and front that on a host TCP port with a small detached proxy, torn down with the VM. Also always pass -display none: without --vnc, qemu fell back to GTK and aborted headless. --- cmd/vm/commands.go | 1 + cmd/vm/lifecycle.go | 13 ++++++ cmd/vm/vnc.go | 96 +++++++++++++++++++++++++++++++++++++++++++++ qemu/launch.go | 15 ++++++- 4 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 cmd/vm/vnc.go diff --git a/cmd/vm/commands.go b/cmd/vm/commands.go index 2bd6b0d..353ea08 100644 --- a/cmd/vm/commands.go +++ b/cmd/vm/commands.go @@ -113,6 +113,7 @@ func Command(h Actions) *cobra.Command { } addVMFlags(cloneCmd) // loader is inherited from SRC + vmCmd.AddCommand(vncProxyCommand()) vmCmd.AddCommand(createCmd, runCmd, startCmd, stopCmd, listCmd, inspectCmd, consoleCmd, rmCmd, snapshotCmd, restoreCmd, cloneCmd) return vmCmd diff --git a/cmd/vm/lifecycle.go b/cmd/vm/lifecycle.go index 1997e42..1200239 100644 --- a/cmd/vm/lifecycle.go +++ b/cmd/vm/lifecycle.go @@ -76,6 +76,7 @@ func (h *Handler) Stop(cmd *cobra.Command, args []string) error { return err } terminate(ctx, r, grace) + stopVNCProxy(dir) r.PID = 0 return saveRec(dir, r) }); err != nil { @@ -103,6 +104,7 @@ func (h *Handler) RM(cmd *cobra.Command, args []string) error { if err := withVMLock(ctx, dir, func() error { if r, err := loadRec(dir); err == nil { terminate(ctx, r, grace) + stopVNCProxy(dir) teardownNet(cmd, r) } if err := os.RemoveAll(dir); err != nil { @@ -183,6 +185,11 @@ func (h *Handler) launch(cmd *cobra.Command, dir string, r *record) error { DataDisks: r.DataDisks, MonSock: filepath.Join(dir, "monitor.sock"), QMPSock: filepath.Join(dir, "qmp.sock"), } + // CNI runs qemu in a netns, so a 127.0.0.1 VNC binds unreachably there; route it through a unix + // socket that a host-side proxy fronts on a TCP port (see startVNCProxy). + if r.Netns != "" && r.VNCDisp >= 0 { + spec.VNCSock = filepath.Join(dir, vncSockName) + } pidfile := filepath.Join(dir, "qemu.pid") args := append(spec.Args(), "-daemonize", "-pidfile", pidfile) ensureNetnsLoopback(ctx, r) // CNI: a fresh netns has lo DOWN, so qemu's -vnc 127.0.0.1 would fail to bind @@ -193,6 +200,7 @@ func (h *Handler) launch(cmd *cobra.Command, dir string, r *record) error { c := launchCmd(r, args) // CNI: wraps in `ip netns exec` so -netdev tap finds the in-netns TAP c.Stdout, c.Stderr = os.Stdout, os.Stderr if err := c.Run(); err != nil { + stopVNCProxy(dir) teardownNet(cmd, r) // don't leak an auto-created TAP/netns on a failed launch return fmt.Errorf("launch qemu: %w", err) } @@ -204,6 +212,11 @@ func (h *Handler) launch(cmd *cobra.Command, dir string, r *record) error { logger.Warnf(ctx, "set vnc password: %v", err) } } + if spec.VNCSock != "" { + if err := startVNCProxy(dir, r.VNCDisp); err != nil { + logger.Warnf(ctx, "start vnc proxy: %v", err) + } + } return saveRec(dir, r) } diff --git a/cmd/vm/vnc.go b/cmd/vm/vnc.go new file mode 100644 index 0000000..c471b01 --- /dev/null +++ b/cmd/vm/vnc.go @@ -0,0 +1,96 @@ +package vm + +import ( + "context" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "syscall" + "time" + + "github.com/spf13/cobra" + + "github.com/cocoonstack/cocoon/utils" +) + +// vncSockName / vncProxyPID live in the per-VM dir. The proxy fronts the netns-local QEMU VNC unix +// socket (see qemu.Spec.VNCSock) on a host TCP port so a CNI VM's console is reachable off-box. +const ( + vncSockName = "vnc.sock" + vncProxyPID = "vnc-proxy.pid" + vncProxyOp = "_vnc-proxy" + vncBasePort = 5900 +) + +// vncProxyCommand is the hidden re-exec target that runs the forwarder for its lifetime. +func vncProxyCommand() *cobra.Command { + return &cobra.Command{ + Use: vncProxyOp + " ", Hidden: true, Args: cobra.ExactArgs(2), + RunE: func(_ *cobra.Command, args []string) error { return runVNCProxy(args[0], args[1]) }, + } +} + +// startVNCProxy re-execs this binary as the hidden proxy, detached, in the HOST netns (so its TCP +// listener is reachable off-box while qemu's VNC stays inside the CNI netns). Idempotent. +func startVNCProxy(dir string, disp int) error { + sock := filepath.Join(dir, vncSockName) + if err := utils.WaitFor(context.Background(), 5*time.Second, 100*time.Millisecond, func() (bool, error) { + info, statErr := os.Stat(sock) // qemu -daemonize creates the socket slightly after fork + return statErr == nil && info.Mode()&os.ModeSocket != 0, nil + }); err != nil { + return fmt.Errorf("vnc socket %s not created by qemu: %w", sock, err) + } + self, err := os.Executable() + if err != nil { + return err + } + listen := fmt.Sprintf("0.0.0.0:%d", vncBasePort+disp) + c := exec.Command(self, "vm", vncProxyOp, listen, sock) + c.SysProcAttr = &syscall.SysProcAttr{Setsid: true} // survive the parent CLI exit + if err := c.Start(); err != nil { + return err + } + return utils.WritePIDFile(filepath.Join(dir, vncProxyPID), c.Process.Pid) +} + +// stopVNCProxy kills a running proxy (best-effort) and removes its pidfile. +func stopVNCProxy(dir string) { + pidPath := filepath.Join(dir, vncProxyPID) + if pid, err := utils.ReadPIDFile(pidPath); err == nil { + _ = syscall.Kill(pid, syscall.SIGTERM) + } + _ = os.Remove(pidPath) +} + +func runVNCProxy(listen, sock string) error { + l, err := net.Listen("tcp", listen) + if err != nil { + return fmt.Errorf("listen %s: %w", listen, err) + } + defer func() { _ = l.Close() }() + for { + client, err := l.Accept() + if err != nil { + return err + } + go pipeVNC(client, sock) + } +} + +// pipeVNC bridges one client connection to the VM's VNC unix socket, copying both directions. +func pipeVNC(client net.Conn, sock string) { + defer func() { _ = client.Close() }() + backend, err := net.Dial("unix", sock) + if err != nil { + return + } + defer func() { _ = backend.Close() }() + done := make(chan struct{}, 2) + cp := func(dst, src net.Conn) { _, _ = io.Copy(dst, src); done <- struct{}{} } + go cp(backend, client) + go cp(client, backend) + <-done +} diff --git a/qemu/launch.go b/qemu/launch.go index 623b9a1..b7fd790 100644 --- a/qemu/launch.go +++ b/qemu/launch.go @@ -42,6 +42,7 @@ type Spec struct { CPUs int Memory string // MiB, e.g. "8192" VNCDisp int // n => host 127.0.0.1:590n; <0 disables + VNCSock string // when set, bind VNC to this unix socket instead of 127.0.0.1 (CNI: fronted by vncProxy) VNCPass string // set via the monitor post-launch (macOS Screen Sharing needs password auth) SSHPort int // host port forwarded to guest :22; 0 disables Hugepages bool // needs host hugepages reserved; off => default RAM @@ -127,14 +128,24 @@ func (s Spec) Args() []string { nic += ",mac=" + s.MAC } a = append(a, "-device", nic) + // Always headless: the CLI has no display, and without -display QEMU defaults to GTK and aborts + // ("gtk initialization failed") — which bit any VM launched without --vnc. + a = append(a, "-display", "none") if s.VNCDisp >= 0 { - vnc := fmt.Sprintf("127.0.0.1:%d", s.VNCDisp) + // CNI runs qemu inside a netns, so a 127.0.0.1 VNC would only be reachable there; bind to a + // unix socket (visible on the host FS across namespaces) that vncProxy fronts on a host port. + var vnc string + if s.VNCSock != "" { + vnc = "unix:" + s.VNCSock + } else { + vnc = fmt.Sprintf("127.0.0.1:%d", s.VNCDisp) + } if s.VNCPass != "" { vnc += ",password=on" // macOS Screen Sharing can't use QEMU's bare "None" auth } // -k en-us maps received VNC keysyms through the en-US keymap so shifted keys aren't dropped // (the guacamole "keyboard garbled" bug: V->v, &->7). - a = append(a, "-k", "en-us", "-display", "none", "-vnc", vnc) + a = append(a, "-k", "en-us", "-vnc", vnc) } if s.MonSock != "" { a = append(a, "-monitor", "unix:"+s.MonSock+",server,nowait") From 92cc4f4ee8131e48a5f4ca21198099e18bcef28a Mon Sep 17 00:00:00 2001 From: CMGS Date: Fri, 3 Jul 2026 21:32:17 +0800 Subject: [PATCH 2/8] fix(vm): boot OpenCore with no picker to avoid a headless stall Timeout alone isn't enough: OpenCanopy cancels its countdown on the stray input from USB device enumeration and then waits forever. ShowPicker=false boots the default entry immediately with no picker UI. --- qemu/inject.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/qemu/inject.go b/qemu/inject.go index 8b9d9c7..be3e6d9 100644 --- a/qemu/inject.go +++ b/qemu/inject.go @@ -123,10 +123,11 @@ func patchPlist(path string, sm *SMBIOS) error { g["ROM"] = rom g["SpoofVendor"] = true } - // Auto-boot the installed macOS: hide the EFI/recovery aux entry + a short timeout, so the VM - // never stalls at the OpenCore picker (which can't be driven reliably headlessly — a missed - // sendkey boots the dead EFI entry and the VM never reaches macOS). + // Boot the installed macOS with no picker UI: ShowPicker=false makes OpenCore boot the default + // entry straight away. A visible picker can't be driven reliably headlessly — OpenCanopy cancels + // its Timeout countdown on the stray input from USB device enumeration and then waits forever. boot := ensureSubMap(ensureSubMap(cfg, "Misc"), "Boot") + boot["ShowPicker"] = false boot["HideAuxiliary"] = true boot["Timeout"] = uint64(5) ensureSubMap(ensureSubMap(cfg, "UEFI"), "Quirks")["RequestBootVarRouting"] = true From 3257e3a0dc04af9e00ba64c6cbe52bec521e00c8 Mon Sep 17 00:00:00 2001 From: CMGS Date: Fri, 3 Jul 2026 21:32:17 +0800 Subject: [PATCH 3/8] feat(image): pull OCI blobs over parallel Range connections ghcr throttles and resets a single HTTP/2 stream on multi-GB blobs. Fetch the qcow2 layer in 8 concurrent Range chunks to a temp file (single-stream fallback when Range is unsupported), verify the sha256 against the layer digest, then import. --- cmd/image/handler.go | 34 ++++++----- cmd/image/oci.go | 140 +++++++++++++++++++++++++++++++++++++++---- go.mod | 2 +- go.sum | 4 +- 4 files changed, 150 insertions(+), 30 deletions(-) diff --git a/cmd/image/handler.go b/cmd/image/handler.go index 643c642..84b5e2d 100644 --- a/cmd/image/handler.go +++ b/cmd/image/handler.go @@ -2,6 +2,7 @@ package image import ( "fmt" + "os" "text/tabwriter" "time" @@ -25,7 +26,7 @@ type Handler struct{} func NewHandler() *Handler { return &Handler{} } // Pull fetches IMAGE into the store: an http(s) URL goes straight through cloudimg; an OCI/ghcr ref -// has its qcow2 layer streamed off the registry (native oras-go) and imported. +// has its qcow2 layer pulled (parallel Range download, digest-verified) and imported. func (h *Handler) Pull(cmd *cobra.Command, args []string) error { ctx, s, err := home.OpenStore(cmd) if err != nil { @@ -44,24 +45,25 @@ func (h *Handler) Pull(cmd *cobra.Command, args []string) error { return nil } } - // Credentials come from the user's docker config (public images pull - // anonymously), so no external oras binary is needed. - logger.Debugf(ctx, "pulling OCI artifact %s via oras-go", ref) - // retry transient registry stream drops: ghcr resets the HTTP/2 stream on multi-GB blobs with - // PROTOCOL_ERROR mid-copy. A fresh fetch (from the start — mid-stream can't resume) usually wins. + // Download the qcow2 layer to a temp file via parallel Range connections (credentials from the + // user's docker config; public images pull anonymously), retrying transient registry drops, then + // import the verified file. ghcr throttles/resets a single HTTP/2 stream on multi-GB blobs, so + // the parallel pull is both faster and more robust than streaming straight into the store. + logger.Debugf(ctx, "pulling OCI artifact %s via parallel Range download", ref) + tmp, err := os.CreateTemp(home.Dir(cmd), "pull-*.qcow2") + if err != nil { + return err + } + tmpPath := tmp.Name() + _ = tmp.Close() + defer func() { _ = os.Remove(tmpPath) }() + var perr error for attempt := 1; attempt <= 3; attempt++ { - rc, ferr := pullOCILayer(ctx, ref) - if ferr != nil { - perr = ferr - } else { - perr = s.ImportFromReader(ctx, ref, progress.Nop, rc) - _ = rc.Close() - } - if perr == nil { - return nil + if perr = pullOCIBlob(ctx, ref, tmpPath); perr == nil { + return s.Import(ctx, ref, progress.Nop, tmpPath) } - logger.Warnf(ctx, "oras pull attempt %d/3 failed: %v", attempt, perr) + logger.Warnf(ctx, "oci pull attempt %d/3 failed: %v", attempt, perr) if attempt < 3 { select { case <-ctx.Done(): diff --git a/cmd/image/oci.go b/cmd/image/oci.go index c2fa047..488c6a3 100644 --- a/cmd/image/oci.go +++ b/cmd/image/oci.go @@ -2,48 +2,166 @@ package image import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" + "net/http" + "os" "strings" ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "golang.org/x/sync/errgroup" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/registry/remote" "oras.land/oras-go/v2/registry/remote/auth" "oras.land/oras-go/v2/registry/remote/credentials" ) -// pullOCILayer resolves an OCI/ghcr ref, picks its qcow2 layer, and returns an open blob reader for -// that layer streamed from the registry. The caller owns closing the reader. -func pullOCILayer(ctx context.Context, ref string) (io.ReadCloser, error) { +// pullConns is the number of parallel HTTP Range connections used to fetch a blob; ghcr throttles a +// single stream to a fraction of the link, so 8 chunks pull the multi-GB base images far faster. +const pullConns = 8 + +// pullOCIBlob resolves ref's qcow2 layer and downloads it to dest, using parallel HTTP Range +// requests when the registry supports them (falling back to a single stream otherwise) and verifying +// the content against the layer's sha256 digest. +func pullOCIBlob(ctx context.Context, ref, dest string) error { repo, err := remote.NewRepository(ref) if err != nil { - return nil, fmt.Errorf("parse ref %s: %w", ref, err) + return fmt.Errorf("parse ref %s: %w", ref, err) + } + client := &auth.Client{Cache: auth.NewCache(), Credential: dockerCredential()} + repo.Client = client + + layer, err := resolveQcow2Layer(ctx, repo, ref) + if err != nil { + return err + } + blobURL := fmt.Sprintf("https://%s/v2/%s/blobs/%s", repo.Reference.Registry, repo.Reference.Repository, layer.Digest) + + f, err := os.Create(dest) //nolint:gosec // dest is an internal temp path (os.CreateTemp), not user input + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + if layer.Size <= 0 || !rangeSupported(ctx, client, blobURL) { + if err = fetchSingle(ctx, repo, layer, f); err != nil { + return err + } + } else { + if err = f.Truncate(layer.Size); err != nil { + return err + } + if err = fetchParallel(ctx, client, blobURL, layer.Size, f); err != nil { + return err + } } - repo.Client = &auth.Client{Cache: auth.NewCache(), Credential: dockerCredential()} + return verifyDigest(dest, layer.Digest.String()) +} +// resolveQcow2Layer fetches ref's manifest and returns its qcow2 layer descriptor. +func resolveQcow2Layer(ctx context.Context, repo *remote.Repository, ref string) (ocispec.Descriptor, error) { desc, err := repo.Resolve(ctx, ref) if err != nil { - return nil, fmt.Errorf("resolve %s: %w", ref, err) + return ocispec.Descriptor{}, fmt.Errorf("resolve %s: %w", ref, err) } raw, err := content.FetchAll(ctx, repo.Manifests(), desc) if err != nil { - return nil, fmt.Errorf("fetch manifest %s: %w", ref, err) + return ocispec.Descriptor{}, fmt.Errorf("fetch manifest %s: %w", ref, err) } var manifest ocispec.Manifest if err = json.Unmarshal(raw, &manifest); err != nil { - return nil, fmt.Errorf("parse manifest %s: %w", ref, err) + return ocispec.Descriptor{}, fmt.Errorf("parse manifest %s: %w", ref, err) } layer, err := pickQcow2Layer(manifest.Layers) if err != nil { - return nil, fmt.Errorf("%s: %w", ref, err) + return ocispec.Descriptor{}, fmt.Errorf("%s: %w", ref, err) + } + return layer, nil +} + +// rangeSupported probes whether the blob endpoint honors a Range request (ghcr redirects to a +// presigned URL that does; a registry that returns 200 does not). +func rangeSupported(ctx context.Context, client *auth.Client, url string) bool { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return false } + req.Header.Set("Range", "bytes=0-0") + resp, err := client.Do(req) + if err != nil { + return false + } + _ = resp.Body.Close() + return resp.StatusCode == http.StatusPartialContent +} + +// fetchParallel downloads [0,size) in pullConns chunks concurrently, each writing at its offset. +func fetchParallel(ctx context.Context, client *auth.Client, url string, size int64, f *os.File) error { + chunk := (size + pullConns - 1) / pullConns + g, gctx := errgroup.WithContext(ctx) + for i := range int64(pullConns) { + start := i * chunk + if start >= size { + break + } + end := start + chunk - 1 + if end >= size { + end = size - 1 + } + g.Go(func() error { return fetchRange(gctx, client, url, start, end, f) }) + } + return g.Wait() +} + +func fetchRange(ctx context.Context, client *auth.Client, url string, start, end int64, f *os.File) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end)) + resp, err := client.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusPartialContent { + return fmt.Errorf("range %d-%d: unexpected status %s", start, end, resp.Status) + } + // each chunk owns a disjoint region, so concurrent WriteAt via the offset writer never overlaps + _, err = io.Copy(io.NewOffsetWriter(f, start), resp.Body) + return err +} + +// fetchSingle streams the blob in one connection (fallback when Range isn't supported). +func fetchSingle(ctx context.Context, repo *remote.Repository, layer ocispec.Descriptor, f *os.File) error { rc, err := repo.Blobs().Fetch(ctx, layer) if err != nil { - return nil, fmt.Errorf("fetch layer %s: %w", layer.Digest, err) + return fmt.Errorf("fetch layer %s: %w", layer.Digest, err) + } + defer func() { _ = rc.Close() }() + _, err = io.Copy(f, rc) + return err +} + +// verifyDigest re-hashes the downloaded file and checks it against "sha256:". +func verifyDigest(path, want string) error { + f, err := os.Open(path) //nolint:gosec // path is an internal temp path (os.CreateTemp), not user input + if err != nil { + return err + } + defer func() { _ = f.Close() }() + h := sha256.New() + if _, err = io.Copy(h, f); err != nil { + return err + } + got := "sha256:" + hex.EncodeToString(h.Sum(nil)) + if got != want { + return fmt.Errorf("digest mismatch: got %s want %s", got, want) } - return rc, nil + return nil } // dockerCredential resolves registry credentials from the user's docker config. NewStoreFromDocker diff --git a/go.mod b/go.mod index 0784d87..c4b4b63 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/opencontainers/image-spec v1.1.1 github.com/projecteru2/core v0.0.0-20241016125006-ff909eefe04c github.com/spf13/cobra v1.10.2 + golang.org/x/sync v0.21.0 howett.net/plist v1.0.1 oras.land/oras-go/v2 v2.6.1 ) @@ -39,7 +40,6 @@ require ( github.com/vishvananda/netns v0.0.5 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/net v0.50.0 // indirect - golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/tools v0.42.0 // indirect diff --git a/go.sum b/go.sum index ad338f1..679c7af 100644 --- a/go.sum +++ b/go.sum @@ -121,8 +121,8 @@ golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= From 0d9608f3d4ba27068d5d1db7afb7265c07aaf53d Mon Sep 17 00:00:00 2001 From: CMGS Date: Fri, 3 Jul 2026 21:59:06 +0800 Subject: [PATCH 4/8] feat(vm): decide VNC per start, off by default A persistent VNC port widens the host's exposure for the VM's whole lifetime. Make it launch-scoped: vm start only enables VNC when --vnc is given, and stop clears the recorded display/password with the qemu they belonged to. --- cmd/vm/commands.go | 4 +++- cmd/vm/lifecycle.go | 8 ++++++-- cmd/vm/vnc.go | 7 +++++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/cmd/vm/commands.go b/cmd/vm/commands.go index 353ea08..2119dc3 100644 --- a/cmd/vm/commands.go +++ b/cmd/vm/commands.go @@ -45,10 +45,12 @@ func Command(h Actions) *cobra.Command { startCmd := &cobra.Command{ Use: "start VM [VM...]", - Short: "Start created/stopped VM(s)", + Short: "Start created/stopped VM(s); VNC is off unless --vnc is given for this start", Args: cobra.MinimumNArgs(1), RunE: h.Start, } + startCmd.Flags().Int("vnc", -1, "VNC display number for this start only (n => port 590n); omit to keep VNC off") + startCmd.Flags().String("vnc-password", "", "VNC password for this start (≤8 chars, QEMU password auth)") stopCmd := &cobra.Command{ Use: "stop VM [VM...]", diff --git a/cmd/vm/lifecycle.go b/cmd/vm/lifecycle.go index 1200239..d752f34 100644 --- a/cmd/vm/lifecycle.go +++ b/cmd/vm/lifecycle.go @@ -42,9 +42,12 @@ func (h *Handler) Run(cmd *cobra.Command, args []string) error { } // Start boots one or more previously-created VMs, reusing each persisted TAP/netns -// across stop/start (only rm tears those down). +// across stop/start (only rm tears those down). VNC is decided per start — off unless +// --vnc is given — so the host port is only exposed while actually wanted. func (h *Handler) Start(cmd *cobra.Command, args []string) error { ctx := cliutil.CommandContext(cmd) + vnc, _ := cmd.Flags().GetInt("vnc") + vncPass, _ := cmd.Flags().GetString("vnc-password") for _, n := range args { dir := home.VMDir(cmd, n) if err := withVMLock(ctx, dir, func() error { @@ -52,6 +55,7 @@ func (h *Handler) Start(cmd *cobra.Command, args []string) error { if err != nil { return err } + r.VNCDisp, r.VNCPass = vnc, vncPass if err := h.launch(cmd, dir, r); err != nil { return err } @@ -77,7 +81,7 @@ func (h *Handler) Stop(cmd *cobra.Command, args []string) error { } terminate(ctx, r, grace) stopVNCProxy(dir) - r.PID = 0 + r.PID, r.VNCDisp, r.VNCPass = 0, -1, "" // VNC is launch-scoped: gone with the qemu it belonged to return saveRec(dir, r) }); err != nil { return err diff --git a/cmd/vm/vnc.go b/cmd/vm/vnc.go index c471b01..c0e9900 100644 --- a/cmd/vm/vnc.go +++ b/cmd/vm/vnc.go @@ -36,6 +36,7 @@ func vncProxyCommand() *cobra.Command { // startVNCProxy re-execs this binary as the hidden proxy, detached, in the HOST netns (so its TCP // listener is reachable off-box while qemu's VNC stays inside the CNI netns). Idempotent. func startVNCProxy(dir string, disp int) error { + stopVNCProxy(dir) // a stale proxy would hold the port and shadow the new one sock := filepath.Join(dir, vncSockName) if err := utils.WaitFor(context.Background(), 5*time.Second, 100*time.Millisecond, func() (bool, error) { info, statErr := os.Stat(sock) // qemu -daemonize creates the socket slightly after fork @@ -56,11 +57,13 @@ func startVNCProxy(dir string, disp int) error { return utils.WritePIDFile(filepath.Join(dir, vncProxyPID), c.Process.Pid) } -// stopVNCProxy kills a running proxy (best-effort) and removes its pidfile. +// stopVNCProxy kills a running proxy (best-effort) and removes its pidfile. SIGKILL, not SIGTERM: +// the CLI's root command traps SIGTERM via signal.NotifyContext, so a TERMed proxy would just keep +// accepting; the proxy is a stateless pipe, so killing it hard loses nothing. func stopVNCProxy(dir string) { pidPath := filepath.Join(dir, vncProxyPID) if pid, err := utils.ReadPIDFile(pidPath); err == nil { - _ = syscall.Kill(pid, syscall.SIGTERM) + _ = syscall.Kill(pid, syscall.SIGKILL) } _ = os.Remove(pidPath) } From af081af28af1964a57ab036881b4f678f6d0426e Mon Sep 17 00:00:00 2001 From: CMGS Date: Fri, 3 Jul 2026 22:11:16 +0800 Subject: [PATCH 5/8] refactor(vm): reuse TerminateProcess for the vnc proxy; document launch-scoped --vnc TerminateProcess verifies the pidfile's PID still runs our _vnc-proxy before signaling (a reused PID must not be killed) and waits for death; zero grace escalates straight to SIGKILL since the proxy traps SIGTERM. Also note on create/run/clone --vnc that the setting is cleared on stop. --- cmd/vm/commands.go | 2 +- cmd/vm/lifecycle.go | 8 ++++---- cmd/vm/vnc.go | 16 ++++++++-------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/cmd/vm/commands.go b/cmd/vm/commands.go index 2119dc3..a3f3570 100644 --- a/cmd/vm/commands.go +++ b/cmd/vm/commands.go @@ -127,7 +127,7 @@ func addVMFlags(cmd *cobra.Command) { cmd.Flags().String("memory", "8192", "guest memory in MiB") cmd.Flags().Bool("hugepages", false, "back guest RAM with 2 MiB hugepages (needs host hugepages reserved; lower TLB/EPT overhead)") cmd.Flags().StringArray("data-disk", nil, "attach an extra qcow2 data disk: comma-separated key=value (size= required e.g. size=20G; name= optional, default dataN). Repeatable, max 4. macOS has no in-guest agent, so fstype=/mount= are unsupported — format it in the guest (Disk Utility/diskutil)") - cmd.Flags().Int("vnc", -1, "VNC display number (n => host 127.0.0.1:590n); <0 disables") + cmd.Flags().Int("vnc", -1, "VNC display number for the initial boot (n => port 590n); <0 disables. Launch-scoped: cleared on stop, re-enable per start with `vm start --vnc`") cmd.Flags().Int("ssh-port", 0, "host port forwarded to guest :22; 0 disables") cmd.Flags().String("opencore", "", "OpenCore.qcow2 boot loader (default: /firmware/OpenCore.qcow2; provisioned by scripts/doctor.sh)") cmd.Flags().String("ovmf-code", "", "OVMF_CODE firmware (default: /firmware/OVMF_CODE.fd)") diff --git a/cmd/vm/lifecycle.go b/cmd/vm/lifecycle.go index d752f34..0d9aee2 100644 --- a/cmd/vm/lifecycle.go +++ b/cmd/vm/lifecycle.go @@ -80,7 +80,7 @@ func (h *Handler) Stop(cmd *cobra.Command, args []string) error { return err } terminate(ctx, r, grace) - stopVNCProxy(dir) + stopVNCProxy(ctx, dir) r.PID, r.VNCDisp, r.VNCPass = 0, -1, "" // VNC is launch-scoped: gone with the qemu it belonged to return saveRec(dir, r) }); err != nil { @@ -108,7 +108,7 @@ func (h *Handler) RM(cmd *cobra.Command, args []string) error { if err := withVMLock(ctx, dir, func() error { if r, err := loadRec(dir); err == nil { terminate(ctx, r, grace) - stopVNCProxy(dir) + stopVNCProxy(ctx, dir) teardownNet(cmd, r) } if err := os.RemoveAll(dir); err != nil { @@ -204,7 +204,7 @@ func (h *Handler) launch(cmd *cobra.Command, dir string, r *record) error { c := launchCmd(r, args) // CNI: wraps in `ip netns exec` so -netdev tap finds the in-netns TAP c.Stdout, c.Stderr = os.Stdout, os.Stderr if err := c.Run(); err != nil { - stopVNCProxy(dir) + stopVNCProxy(ctx, dir) teardownNet(cmd, r) // don't leak an auto-created TAP/netns on a failed launch return fmt.Errorf("launch qemu: %w", err) } @@ -217,7 +217,7 @@ func (h *Handler) launch(cmd *cobra.Command, dir string, r *record) error { } } if spec.VNCSock != "" { - if err := startVNCProxy(dir, r.VNCDisp); err != nil { + if err := startVNCProxy(ctx, dir, r.VNCDisp); err != nil { logger.Warnf(ctx, "start vnc proxy: %v", err) } } diff --git a/cmd/vm/vnc.go b/cmd/vm/vnc.go index c0e9900..db6dfd1 100644 --- a/cmd/vm/vnc.go +++ b/cmd/vm/vnc.go @@ -35,10 +35,10 @@ func vncProxyCommand() *cobra.Command { // startVNCProxy re-execs this binary as the hidden proxy, detached, in the HOST netns (so its TCP // listener is reachable off-box while qemu's VNC stays inside the CNI netns). Idempotent. -func startVNCProxy(dir string, disp int) error { - stopVNCProxy(dir) // a stale proxy would hold the port and shadow the new one +func startVNCProxy(ctx context.Context, dir string, disp int) error { + stopVNCProxy(ctx, dir) // a stale proxy would hold the port and shadow the new one sock := filepath.Join(dir, vncSockName) - if err := utils.WaitFor(context.Background(), 5*time.Second, 100*time.Millisecond, func() (bool, error) { + if err := utils.WaitFor(ctx, 5*time.Second, 100*time.Millisecond, func() (bool, error) { info, statErr := os.Stat(sock) // qemu -daemonize creates the socket slightly after fork return statErr == nil && info.Mode()&os.ModeSocket != 0, nil }); err != nil { @@ -57,13 +57,13 @@ func startVNCProxy(dir string, disp int) error { return utils.WritePIDFile(filepath.Join(dir, vncProxyPID), c.Process.Pid) } -// stopVNCProxy kills a running proxy (best-effort) and removes its pidfile. SIGKILL, not SIGTERM: -// the CLI's root command traps SIGTERM via signal.NotifyContext, so a TERMed proxy would just keep -// accepting; the proxy is a stateless pipe, so killing it hard loses nothing. -func stopVNCProxy(dir string) { +// stopVNCProxy kills a running proxy (best-effort) and removes its pidfile. Zero grace: the CLI's +// root command traps SIGTERM via signal.NotifyContext, so a TERMed proxy would just keep accepting; +// TerminateProcess then escalates straight to SIGKILL, which loses nothing on a stateless pipe. +func stopVNCProxy(ctx context.Context, dir string) { pidPath := filepath.Join(dir, vncProxyPID) if pid, err := utils.ReadPIDFile(pidPath); err == nil { - _ = syscall.Kill(pid, syscall.SIGKILL) + _ = utils.TerminateProcess(ctx, pid, filepath.Base(os.Args[0]), vncProxyOp, 0) } _ = os.Remove(pidPath) } From c948fa9f9fab24b93f9aeff1ec3556e14053fd8c Mon Sep 17 00:00:00 2001 From: CMGS Date: Fri, 3 Jul 2026 22:26:33 +0800 Subject: [PATCH 6/8] fix(vm): fail loud on cni VNC gaps; keep persisted net on restart failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups: the CNI proxy listens on all host interfaces, so --vnc there now requires --vnc-password; the parent binds the proxy's TCP port and passes the listener fd to the detached child, so a taken port fails the launch (and kills qemu) instead of silently reporting a VNC entry point that doesn't exist. Also stop tearing down the persisted TAP/netns when a restart fails — only the create path cleans up its own fresh network. README documents the per-mode exposure. --- README.md | 12 ++++++++---- cmd/vm/lifecycle.go | 14 ++++++++++++-- cmd/vm/vnc.go | 29 +++++++++++++++++++++-------- 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 1aa68ee..bb78e3a 100644 --- a/README.md +++ b/README.md @@ -93,10 +93,14 @@ into its `config.plist` `PlatformInfo/Generic` (via `qemu-nbd` mount); the model (proven to boot Tahoe) and only serial/MLB/UUID/ROM are randomized. The assigned identity is recorded and shown by `vm inspect`. -QEMU's VNC is loopback-only (`127.0.0.1:590`) and offers `None` auth, which **macOS -Screen Sharing hangs on**. Pass `--vnc-password <≤8 chars>` to start QEMU with `password=on` -(set via the monitor post-launch) so Screen Sharing prompts and connects; tunnel first with -`ssh -L 5901:127.0.0.1:5901 `. Plain VNC clients (RealVNC/TigerVNC) work without a password. +VNC exposure depends on the net mode. With `--net user|tap|bridge` QEMU binds loopback-only +(`127.0.0.1:590`) — tunnel with `ssh -L 5901:127.0.0.1:5901 `. With `--net cni` QEMU +runs in a netns, so its VNC rides a unix socket fronted by a proxy on **all host interfaces** +(`0.0.0.0:590`); because that is reachable off-box, `--vnc-password` is **required** there. +VNC is launch-scoped: cleared on `vm stop`, re-enabled per start via `vm start --vnc N`. +QEMU's default `None` auth also **hangs macOS Screen Sharing** — pass `--vnc-password <≤8 chars>` +(applied via the monitor post-launch) so Screen Sharing prompts and connects. Plain VNC clients +(RealVNC/TigerVNC) work without a password on the loopback modes. **Display sleep blanks VNC.** macOS only repaints the emulated framebuffer while the display is awake; once it sleeps (~idle), VNC shows a blank **white/black** screen with just the cursor even diff --git a/cmd/vm/lifecycle.go b/cmd/vm/lifecycle.go index 0d9aee2..2a24a14 100644 --- a/cmd/vm/lifecycle.go +++ b/cmd/vm/lifecycle.go @@ -2,6 +2,7 @@ package vm import ( "context" + "errors" "fmt" "os" "path/filepath" @@ -35,6 +36,9 @@ func (h *Handler) Run(cmd *cobra.Command, args []string) error { return err } if err := h.launch(cmd, home.VMDir(cmd, r.Name), r); err != nil { + // only the create path cleans up its own fresh TAP/netns: a restart failure must never + // tear down the persisted network a stopped VM will reuse + teardownNet(cmd, r) return err } fmt.Printf("%s (pid %d)\n", r.Name, r.PID) @@ -174,6 +178,11 @@ func (h *Handler) create(cmd *cobra.Command, image string) (*record, error) { func (h *Handler) launch(cmd *cobra.Command, dir string, r *record) error { ctx := cliutil.CommandContext(cmd) logger := log.WithFunc("cmd.vm.launch") + if r.Netns != "" && r.VNCDisp >= 0 && r.VNCPass == "" { + // unlike the loopback bind of the other net modes, the CNI proxy listens on all host + // interfaces — never expose that unauthenticated + return errors.New("--vnc with --net cni serves VNC on a host port reachable off-box; --vnc-password is required") + } if hostIsAMD() { // macOS reads MSRs an AMD host lacks; without kvm.ignore_msrs KVM injects #GP. Best-effort, // host-global, and only set on AMD where macOS needs it. @@ -205,7 +214,6 @@ func (h *Handler) launch(cmd *cobra.Command, dir string, r *record) error { c.Stdout, c.Stderr = os.Stdout, os.Stderr if err := c.Run(); err != nil { stopVNCProxy(ctx, dir) - teardownNet(cmd, r) // don't leak an auto-created TAP/netns on a failed launch return fmt.Errorf("launch qemu: %w", err) } if pid, err := utils.ReadPIDFile(pidfile); err == nil { @@ -218,7 +226,9 @@ func (h *Handler) launch(cmd *cobra.Command, dir string, r *record) error { } if spec.VNCSock != "" { if err := startVNCProxy(ctx, dir, r.VNCDisp); err != nil { - logger.Warnf(ctx, "start vnc proxy: %v", err) + // the requested VNC entry point doesn't exist — a "running with VNC" success would lie + terminate(ctx, r, 0) + return fmt.Errorf("start vnc proxy: %w", err) } } return saveRec(dir, r) diff --git a/cmd/vm/vnc.go b/cmd/vm/vnc.go index db6dfd1..b7c1b2c 100644 --- a/cmd/vm/vnc.go +++ b/cmd/vm/vnc.go @@ -25,11 +25,12 @@ const ( vncBasePort = 5900 ) -// vncProxyCommand is the hidden re-exec target that runs the forwarder for its lifetime. +// vncProxyCommand is the hidden re-exec target that runs the forwarder for its lifetime, accepting +// on the TCP listener inherited as fd 3 (bound by the parent so bind errors fail the launch). func vncProxyCommand() *cobra.Command { return &cobra.Command{ - Use: vncProxyOp + " ", Hidden: true, Args: cobra.ExactArgs(2), - RunE: func(_ *cobra.Command, args []string) error { return runVNCProxy(args[0], args[1]) }, + Use: vncProxyOp + " ", Hidden: true, Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { return runVNCProxy(args[0]) }, } } @@ -44,12 +45,24 @@ func startVNCProxy(ctx context.Context, dir string, disp int) error { }); err != nil { return fmt.Errorf("vnc socket %s not created by qemu: %w", sock, err) } + // Bind here, not in the child: the child is detached, so its bind error (port taken) would be + // invisible and the launch would report a VNC entry point that doesn't exist. + l, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", vncBasePort+disp)) + if err != nil { + return fmt.Errorf("bind vnc port: %w", err) + } + defer func() { _ = l.Close() }() // child holds its own dup + lf, err := l.(*net.TCPListener).File() + if err != nil { + return err + } + defer func() { _ = lf.Close() }() self, err := os.Executable() if err != nil { return err } - listen := fmt.Sprintf("0.0.0.0:%d", vncBasePort+disp) - c := exec.Command(self, "vm", vncProxyOp, listen, sock) + c := exec.Command(self, "vm", vncProxyOp, sock) + c.ExtraFiles = []*os.File{lf} // inherited as fd 3 c.SysProcAttr = &syscall.SysProcAttr{Setsid: true} // survive the parent CLI exit if err := c.Start(); err != nil { return err @@ -68,10 +81,10 @@ func stopVNCProxy(ctx context.Context, dir string) { _ = os.Remove(pidPath) } -func runVNCProxy(listen, sock string) error { - l, err := net.Listen("tcp", listen) +func runVNCProxy(sock string) error { + l, err := net.FileListener(os.NewFile(3, "vnc-listener")) if err != nil { - return fmt.Errorf("listen %s: %w", listen, err) + return fmt.Errorf("inherit vnc listener: %w", err) } defer func() { _ = l.Close() }() for { From c232fe79efdb721e052b6fc1495fda818ca3573d Mon Sep 17 00:00:00 2001 From: CMGS Date: Fri, 3 Jul 2026 22:40:41 +0800 Subject: [PATCH 7/8] fix(vm): fail launch on password-set failure; validate cni VNC before scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed set_password leaves qemu at password=on with no password, so every VNC auth fails — kill qemu and error instead of warning. The cni --vnc-password check now also runs before create scaffolds anything, and a failed run removes the VM dir it just made: a leftover record pointing at a torn-down netns bricked same-name retries and vm start. --- cmd/vm/handler.go | 9 ++++++--- cmd/vm/lifecycle.go | 25 +++++++++++++++---------- cmd/vm/net_linux.go | 6 ------ cmd/vm/vnc.go | 5 +++++ 4 files changed, 26 insertions(+), 19 deletions(-) diff --git a/cmd/vm/handler.go b/cmd/vm/handler.go index 17eb893..da3893e 100644 --- a/cmd/vm/handler.go +++ b/cmd/vm/handler.go @@ -10,9 +10,12 @@ const ( qemuBinary = "qemu-system-x86_64" stopGracePeriod = 10 * time.Second - // --net modes handled on every platform (bridge/cni are Linux-only, see net_linux.go). - netUser = "user" - netTAP = "tap" + // --net modes; bridge/cni provisioning is Linux-only (see net_linux.go) but the names are + // needed cross-platform for flag validation. + netUser = "user" + netTAP = "tap" + netBridge = "bridge" + netCNI = "cni" ) var _ Actions = (*Handler)(nil) diff --git a/cmd/vm/lifecycle.go b/cmd/vm/lifecycle.go index 2a24a14..29cd9a3 100644 --- a/cmd/vm/lifecycle.go +++ b/cmd/vm/lifecycle.go @@ -2,7 +2,6 @@ package vm import ( "context" - "errors" "fmt" "os" "path/filepath" @@ -36,9 +35,11 @@ func (h *Handler) Run(cmd *cobra.Command, args []string) error { return err } if err := h.launch(cmd, home.VMDir(cmd, r.Name), r); err != nil { - // only the create path cleans up its own fresh TAP/netns: a restart failure must never - // tear down the persisted network a stopped VM will reuse + // run is an atomic create+boot: on failure remove everything it just made — a leftover + // record would point at the torn-down netns/TAP and brick retries ("already exists", + // un-startable). Restart failures (vm start) must NOT do this: their network is persisted. teardownNet(cmd, r) + _ = os.RemoveAll(home.VMDir(cmd, r.Name)) return err } fmt.Printf("%s (pid %d)\n", r.Name, r.PID) @@ -138,6 +139,12 @@ func (h *Handler) create(cmd *cobra.Command, image string) (*record, error) { if err != nil { return nil, err } + vnc, _ := cmd.Flags().GetInt("vnc") + vncPass, _ := cmd.Flags().GetString("vnc-password") + netMode, _ := cmd.Flags().GetString("net") + if netMode == netCNI && vnc >= 0 && vncPass == "" { // fail before scaffolding leaves a half-made VM + return nil, errCNIVNCPassRequired + } oc, code, varsTmpl, err := resolveFirmware(cmd) if err != nil { return nil, err @@ -149,10 +156,7 @@ func (h *Handler) create(cmd *cobra.Command, image string) (*record, error) { ctx := cliutil.CommandContext(cmd) cpus, _ := cmd.Flags().GetInt("cpus") mem, _ := cmd.Flags().GetString("memory") - vnc, _ := cmd.Flags().GetInt("vnc") ssh, _ := cmd.Flags().GetInt("ssh-port") - vncPass, _ := cmd.Flags().GetString("vnc-password") - netMode, _ := cmd.Flags().GetString("net") tap, _ := cmd.Flags().GetString("tap") huge, _ := cmd.Flags().GetBool("hugepages") r := &record{ @@ -179,9 +183,7 @@ func (h *Handler) launch(cmd *cobra.Command, dir string, r *record) error { ctx := cliutil.CommandContext(cmd) logger := log.WithFunc("cmd.vm.launch") if r.Netns != "" && r.VNCDisp >= 0 && r.VNCPass == "" { - // unlike the loopback bind of the other net modes, the CNI proxy listens on all host - // interfaces — never expose that unauthenticated - return errors.New("--vnc with --net cni serves VNC on a host port reachable off-box; --vnc-password is required") + return errCNIVNCPassRequired } if hostIsAMD() { // macOS reads MSRs an AMD host lacks; without kvm.ignore_msrs KVM injects #GP. Best-effort, @@ -221,7 +223,10 @@ func (h *Handler) launch(cmd *cobra.Command, dir string, r *record) error { } if r.VNCPass != "" { if err := setVNCPassword(ctx, spec.MonSock, r.VNCPass); err != nil { - logger.Warnf(ctx, "set vnc password: %v", err) + // qemu keeps password=on with no password set, so every VNC auth would fail — + // reporting success here would hand out a dead entry point + terminate(ctx, r, 0) + return fmt.Errorf("set vnc password: %w", err) } } if spec.VNCSock != "" { diff --git a/cmd/vm/net_linux.go b/cmd/vm/net_linux.go index bdb529d..cf01e3e 100644 --- a/cmd/vm/net_linux.go +++ b/cmd/vm/net_linux.go @@ -23,12 +23,6 @@ import ( "github.com/cocoonstack/cocoon-macos/home" ) -// --net modes available only on Linux (cocoon's bridge/CNI network plane). -const ( - netBridge = "bridge" - netCNI = "cni" -) - // newProvider builds the cocoon host-side network provider for r.NetMode. "tap"/"bridge" both // use the bridge backend (QEMU -netdev tap,ifname= opens the device in the host netns, so the // TAP must be a host-side bridge port); "cni" uses the CNI backend (TAP lives inside a netns). diff --git a/cmd/vm/vnc.go b/cmd/vm/vnc.go index b7c1b2c..3dfc6fa 100644 --- a/cmd/vm/vnc.go +++ b/cmd/vm/vnc.go @@ -2,6 +2,7 @@ package vm import ( "context" + "errors" "fmt" "io" "net" @@ -25,6 +26,10 @@ const ( vncBasePort = 5900 ) +// Unlike the loopback bind of the other net modes, the CNI proxy listens on all host interfaces — +// never expose that unauthenticated. +var errCNIVNCPassRequired = errors.New("--vnc with --net cni serves VNC on a host port reachable off-box; --vnc-password is required") + // vncProxyCommand is the hidden re-exec target that runs the forwarder for its lifetime, accepting // on the TCP listener inherited as fd 3 (bound by the parent so bind errors fail the launch). func vncProxyCommand() *cobra.Command { From 3b78dfe445ac29f950ed263167bf74d131e83ab4 Mon Sep 17 00:00:00 2001 From: CMGS Date: Fri, 3 Jul 2026 22:58:11 +0800 Subject: [PATCH 8/8] fix(vm): apply the cni VNC password pre-check to clone too --- cmd/vm/clone.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/cmd/vm/clone.go b/cmd/vm/clone.go index d7214aa..c80da7e 100644 --- a/cmd/vm/clone.go +++ b/cmd/vm/clone.go @@ -27,6 +27,12 @@ func (h *Handler) Clone(cmd *cobra.Command, args []string) error { if name == "" { name = src + "-clone-" + time.Now().Format("150405") } + netMode, _ := cmd.Flags().GetString("net") + vnc, _ := cmd.Flags().GetInt("vnc") + vncPass, _ := cmd.Flags().GetString("vnc-password") + if netMode == netCNI && vnc >= 0 && vncPass == "" { // same pre-scaffold check as create + return errCNIVNCPassRequired + } // the clone inherits SRC's data disks by name; extra --data-disk specs must not collide with them // and the combined count still honors the AHCI cap. Parse before scaffolding to fail fast. reserved := make([]string, len(srcRec.DataDisks)) @@ -67,9 +73,9 @@ func (h *Handler) Clone(cmd *cobra.Command, args []string) error { if cmd.Flags().Changed("hugepages") { r.Hugepages, _ = cmd.Flags().GetBool("hugepages") } - r.VNCDisp, _ = cmd.Flags().GetInt("vnc") + r.VNCDisp = vnc r.SSHPort, _ = cmd.Flags().GetInt("ssh-port") - r.VNCPass, _ = cmd.Flags().GetString("vnc-password") + r.VNCPass = vncPass // fresh identity when SRC has one or --random-smbios (cold boot re-reads PlatformInfo from the overlay) randomSMBIOS, _ := cmd.Flags().GetBool("random-smbios") freshIdentity := randomSMBIOS || srcRec.SMBIOS != nil @@ -84,7 +90,7 @@ func (h *Handler) Clone(cmd *cobra.Command, args []string) error { if !freshIdentity { r.MAC = srcRec.MAC // prepareOpenCore only sets a fresh MAC for fresh identities } - r.NetMode, _ = cmd.Flags().GetString("net") + r.NetMode = netMode tapFlag, _ := cmd.Flags().GetString("tap") r.Tap = tapFlag if err = applyNet(cmd, r); err != nil {