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/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/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 { diff --git a/cmd/vm/commands.go b/cmd/vm/commands.go index 2bd6b0d..a3f3570 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...]", @@ -113,6 +115,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 @@ -124,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/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 1997e42..29cd9a3 100644 --- a/cmd/vm/lifecycle.go +++ b/cmd/vm/lifecycle.go @@ -35,6 +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 { + // 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) @@ -42,9 +47,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 +60,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 } @@ -76,7 +85,8 @@ func (h *Handler) Stop(cmd *cobra.Command, args []string) error { return err } terminate(ctx, r, grace) - r.PID = 0 + 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 { return err @@ -103,6 +113,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(ctx, dir) teardownNet(cmd, r) } if err := os.RemoveAll(dir); err != nil { @@ -128,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 @@ -139,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{ @@ -168,6 +182,9 @@ 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 == "" { + return errCNIVNCPassRequired + } 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. @@ -183,6 +200,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,7 +215,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 { - teardownNet(cmd, r) // don't leak an auto-created TAP/netns on a failed launch + stopVNCProxy(ctx, dir) return fmt.Errorf("launch qemu: %w", err) } if pid, err := utils.ReadPIDFile(pidfile); err == nil { @@ -201,7 +223,17 @@ 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 != "" { + if err := startVNCProxy(ctx, dir, r.VNCDisp); err != nil { + // 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/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 new file mode 100644 index 0000000..3dfc6fa --- /dev/null +++ b/cmd/vm/vnc.go @@ -0,0 +1,117 @@ +package vm + +import ( + "context" + "errors" + "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 +) + +// 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 { + return &cobra.Command{ + Use: vncProxyOp + " ", Hidden: true, Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { return runVNCProxy(args[0]) }, + } +} + +// 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(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(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 { + 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 + } + 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 + } + return utils.WritePIDFile(filepath.Join(dir, vncProxyPID), c.Process.Pid) +} + +// 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 { + _ = utils.TerminateProcess(ctx, pid, filepath.Base(os.Args[0]), vncProxyOp, 0) + } + _ = os.Remove(pidPath) +} + +func runVNCProxy(sock string) error { + l, err := net.FileListener(os.NewFile(3, "vnc-listener")) + if err != nil { + return fmt.Errorf("inherit vnc listener: %w", 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/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= 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 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")