Skip to content
Merged
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<vnc>`) 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 <host>`. 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<vnc>`) — tunnel with `ssh -L 5901:127.0.0.1:5901 <host>`. 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<vnc>`); 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
Expand Down
34 changes: 18 additions & 16 deletions cmd/image/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package image

import (
"fmt"
"os"
"text/tabwriter"
"time"

Expand All @@ -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 {
Expand All @@ -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():
Expand Down
140 changes: 129 additions & 11 deletions cmd/image/oci.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:<hex>".
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
Expand Down
12 changes: 9 additions & 3 deletions cmd/vm/clone.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
7 changes: 5 additions & 2 deletions cmd/vm/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...]",
Expand Down Expand Up @@ -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
Expand All @@ -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: <state-dir>/firmware/OpenCore.qcow2; provisioned by scripts/doctor.sh)")
cmd.Flags().String("ovmf-code", "", "OVMF_CODE firmware (default: <state-dir>/firmware/OVMF_CODE.fd)")
Expand Down
9 changes: 6 additions & 3 deletions cmd/vm/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading