diff --git a/cmd/vmhost/bridge_linux.go b/cmd/vmhost/bridge_linux.go new file mode 100644 index 0000000..1746675 --- /dev/null +++ b/cmd/vmhost/bridge_linux.go @@ -0,0 +1,37 @@ +//go:build linux + +package vmhostcmder + +import ( + "os" + "os/exec" + "path/filepath" +) + +// findBridgeHelper locates the QEMU bridge helper binary by searching +// common installation paths. See pkg/vm/bridge_linux.go for details. +func findBridgeHelper(qemuBinary string) string { + if qemuBin, err := exec.LookPath(qemuBinary); err == nil { + resolved, err := filepath.EvalSymlinks(qemuBin) + if err == nil { + prefix := filepath.Dir(filepath.Dir(resolved)) + candidate := filepath.Join(prefix, "libexec", "qemu-bridge-helper") + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + } + + candidates := []string{ + "/usr/lib/qemu/qemu-bridge-helper", + "/usr/libexec/qemu-bridge-helper", + "/usr/lib64/qemu/qemu-bridge-helper", + } + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + return c + } + } + + return "" +} diff --git a/cmd/vmhost/platform_darwin_arm64.go b/cmd/vmhost/platform_darwin_arm64.go index 8c5c375..62d5fa7 100644 --- a/cmd/vmhost/platform_darwin_arm64.go +++ b/cmd/vmhost/platform_darwin_arm64.go @@ -61,9 +61,10 @@ func bootAppleVirt(ctx context.Context, baseDir string, inst *vm.Instance, logge // getPlatformConfig returns the QEMU platform configuration for darwin/arm64. func getPlatformConfig(_ string) *vm.QEMUPlatformConfig { return &vm.QEMUPlatformConfig{ - Accelerator: "hvf", - Binary: "qemu-system-aarch64", - MachineType: "virt", + Accelerator: "hvf", + Binary: "qemu-system-aarch64", + MachineType: "virt", + MachineProps: "highmem=on", EFISearchPaths: []string{ "{qemu_prefix}/share/qemu/edk2-aarch64-code.fd", "/opt/homebrew/share/qemu/edk2-aarch64-code.fd", diff --git a/cmd/vmhost/platform_linux_amd64.go b/cmd/vmhost/platform_linux_amd64.go new file mode 100644 index 0000000..c04a773 --- /dev/null +++ b/cmd/vmhost/platform_linux_amd64.go @@ -0,0 +1,39 @@ +//go:build linux && amd64 + +package vmhostcmder + +import ( + "context" + "fmt" + "log" + + "github.com/papercomputeco/masterblaster/pkg/vm" + "github.com/papercomputeco/masterblaster/pkg/vmhost" +) + +func bootAppleVirt(_ context.Context, _ string, _ *vm.Instance, _ *log.Logger) (vmhost.VMController, error) { + return nil, fmt.Errorf("apple Virtualization.framework is only available on macOS/Apple Silicon") +} + +// getPlatformConfig returns the QEMU platform configuration for Linux x86_64. +func getPlatformConfig(_ string) *vm.QEMUPlatformConfig { + const binary = "qemu-system-x86_64" + + return &vm.QEMUPlatformConfig{ + Accelerator: "kvm", + Binary: binary, + MachineType: "q35", + EFISearchPaths: []string{ + "{qemu_prefix}/share/qemu/edk2-x86_64-code.fd", + "/usr/share/qemu/edk2-x86_64-code.fd", + "/usr/share/OVMF/OVMF_CODE.fd", + "/usr/share/edk2/x86_64/OVMF_CODE.fd", + }, + ControlPlaneMode: "vsock", + VsockDevice: "vhost-vsock-pci", + DirectKernelBoot: true, + DiskAIO: "io_uring", + DiskCache: "none", + BridgeHelper: findBridgeHelper(binary), + } +} diff --git a/cmd/vmhost/platform_linux.go b/cmd/vmhost/platform_linux_arm64.go similarity index 79% rename from cmd/vmhost/platform_linux.go rename to cmd/vmhost/platform_linux_arm64.go index cd22aa8..c20e8cd 100644 --- a/cmd/vmhost/platform_linux.go +++ b/cmd/vmhost/platform_linux_arm64.go @@ -1,4 +1,4 @@ -//go:build linux +//go:build linux && arm64 package vmhostcmder @@ -15,12 +15,15 @@ func bootAppleVirt(_ context.Context, _ string, _ *vm.Instance, _ *log.Logger) ( return nil, fmt.Errorf("apple Virtualization.framework is only available on macOS/Apple Silicon") } -// getPlatformConfig returns the QEMU platform configuration for Linux. +// getPlatformConfig returns the QEMU platform configuration for Linux ARM64. func getPlatformConfig(_ string) *vm.QEMUPlatformConfig { + const binary = "qemu-system-aarch64" + return &vm.QEMUPlatformConfig{ - Accelerator: "kvm", - Binary: "qemu-system-aarch64", - MachineType: "virt", + Accelerator: "kvm", + Binary: binary, + MachineType: "virt", + MachineProps: "highmem=on", EFISearchPaths: []string{ "{qemu_prefix}/share/qemu/edk2-aarch64-code.fd", "/usr/share/qemu/edk2-aarch64-code.fd", @@ -32,5 +35,6 @@ func getPlatformConfig(_ string) *vm.QEMUPlatformConfig { DirectKernelBoot: true, DiskAIO: "io_uring", DiskCache: "none", + BridgeHelper: findBridgeHelper(binary), } } diff --git a/jcard.toml b/jcard.toml index 6146ad2..3473d71 100644 --- a/jcard.toml +++ b/jcard.toml @@ -5,8 +5,6 @@ cpus = 2 memory = "8GiB" [[agents]] -type = "native" harness = "opencode" -prompt = "Use cowsay to print Hello world!" -extra_packages = [ "cowsay" ] -replicas = 10 +prompt = "Hello world" +replicas = 3 diff --git a/pkg/config/config.go b/pkg/config/config.go index 873f129..87a92b9 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -77,6 +77,11 @@ type NetworkConfig struct { // Mode is the network mode: "nat" (default), "bridged", or "none". Mode string `toml:"mode"` + // Bridge is the host bridge interface for bridged mode on Linux + // (e.g. "virbr0", "br0"). Ignored on macOS where vmnet-shared is + // used instead. Defaults to "virbr0" (the libvirt default NAT bridge). + Bridge string `toml:"bridge"` + // Forwards are port forwards from host to sandbox (nat mode only). Forwards []PortForward `toml:"forwards"` diff --git a/pkg/vm/backend_darwin_arm64.go b/pkg/vm/backend_darwin_arm64.go index fad106e..8d71bd0 100644 --- a/pkg/vm/backend_darwin_arm64.go +++ b/pkg/vm/backend_darwin_arm64.go @@ -34,9 +34,10 @@ func NewPlatformBackend(baseDir string) (Backend, error) { // so the stereosd control plane is forwarded via TCP through QEMU's // user-mode networking. platform := &QEMUPlatformConfig{ - Accelerator: "hvf", - Binary: "qemu-system-aarch64", - MachineType: "virt", + Accelerator: "hvf", + Binary: "qemu-system-aarch64", + MachineType: "virt", + MachineProps: "highmem=on", EFISearchPaths: []string{ "{qemu_prefix}/share/qemu/edk2-aarch64-code.fd", "/opt/homebrew/share/qemu/edk2-aarch64-code.fd", diff --git a/pkg/vm/backend_linux_amd64.go b/pkg/vm/backend_linux_amd64.go new file mode 100644 index 0000000..1bb0ff9 --- /dev/null +++ b/pkg/vm/backend_linux_amd64.go @@ -0,0 +1,29 @@ +//go:build linux && amd64 + +package vm + +// NewPlatformBackend returns the QEMU backend for Linux on x86_64, +// configured for KVM acceleration with native vsock support. +func NewPlatformBackend(baseDir string) (Backend, error) { + const binary = "qemu-system-x86_64" + + platform := &QEMUPlatformConfig{ + Accelerator: "kvm", + Binary: binary, + MachineType: "q35", + EFISearchPaths: []string{ + "{qemu_prefix}/share/qemu/edk2-x86_64-code.fd", + "/usr/share/qemu/edk2-x86_64-code.fd", + "/usr/share/OVMF/OVMF_CODE.fd", + "/usr/share/edk2/x86_64/OVMF_CODE.fd", + }, + ControlPlaneMode: "vsock", + VsockDevice: "vhost-vsock-pci", + DirectKernelBoot: true, + DiskAIO: "io_uring", + DiskCache: "none", + BridgeHelper: findBridgeHelper(binary), + } + + return NewQEMUBackend(baseDir, platform), nil +} diff --git a/pkg/vm/backend_linux.go b/pkg/vm/backend_linux_arm64.go similarity index 60% rename from pkg/vm/backend_linux.go rename to pkg/vm/backend_linux_arm64.go index 6272472..6a2d091 100644 --- a/pkg/vm/backend_linux.go +++ b/pkg/vm/backend_linux_arm64.go @@ -1,17 +1,17 @@ -//go:build linux +//go:build linux && arm64 package vm -// NewPlatformBackend returns the appropriate VM backend for Linux. -// This returns the QEMU backend configured for KVM acceleration with -// native vsock support via vhost-vsock-pci. +// NewPlatformBackend returns the QEMU backend for Linux on ARM64, +// configured for KVM acceleration with native vsock support. func NewPlatformBackend(baseDir string) (Backend, error) { - // TODO: Implement KVM/libvirt backend for better performance. + const binary = "qemu-system-aarch64" platform := &QEMUPlatformConfig{ - Accelerator: "kvm", - Binary: "qemu-system-aarch64", - MachineType: "virt", + Accelerator: "kvm", + Binary: binary, + MachineType: "virt", + MachineProps: "highmem=on", EFISearchPaths: []string{ "{qemu_prefix}/share/qemu/edk2-aarch64-code.fd", "/usr/share/qemu/edk2-aarch64-code.fd", @@ -23,6 +23,7 @@ func NewPlatformBackend(baseDir string) (Backend, error) { DirectKernelBoot: true, DiskAIO: "io_uring", DiskCache: "none", + BridgeHelper: findBridgeHelper(binary), } return NewQEMUBackend(baseDir, platform), nil diff --git a/pkg/vm/bridge_linux.go b/pkg/vm/bridge_linux.go new file mode 100644 index 0000000..12b9d4b --- /dev/null +++ b/pkg/vm/bridge_linux.go @@ -0,0 +1,45 @@ +//go:build linux + +package vm + +import ( + "os" + "os/exec" + "path/filepath" +) + +// findBridgeHelper locates the QEMU bridge helper binary by searching +// common installation paths. The helper creates tap devices and attaches +// them to a bridge interface, enabling bridged networking without root +// privileges on the QEMU process itself. +// +// The helper binary must have setuid root or CAP_NET_ADMIN capability, +// and the target bridge must be listed in /etc/qemu/bridge.conf. +func findBridgeHelper(qemuBinary string) string { + // Try the QEMU installation prefix first. This works for Nix, + // Homebrew-on-Linux, and custom QEMU installs. + if qemuBin, err := exec.LookPath(qemuBinary); err == nil { + resolved, err := filepath.EvalSymlinks(qemuBin) + if err == nil { + prefix := filepath.Dir(filepath.Dir(resolved)) + candidate := filepath.Join(prefix, "libexec", "qemu-bridge-helper") + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + } + + // Common distro paths. + candidates := []string{ + "/usr/lib/qemu/qemu-bridge-helper", // Ubuntu, Debian, Arch + "/usr/libexec/qemu-bridge-helper", // Fedora, RHEL + "/usr/lib64/qemu/qemu-bridge-helper", // Some RHEL/CentOS variants + } + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + return c + } + } + + return "" +} diff --git a/pkg/vm/clone_linux.go b/pkg/vm/clone_linux.go new file mode 100644 index 0000000..acd8937 --- /dev/null +++ b/pkg/vm/clone_linux.go @@ -0,0 +1,30 @@ +//go:build linux + +package vm + +import ( + "fmt" + "os" + + "golang.org/x/sys/unix" +) + +// cloneFile attempts an instant copy-on-write clone via ioctl(FICLONE). +// This works on btrfs, xfs (with reflink), and other CoW filesystems. +// Returns an error if the filesystem doesn't support cloning, causing +// the caller to fall back to streaming io.Copy. +func cloneFile(src, dst string) error { + srcFile, err := os.Open(src) + if err != nil { + return fmt.Errorf("opening source for clone: %w", err) + } + defer srcFile.Close() + + dstFile, err := os.Create(dst) + if err != nil { + return fmt.Errorf("creating destination for clone: %w", err) + } + defer dstFile.Close() + + return unix.IoctlFileClone(int(dstFile.Fd()), int(srcFile.Fd())) +} diff --git a/pkg/vm/clone_other.go b/pkg/vm/clone_other.go index 2033c9e..f2ab98c 100644 --- a/pkg/vm/clone_other.go +++ b/pkg/vm/clone_other.go @@ -1,4 +1,4 @@ -//go:build !darwin +//go:build !darwin && !linux package vm diff --git a/pkg/vm/platform.go b/pkg/vm/platform.go index 0783ac2..cc79236 100644 --- a/pkg/vm/platform.go +++ b/pkg/vm/platform.go @@ -62,6 +62,17 @@ type QEMUPlatformConfig struct { // Set to "none" (O_DIRECT) when using io_uring for best performance, // or leave empty to use QEMU's default (writeback). DiskCache string + + // MachineProps are extra comma-separated properties appended to the + // -machine argument. For example, "highmem=on" is valid for the ARM + // "virt" machine type but not for x86_64 "q35". + MachineProps string + + // BridgeHelper is the resolved path to the QEMU bridge helper binary + // (qemu-bridge-helper). Only set on Linux where bridged networking + // uses tap devices via the helper. Empty on macOS where vmnet-shared + // is used instead. + BridgeHelper string } // DefaultMachineType returns the machine type, defaulting to "virt". diff --git a/pkg/vm/qemu.go b/pkg/vm/qemu.go index 90f1c1c..03cd08e 100644 --- a/pkg/vm/qemu.go +++ b/pkg/vm/qemu.go @@ -162,6 +162,18 @@ func (q *QEMUBackend) Start(ctx context.Context, inst *Instance) error { // // kernelArtifacts may be nil, in which case QEMU boots via EFI firmware. func (q *QEMUBackend) boot(ctx context.Context, inst *Instance, cfg *config.JcardConfig, kernelArtifacts *KernelArtifacts) error { + // Validate bridged networking prerequisites before doing any work. + if cfg.Network.Mode == "bridged" && q.platform.BridgeHelper == "" && q.platform.Accelerator == "kvm" { + return fmt.Errorf("bridged networking on Linux requires qemu-bridge-helper, but it was not found.\n\n" + + "Install it with your package manager:\n" + + " Ubuntu/Debian: sudo apt install qemu-system-common\n" + + " Fedora/RHEL: sudo dnf install qemu-common\n" + + " Arch Linux: sudo pacman -S qemu-base\n" + + " NixOS: add qemu to environment.systemPackages\n\n" + + "Then ensure your bridge is listed in /etc/qemu/bridge.conf:\n" + + " echo 'allow virbr0' | sudo tee -a /etc/qemu/bridge.conf") + } + // Allocate ports sshPort, err := allocatePort() if err != nil { @@ -169,16 +181,16 @@ func (q *QEMUBackend) boot(ctx context.Context, inst *Instance, cfg *config.Jcar } inst.SSHPort = sshPort - // Allocate a TCP port for the stereosd control plane. - // TODO(@jpmcb): Once native AF_VSOCK transport is implemented for - // Linux/KVM, this can be made conditional on ControlPlaneMode == "tcp". - // For now, all platforms use TCP through QEMU user-mode networking - // and stereosd listens on TCP via --listen-mode auto. - vsockTCPPort, err := allocatePort() - if err != nil { - return fmt.Errorf("allocating control plane port: %w", err) + // Allocate a TCP port for the stereosd control plane when using TCP + // transport (macOS/HVF). With native AF_VSOCK (Linux/KVM), the control + // plane bypasses TCP entirely, so no host port is needed. + if q.platform.ControlPlaneMode != "vsock" { + vsockTCPPort, err := allocatePort() + if err != nil { + return fmt.Errorf("allocating control plane port: %w", err) + } + inst.VsockPort = vsockTCPPort } - inst.VsockPort = vsockTCPPort inst.QMPSocket = filepath.Join(inst.Dir, "qmp.sock") @@ -520,8 +532,11 @@ func (q *QEMUBackend) buildArgs(inst *Instance, cfg *config.JcardConfig, kernelA memory := convertSizeForQEMU(cfg.Resources.Memory) // Machine type and acceleration from platform config - machineArg := fmt.Sprintf("%s,accel=%s,highmem=on", + machineArg := fmt.Sprintf("%s,accel=%s", q.platform.DefaultMachineType(), q.platform.Accelerator) + if q.platform.MachineProps != "" { + machineArg += "," + q.platform.MachineProps + } args := []string{ // Machine + acceleration @@ -574,7 +589,11 @@ func (q *QEMUBackend) buildArgs(inst *Instance, cfg *config.JcardConfig, kernelA ) // Networking - args = append(args, q.buildNetworkArgs(inst, cfg)...) + netArgs, err := q.buildNetworkArgs(inst, cfg) + if err != nil { + return nil, err + } + args = append(args, netArgs...) // Control plane device: native vsock when available, otherwise the // stereosd port is forwarded via TCP hostfwd in buildNetworkArgs. @@ -603,26 +622,44 @@ func (q *QEMUBackend) buildArgs(inst *Instance, cfg *config.JcardConfig, kernelA // buildNetworkArgs constructs QEMU network arguments based on config. // When the control plane uses TCP transport, the stereosd port is forwarded // here alongside SSH. With native vsock, only SSH is forwarded. -func (q *QEMUBackend) buildNetworkArgs(inst *Instance, cfg *config.JcardConfig) []string { +func (q *QEMUBackend) buildNetworkArgs(inst *Instance, cfg *config.JcardConfig) ([]string, error) { switch cfg.Network.Mode { case "none": - return []string{"-nic", "none"} + return []string{"-nic", "none"}, nil case "bridged": - // bridged mode uses vmnet-shared on macOS + if q.platform.BridgeHelper != "" { + // Linux: use QEMU bridge helper with tap networking. + // The helper creates a tap device and attaches it to the + // specified bridge. It requires: + // 1. qemu-bridge-helper with setuid or CAP_NET_ADMIN + // 2. The bridge listed in /etc/qemu/bridge.conf + // 3. A pre-existing bridge interface (e.g. virbr0) + bridge := cfg.Network.Bridge + if bridge == "" { + bridge = "virbr0" + } + return []string{ + "-netdev", fmt.Sprintf("bridge,id=net0,br=%s,helper=%s", bridge, q.platform.BridgeHelper), + "-device", "virtio-net-pci,netdev=net0", + }, nil + } + // macOS: vmnet-shared (no bridge helper needed). return []string{ "-netdev", "vmnet-shared,id=net0", "-device", "virtio-net-pci,netdev=net0", - } + }, nil default: // "nat" // Always forward SSH hostfwds := fmt.Sprintf("hostfwd=tcp::%d-:22", inst.SSHPort) - // Forward stereosd control plane via TCP through SLIRP. - // TODO(@jpmcb): Once native AF_VSOCK transport is implemented for - // Linux/KVM, this can be skipped when ControlPlaneMode == "vsock". - hostfwds += fmt.Sprintf(",hostfwd=tcp::%d-:%d", inst.VsockPort, vsock.VsockPort) + // Forward stereosd control plane via TCP through SLIRP when not + // using native vsock. With AF_VSOCK (Linux/KVM), the control plane + // bypasses guest networking entirely. + if q.platform.ControlPlaneMode != "vsock" { + hostfwds += fmt.Sprintf(",hostfwd=tcp::%d-:%d", inst.VsockPort, vsock.VsockPort) + } // Add configured port forwards for _, fwd := range cfg.Network.Forwards { @@ -632,7 +669,7 @@ func (q *QEMUBackend) buildNetworkArgs(inst *Instance, cfg *config.JcardConfig) return []string{ "-netdev", fmt.Sprintf("user,id=net0,%s", hostfwds), "-device", "virtio-net-pci,netdev=net0", - } + }, nil } } @@ -727,13 +764,15 @@ func (q *QEMUBackend) controlPlaneShutdown(ctx context.Context, inst *Instance) // controlPlaneTransport returns the appropriate Transport for connecting // to stereosd based on the platform's control plane mode. // -// TODO(@jpmcb): Implement native AF_VSOCK transport for Linux/KVM. -// When ControlPlaneMode == "vsock", this should return a VsockTransport -// that dials AF_VSOCK CID:3 port 1024 directly, bypassing TCP/SLIRP. -// See the VsockTransport sketch in pkg/vsock/transport.go. +// - "vsock": Native AF_VSOCK via vhost-vsock-pci (Linux/KVM only). +// Provides an isolated control plane that works even with +// network.mode = "none". Implemented in qemu_linux.go. +// - "tcp": TCP through QEMU user-mode networking (hostfwd). +// Used on macOS/HVF where native vsock is unavailable. func (q *QEMUBackend) controlPlaneTransport(inst *Instance) vsock.Transport { - // All platforms currently use TCP through QEMU user-mode networking. - // stereosd inside the guest listens on TCP via --listen-mode auto. + if q.platform.ControlPlaneMode == "vsock" { + return newVsockTransport() + } return &vsock.TCPTransport{Host: "127.0.0.1", Port: inst.VsockPort} } diff --git a/pkg/vm/qemu_linux.go b/pkg/vm/qemu_linux.go new file mode 100644 index 0000000..8e149f5 --- /dev/null +++ b/pkg/vm/qemu_linux.go @@ -0,0 +1,15 @@ +//go:build linux + +package vm + +import "github.com/papercomputeco/masterblaster/pkg/vsock" + +// newVsockTransport returns a native AF_VSOCK transport for Linux/KVM. +// This connects directly to the guest via vhost-vsock-pci, bypassing +// TCP/SLIRP entirely. +func newVsockTransport() vsock.Transport { + return &vsock.VsockTransport{ + CID: vsock.VsockGuestCID, + Port: uint32(vsock.VsockPort), + } +} diff --git a/pkg/vm/qemu_other.go b/pkg/vm/qemu_other.go new file mode 100644 index 0000000..5f5838f --- /dev/null +++ b/pkg/vm/qemu_other.go @@ -0,0 +1,12 @@ +//go:build !linux + +package vm + +import "github.com/papercomputeco/masterblaster/pkg/vsock" + +// newVsockTransport is not available on non-Linux platforms. +// The caller should never reach this path because ControlPlaneMode +// is only set to "vsock" on Linux. +func newVsockTransport() vsock.Transport { + panic("vsock transport is only available on Linux/KVM") +} diff --git a/pkg/vsock/transport.go b/pkg/vsock/transport.go index 395d908..f005c7a 100644 --- a/pkg/vsock/transport.go +++ b/pkg/vsock/transport.go @@ -65,19 +65,6 @@ func (f *FuncTransport) Dial(timeout time.Duration) (net.Conn, error) { // String returns the human-readable label. func (f *FuncTransport) String() string { return f.Label } -// NOTE: VsockTransport for Linux/KVM (AF_VSOCK CID:3 port 1024) will be -// implemented when Linux backend support is built. It requires: -// -// import "golang.org/x/sys/unix" -// -// type VsockTransport struct { -// CID uint32 -// Port uint32 -// } -// -// func (t *VsockTransport) Dial(timeout time.Duration) (net.Conn, error) { -// fd, _ := unix.Socket(unix.AF_VSOCK, unix.SOCK_STREAM, 0) -// addr := &unix.SockaddrVM{CID: t.CID, Port: t.Port} -// unix.Connect(fd, addr) -// return net.FileConn(os.NewFile(uintptr(fd), "vsock")) -// } +// VsockTransport for Linux/KVM (AF_VSOCK) is implemented in +// transport_linux.go. It connects directly to the guest via +// vhost-vsock-pci, bypassing TCP/SLIRP entirely. diff --git a/pkg/vsock/transport_linux.go b/pkg/vsock/transport_linux.go new file mode 100644 index 0000000..9c214a7 --- /dev/null +++ b/pkg/vsock/transport_linux.go @@ -0,0 +1,65 @@ +//go:build linux + +package vsock + +import ( + "fmt" + "net" + "os" + "time" + + "golang.org/x/sys/unix" +) + +// VsockTransport connects to stereosd via AF_VSOCK on Linux/KVM. +// This provides an isolated control plane that works independently of +// guest networking, even with network.mode = "none". It requires a +// vhost-vsock-pci device attached to the QEMU guest. +type VsockTransport struct { + // CID is the guest context identifier. QEMU uses guest-cid=3 by default. + CID uint32 + + // Port is the vsock port that stereosd listens on inside the guest. + Port uint32 +} + +// Dial connects to stereosd via AF_VSOCK. +func (t *VsockTransport) Dial(timeout time.Duration) (net.Conn, error) { + fd, err := unix.Socket(unix.AF_VSOCK, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0) + if err != nil { + return nil, fmt.Errorf("creating vsock socket: %w", err) + } + + // Set a send timeout so that Connect does not block indefinitely. + // AF_VSOCK connect honors SO_SNDTIMEO on Linux. + tv := unix.NsecToTimeval(timeout.Nanoseconds()) + if err := unix.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_SNDTIMEO, &tv); err != nil { + unix.Close(fd) + return nil, fmt.Errorf("setting vsock connect timeout: %w", err) + } + + sa := &unix.SockaddrVM{ + CID: t.CID, + Port: t.Port, + } + if err := unix.Connect(fd, sa); err != nil { + unix.Close(fd) + return nil, fmt.Errorf("connecting to vsock CID %d port %d: %w", t.CID, t.Port, err) + } + + // Wrap the raw fd in a net.Conn via os.File. FileConn duplicates the + // fd, so we must close the os.File to avoid leaking the original. + file := os.NewFile(uintptr(fd), fmt.Sprintf("vsock:%d:%d", t.CID, t.Port)) + conn, err := net.FileConn(file) + _ = file.Close() + if err != nil { + return nil, fmt.Errorf("wrapping vsock fd as net.Conn: %w", err) + } + + return conn, nil +} + +// String returns a human-readable description for logs. +func (t *VsockTransport) String() string { + return fmt.Sprintf("vsock:%d:%d", t.CID, t.Port) +}