diff --git a/cmd/limactl/main.go b/cmd/limactl/main.go
index 408e3a9e9d5..59a2af3fa6d 100644
--- a/cmd/limactl/main.go
+++ b/cmd/limactl/main.go
@@ -208,6 +208,7 @@ func newApp() *cobra.Command {
newNetworkCommand(),
newCloneCommand(),
newRenameCommand(),
+ newvzvmnetCommand(),
)
addPluginCommands(rootCmd)
diff --git a/cmd/limactl/vz-vmnet.go b/cmd/limactl/vz-vmnet.go
new file mode 100644
index 00000000000..e0a37d1fe13
--- /dev/null
+++ b/cmd/limactl/vz-vmnet.go
@@ -0,0 +1,27 @@
+// SPDX-FileCopyrightText: Copyright The Lima Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "github.com/spf13/cobra"
+)
+
+func newvzvmnetCommand() *cobra.Command {
+ newCommand := &cobra.Command{
+ Use: "vz-vmnet",
+ Short: "Run vz-vmnet",
+ Args: cobra.ExactArgs(0),
+ RunE: newvzvmnetAction,
+ ValidArgsFunction: newvzvmnetComplete,
+ Hidden: true,
+ }
+ newCommand.Flags().Bool("unregister-mach-service", false, "Unregister Mach service")
+ newCommand.Flags().String("mach-service", "", "Run as Mach service")
+ _ = newCommand.Flags().MarkHidden("mach-service")
+ return newCommand
+}
+
+func newvzvmnetComplete(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
+ return bashCompleteInstanceNames(cmd)
+}
diff --git a/cmd/limactl/vz-vmnet_darwin.go b/cmd/limactl/vz-vmnet_darwin.go
new file mode 100644
index 00000000000..a255c454e77
--- /dev/null
+++ b/cmd/limactl/vz-vmnet_darwin.go
@@ -0,0 +1,41 @@
+// SPDX-FileCopyrightText: Copyright The Lima Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "errors"
+ "os"
+ "os/signal"
+ "syscall"
+
+ "github.com/coreos/go-semver/semver"
+ "github.com/spf13/cobra"
+
+ "github.com/lima-vm/lima/v2/pkg/osutil"
+ "github.com/lima-vm/lima/v2/pkg/vzvmnet"
+)
+
+func newvzvmnetAction(cmd *cobra.Command, _ []string) error {
+ macOSProductVersion, err := osutil.ProductVersion()
+ if err != nil {
+ return err
+ }
+ if macOSProductVersion.LessThan(*semver.New("26.0.0")) {
+ return errors.New("vz-vmnet requires macOS 26 or higher to run")
+ }
+
+ if !cmd.HasLocalFlags() {
+ return cmd.Help()
+ }
+
+ ctx, cancel := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
+ defer cancel()
+
+ if machServiceName, _ := cmd.Flags().GetString("mach-service"); machServiceName != "" {
+ return vzvmnet.RunMachService(ctx, machServiceName)
+ } else if unregisterMachService, _ := cmd.Flags().GetBool("unregister-mach-service"); unregisterMachService {
+ return vzvmnet.UnregisterMachService(ctx)
+ }
+ return cmd.Help()
+}
diff --git a/cmd/limactl/vz-vmnet_nodarwin.go b/cmd/limactl/vz-vmnet_nodarwin.go
new file mode 100644
index 00000000000..f7b576908b6
--- /dev/null
+++ b/cmd/limactl/vz-vmnet_nodarwin.go
@@ -0,0 +1,16 @@
+//go:build !darwin
+
+// SPDX-FileCopyrightText: Copyright The Lima Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "errors"
+
+ "github.com/spf13/cobra"
+)
+
+func newvzvmnetAction(_ *cobra.Command, _ []string) error {
+ return errors.New("vz-vmnet command is only supported on macOS")
+}
diff --git a/go.mod b/go.mod
index 094d60e5f92..5217374308d 100644
--- a/go.mod
+++ b/go.mod
@@ -147,3 +147,5 @@ require (
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
)
+
+replace github.com/Code-Hex/vz/v3 => github.com/norio-nomura/vz/v3 v3.7.2-0.20251222044054-ebc8b3654abc
diff --git a/go.sum b/go.sum
index cba3ab8c8a6..503dcf05199 100644
--- a/go.sum
+++ b/go.sum
@@ -4,8 +4,6 @@ github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkk
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/Code-Hex/go-infinity-channel v1.0.0 h1:M8BWlfDOxq9or9yvF9+YkceoTkDI1pFAqvnP87Zh0Nw=
github.com/Code-Hex/go-infinity-channel v1.0.0/go.mod h1:5yUVg/Fqao9dAjcpzoQ33WwfdMWmISOrQloDRn3bsvY=
-github.com/Code-Hex/vz/v3 v3.7.1 h1:EN1yNiyrbPq+dl388nne2NySo8I94EnPppvqypA65XM=
-github.com/Code-Hex/vz/v3 v3.7.1/go.mod h1:1LsW0jqW0r0cQ+IeR4hHbjdqOtSidNCVMWhStMHGho8=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
@@ -209,6 +207,8 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/norio-nomura/vz/v3 v3.7.2-0.20251222044054-ebc8b3654abc h1:2yPSKwsxNv74W0WY3G/MV3qm5FHTCDhDRu09LBdOsvM=
+github.com/norio-nomura/vz/v3 v3.7.2-0.20251222044054-ebc8b3654abc/go.mod h1:+0IVfZY7N/7Vv5KpZWbEgTRK6jMg4s7DVM+op2hdyrs=
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
diff --git a/pkg/autostart/autostart_test.go b/pkg/autostart/autostart_test.go
index ef3d30cd4c2..5d98c282ea0 100644
--- a/pkg/autostart/autostart_test.go
+++ b/pkg/autostart/autostart_test.go
@@ -63,6 +63,8 @@ func TestRenderTemplate(t *testing.T) {
RunAtLoad
+ ExitTimeOut
+ 20
StandardErrorPath
launchd.stderr.log
StandardOutPath
diff --git a/pkg/autostart/launchd/io.lima-vm.autostart.INSTANCE.plist b/pkg/autostart/launchd/io.lima-vm.autostart.INSTANCE.plist
index 7e0ffd9494c..da118e67689 100644
--- a/pkg/autostart/launchd/io.lima-vm.autostart.INSTANCE.plist
+++ b/pkg/autostart/launchd/io.lima-vm.autostart.INSTANCE.plist
@@ -13,6 +13,8 @@
RunAtLoad
+ ExitTimeOut
+ 20
StandardErrorPath
launchd.stderr.log
StandardOutPath
diff --git a/pkg/driver/qemu/qemu.go b/pkg/driver/qemu/qemu.go
index c139348a5a6..97ba722cc24 100644
--- a/pkg/driver/qemu/qemu.go
+++ b/pkg/driver/qemu/qemu.go
@@ -790,7 +790,8 @@ func Cmdline(ctx context.Context, cfg Config) (exe string, args []string, err er
args = append(args, "-device", virtioNet+",netdev=net0,mac="+limayaml.MACAddress(cfg.InstanceDir))
for i, nw := range y.Networks {
- if nw.Lima != "" {
+ switch {
+ case nw.Lima != "":
nwCfg, err := networks.LoadConfig()
if err != nil {
return "", nil, err
@@ -821,15 +822,22 @@ func Cmdline(ctx context.Context, cfg Config) (exe string, args []string, err er
return "", nil, err
}
args = append(args, "-netdev", fmt.Sprintf("socket,id=net%d,fd={{ fd_connect %q }}", i+1, sock))
+ args = append(args, "-device", fmt.Sprintf("%s,netdev=net%d,mac=%s", virtioNet, i+1, nw.MACAddress))
// TODO: should we also validate that the socket exists, or do we rely on the
// networks reconciler to throw an error when the network cannot start?
}
- } else if nw.Socket != "" {
+ case nw.Socket != "":
args = append(args, "-netdev", fmt.Sprintf("socket,id=net%d,fd={{ fd_connect %q }}", i+1, nw.Socket))
- } else {
+ args = append(args, "-device", fmt.Sprintf("%s,netdev=net%d,mac=%s", virtioNet, i+1, nw.MACAddress))
+ case nw.Vz != "":
+ if runtime.GOOS != "darwin" {
+ return "", nil, fmt.Errorf("%+v is only supported on macOS", nw)
+ }
+ args = append(args, "-netdev", fmt.Sprintf("socket,id=net%d,fd={{ fd_connect_vz %q }}", i+1, nw.Vz))
+ args = append(args, "-device", fmt.Sprintf("%s,netdev=net%d,mac=%s", virtioNet, i+1, nw.MACAddress))
+ default:
return "", nil, fmt.Errorf("invalid network spec %+v", nw)
}
- args = append(args, "-device", fmt.Sprintf("%s,netdev=net%d,mac=%s", virtioNet, i+1, nw.MACAddress))
}
// virtio-rng-pci accelerates starting up the OS, according to https://wiki.gentoo.org/wiki/QEMU/Options
diff --git a/pkg/driver/qemu/qemu_driver.go b/pkg/driver/qemu/qemu_driver.go
index 3cb7bed29d7..3664f34c340 100644
--- a/pkg/driver/qemu/qemu_driver.go
+++ b/pkg/driver/qemu/qemu_driver.go
@@ -38,6 +38,7 @@ import (
"github.com/lima-vm/lima/v2/pkg/ptr"
"github.com/lima-vm/lima/v2/pkg/reflectutil"
"github.com/lima-vm/lima/v2/pkg/version/versionutil"
+ "github.com/lima-vm/lima/v2/pkg/vzvmnet/vmnetinterface"
)
type LimaQemuDriver struct {
@@ -284,7 +285,7 @@ func (l *LimaQemuDriver) Start(_ context.Context) (chan error, error) {
var qArgsFinal []string
applier := &qArgTemplateApplier{}
for _, unapplied := range qArgs {
- applied, err := applier.applyTemplate(unapplied)
+ applied, err := applier.applyTemplate(ctx, unapplied)
if err != nil {
return nil, err
}
@@ -627,7 +628,7 @@ type qArgTemplateApplier struct {
files []*os.File
}
-func (a *qArgTemplateApplier) applyTemplate(qArg string) (string, error) {
+func (a *qArgTemplateApplier) applyTemplate(ctx context.Context, qArg string) (string, error) {
if !strings.Contains(qArg, "{{") {
return qArg, nil
}
@@ -654,8 +655,8 @@ func (a *qArgTemplateApplier) applyTemplate(qArg string) (string, error) {
return "", err
}
a.files = append(a.files, f)
- fd := len(a.files) + 2 // the first FD is 3
- return strconv.Itoa(fd), nil
+ fd := f.Fd()
+ return strconv.Itoa(int(fd)), nil
}
res, err := fn(v)
if err != nil {
@@ -663,6 +664,26 @@ func (a *qArgTemplateApplier) applyTemplate(qArg string) (string, error) {
}
return res
},
+ "fd_connect_vz": func(v any) string {
+ fn := func(v any) (string, error) {
+ vzNetwork, ok := v.(string)
+ if !ok {
+ return "", fmt.Errorf("non-string argument %+v", v)
+ }
+ file, err := vmnetinterface.FileDescriptorForNetwork(ctx, vzNetwork)
+ if err != nil {
+ return "", fmt.Errorf("failed to get file descriptor for 'vz: %s': %w", vzNetwork, err)
+ }
+ a.files = append(a.files, file)
+ fd := file.Fd()
+ return strconv.Itoa(int(fd)), nil
+ }
+ res, err := fn(v)
+ if err != nil {
+ panic(fmt.Errorf("fd_connect_vz: %w", err))
+ }
+ return res
+ },
}
tmpl, err := template.New("").Funcs(funcMap).Parse(qArg)
if err != nil {
diff --git a/pkg/driver/vz/vm_darwin.go b/pkg/driver/vz/vm_darwin.go
index 397c3d5f3d2..8b7509af80d 100644
--- a/pkg/driver/vz/vm_darwin.go
+++ b/pkg/driver/vz/vm_darwin.go
@@ -37,6 +37,7 @@ import (
"github.com/lima-vm/lima/v2/pkg/networks/usernet"
"github.com/lima-vm/lima/v2/pkg/osutil"
"github.com/lima-vm/lima/v2/pkg/store"
+ "github.com/lima-vm/lima/v2/pkg/vzvmnet"
)
// diskImageCachingMode is set to DiskImageCachingModeCached so as to avoid disk corruption on ARM:
@@ -363,7 +364,8 @@ func attachNetwork(ctx context.Context, inst *limatype.Instance, vmConfig *vz.Vi
}
for i, nw := range inst.Networks {
- if nw.VZNAT != nil && *nw.VZNAT {
+ switch {
+ case nw.VZNAT != nil && *nw.VZNAT:
attachment, err := vz.NewNATNetworkDeviceAttachment()
if err != nil {
return err
@@ -373,7 +375,29 @@ func attachNetwork(ctx context.Context, inst *limatype.Instance, vmConfig *vz.Vi
return err
}
configurations = append(configurations, networkConfig)
- } else if nw.Lima != "" {
+ case nw.Vz != "":
+ nwCfg, err := networks.LoadConfig()
+ if err != nil {
+ return err
+ }
+ vzCfg, ok := nwCfg.Vz[nw.Vz]
+ if !ok {
+ return fmt.Errorf("networks.yaml: 'vz: %s' is not defined", nw.Vz)
+ }
+ network, err := vzvmnet.RequestVmnetNetwork(ctx, nw.Vz, vzCfg)
+ if err != nil {
+ return err
+ }
+ attachment, err := vz.NewVmnetNetworkDeviceAttachment(network.Raw())
+ if err != nil {
+ return err
+ }
+ networkConfig, err := newVirtioNetworkDeviceConfiguration(attachment, nw.MACAddress)
+ if err != nil {
+ return err
+ }
+ configurations = append(configurations, networkConfig)
+ case nw.Lima != "":
nwCfg, err := networks.LoadConfig()
if err != nil {
return err
@@ -425,7 +449,7 @@ func attachNetwork(ctx context.Context, inst *limatype.Instance, vmConfig *vz.Vi
configurations = append(configurations, networkConfig)
}
}
- } else if nw.Socket != "" {
+ case nw.Socket != "":
clientFile, err := DialQemu(ctx, nw.Socket)
if err != nil {
return err
diff --git a/pkg/driver/vz/vz_driver_darwin.go b/pkg/driver/vz/vz_driver_darwin.go
index 566ebe602e1..2b079e13b96 100644
--- a/pkg/driver/vz/vz_driver_darwin.go
+++ b/pkg/driver/vz/vz_driver_darwin.go
@@ -280,6 +280,7 @@ func validateConfig(_ context.Context, cfg *limatype.LimaYAML) error {
for i, nw := range cfg.Networks {
if unknown := reflectutil.UnknownNonEmptyFields(nw, "VZNAT",
+ "Vz",
"Lima",
"Socket",
"MACAddress",
@@ -288,6 +289,11 @@ func validateConfig(_ context.Context, cfg *limatype.LimaYAML) error {
); len(unknown) > 0 {
logrus.Warnf("vmType %s: ignoring networks[%d]: %+v", *cfg.VMType, i, unknown)
}
+ if nw.Vz != "" {
+ if macOSProductVersion.LessThan(*semver.New("26.0.0")) {
+ return fmt.Errorf("networks[%d]: 'vz: %s' require macOS 26.0 or later", i, nw.Vz)
+ }
+ }
}
switch audioDevice := *cfg.Audio.Device; audioDevice {
@@ -368,9 +374,10 @@ func (l *LimaVzDriver) Stop(_ context.Context) error {
return err
}
- timeout := time.After(5 * time.Second)
+ timeout := time.After(15 * time.Second)
ticker := time.NewTicker(500 * time.Millisecond)
for {
+ logrus.Debug("Waiting for VZ to stop...")
select {
case <-timeout:
return errors.New("vz timeout while waiting for stop status")
diff --git a/pkg/limatmpl/embed.go b/pkg/limatmpl/embed.go
index 4797369580a..a2792415ff8 100644
--- a/pkg/limatmpl/embed.go
+++ b/pkg/limatmpl/embed.go
@@ -543,6 +543,10 @@ func (tmpl *Template) combineNetworks() {
tmpl.copyListEntryField(networks, dst, src, "vzNAT")
dest.VZNAT = nw.VZNAT
}
+ if dest.Vz == "" && nw.Vz != "" {
+ tmpl.copyListEntryField(networks, dst, src, "vz")
+ dest.Vz = nw.Vz
+ }
if dest.Metric == nil && nw.Metric != nil {
tmpl.copyListEntryField(networks, dst, src, "metric")
dest.Metric = nw.Metric
diff --git a/pkg/limatype/lima_yaml.go b/pkg/limatype/lima_yaml.go
index fc48766cc85..02a9e472548 100644
--- a/pkg/limatype/lima_yaml.go
+++ b/pkg/limatype/lima_yaml.go
@@ -317,6 +317,9 @@ type Network struct {
Socket string `yaml:"socket,omitempty" json:"socket,omitempty"`
// VZNAT uses VZNATNetworkDeviceAttachment. Needs VZ. No root privilege is required.
VZNAT *bool `yaml:"vzNAT,omitempty" json:"vzNAT,omitempty"`
+ // Vz uses VZVmnetNetworkDeviceAttachment. Needs VZ. No root privilege is required.
+ // Requires macOS 26.0 or later.
+ Vz string `yaml:"vz,omitempty" json:"vz,omitempty"`
MACAddress string `yaml:"macAddress,omitempty" json:"macAddress,omitempty"`
Interface string `yaml:"interface,omitempty" json:"interface,omitempty"`
diff --git a/pkg/limayaml/validate.go b/pkg/limayaml/validate.go
index c03ca5e7d6b..a26df72162c 100644
--- a/pkg/limayaml/validate.go
+++ b/pkg/limayaml/validate.go
@@ -466,22 +466,41 @@ func validateNetwork(y *limatype.LimaYAML) error {
if nw.VZNAT != nil && *nw.VZNAT {
errs = errors.Join(errs, fmt.Errorf("field `%s.lima` and field `%s.vzNAT` are mutually exclusive", field, field))
}
+ if nw.Vz != "" {
+ errs = errors.Join(errs, fmt.Errorf("field `%s.lima` and field `%s.vz` are mutually exclusive", field, field))
+ }
case nw.Socket != "":
if nw.VZNAT != nil && *nw.VZNAT {
errs = errors.Join(errs, fmt.Errorf("field `%s.socket` and field `%s.vzNAT` are mutually exclusive", field, field))
}
+ if nw.Vz != "" {
+ errs = errors.Join(errs, fmt.Errorf("field `%s.socket` and field `%s.vz` are mutually exclusive", field, field))
+ }
if fi, err := os.Stat(nw.Socket); err != nil && !errors.Is(err, os.ErrNotExist) {
errs = errors.Join(errs, err)
} else if err == nil && fi.Mode()&os.ModeSocket == 0 {
errs = errors.Join(errs, fmt.Errorf("field `%s.socket` %q points to a non-socket file", field, nw.Socket))
}
case nw.VZNAT != nil && *nw.VZNAT:
+ if nw.Vz != "" {
+ errs = errors.Join(errs, fmt.Errorf("field `%s.vzNAT` and field `%s.vz` are mutually exclusive", field, field))
+ }
if nw.Lima != "" {
errs = errors.Join(errs, fmt.Errorf("field `%s.vzNAT` and field `%s.lima` are mutually exclusive", field, field))
}
if nw.Socket != "" {
errs = errors.Join(errs, fmt.Errorf("field `%s.vzNAT` and field `%s.socket` are mutually exclusive", field, field))
}
+ case nw.Vz != "":
+ if nw.VZNAT != nil && *nw.VZNAT {
+ errs = errors.Join(errs, fmt.Errorf("field `%s.vz` and field `%s.vzNAT` are mutually exclusive", field, field))
+ }
+ if nw.Lima != "" {
+ errs = errors.Join(errs, fmt.Errorf("field `%s.vz` and field `%s.lima` are mutually exclusive", field, field))
+ }
+ if nw.Socket != "" {
+ errs = errors.Join(errs, fmt.Errorf("field `%s.vz` and field `%s.socket` are mutually exclusive", field, field))
+ }
default:
errs = errors.Join(errs, fmt.Errorf("field `%s.lima` or field `%s.socket must be set", field, field))
}
diff --git a/pkg/networks/config.go b/pkg/networks/config.go
index 6cefe46b58e..576de6f0167 100644
--- a/pkg/networks/config.go
+++ b/pkg/networks/config.go
@@ -72,6 +72,13 @@ func fillDefaults(cfg Config) (Config, error) {
}
cfg.Networks[ModeUserV2] = defaultCfg.Networks[ModeUserV2]
}
+ if len(cfg.Vz) == 0 {
+ defaultCfg, err := DefaultConfig()
+ if err != nil {
+ return cfg, err
+ }
+ cfg.Vz = defaultCfg.Vz
+ }
return cfg, nil
}
diff --git a/pkg/networks/networks.TEMPLATE.yaml b/pkg/networks/networks.TEMPLATE.yaml
index 811ccdf5046..0ad5d1acb78 100644
--- a/pkg/networks/networks.TEMPLATE.yaml
+++ b/pkg/networks/networks.TEMPLATE.yaml
@@ -36,3 +36,24 @@ networks:
gateway: 192.168.106.1
dhcpEnd: 192.168.106.254
netmask: 255.255.255.0
+
+vz:
+ shared:
+ mode: shared
+ dhcp: true
+ dnsProxy: true
+ mtu: 1500
+ nat44: true
+ nat66: true
+ routerAdvertisement: true
+ subnet: 192.168.107.0/24
+ host:
+ mode: host
+ dhcp: true
+ dnsProxy: true
+ mtu: 1500
+ nat44: true
+ nat66: true
+ # host mode ignores routerAdvertisement setting
+ routerAdvertisement: false
+ subnet: 192.168.108.0/24
diff --git a/pkg/networks/networks.go b/pkg/networks/networks.go
index 717627fb0da..1989f313174 100644
--- a/pkg/networks/networks.go
+++ b/pkg/networks/networks.go
@@ -3,12 +3,16 @@
package networks
-import "net"
+import (
+ "net"
+ "net/netip"
+)
type Config struct {
- Paths Paths `yaml:"paths" json:"paths"`
- Group string `yaml:"group,omitempty" json:"group,omitempty"` // default: "everyone"
- Networks map[string]Network `yaml:"networks" json:"networks"`
+ Paths Paths `yaml:"paths" json:"paths"`
+ Group string `yaml:"group,omitempty" json:"group,omitempty"` // default: "everyone"
+ Networks map[string]Network `yaml:"networks" json:"networks"`
+ Vz map[string]VzVmnetConfig `yaml:"vz" json:"vz"`
}
type Paths struct {
@@ -38,3 +42,21 @@ type Network struct {
DHCPEnd net.IP `yaml:"dhcpEnd,omitempty" json:"dhcpEnd,omitempty"` // default: same as Gateway, last byte is 254
NetMask net.IP `yaml:"netmask,omitempty" json:"netmask,omitempty"` // default: 255.255.255.0
}
+
+type VzVmnetMode string
+
+const (
+ VzModeShared VzVmnetMode = "shared"
+ VzModeHost VzVmnetMode = "host"
+)
+
+type VzVmnetConfig struct {
+ Mode VzVmnetMode `yaml:"mode" json:"mode"` // "shared" or "host"
+ Dhcp bool `yaml:"dhcp,omitempty" json:"dhcp,omitempty"`
+ DNSProxy bool `yaml:"dnsProxy,omitempty" json:"dnsProxy,omitempty"`
+ Mtu uint32 `yaml:"mtu,omitempty" json:"mtu,omitempty"`
+ Nat44 bool `yaml:"nat44,omitempty" json:"nat44,omitempty"`
+ Nat66 bool `yaml:"nat66,omitempty" json:"nat66,omitempty"`
+ RouterAdvertisement bool `yaml:"routerAdvertisement,omitempty" json:"routerAdvertisement,omitempty"`
+ Subnet netip.Prefix `yaml:"subnet,omitempty" json:"subnet,omitempty"`
+}
diff --git a/pkg/vzvmnet/csops/cdhash_darwin.go b/pkg/vzvmnet/csops/cdhash_darwin.go
new file mode 100644
index 00000000000..a7290622240
--- /dev/null
+++ b/pkg/vzvmnet/csops/cdhash_darwin.go
@@ -0,0 +1,59 @@
+// SPDX-FileCopyrightText: Copyright The Lima Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package csops
+
+/*
+#include
+#include
+#include
+
+// see: https://github.com/apple-oss-distributions/xnu/blob/f6217f891ac0bb64f3d375211650a4c1ff8ca1ea/bsd/sys/codesign.h#L72
+int csops(pid_t pid, unsigned int ops, void *useraddr, size_t usersize);
+
+// see: https://github.com/apple-oss-distributions/xnu/blob/f6217f891ac0bb64f3d375211650a4c1ff8ca1ea/bsd/sys/codesign.h#L48
+#define CS_OPS_CDHASH 5
+
+enum {
+// see: https://github.com/apple-oss-distributions/xnu/blob/f6217f891ac0bb64f3d375211650a4c1ff8ca1ea/osfmk/kern/cs_blobs.h#L142
+ CS_CDHASH_LEN = 20,
+};
+
+*/
+import (
+ "C" //nolint:gocritic // false positive: dupImport: package is imported 2 times under different aliases on... (gocritic)
+)
+
+import (
+ "fmt"
+ "os"
+ "unsafe" //nolint:gocritic // false positive: dupImport: package is imported 2 times under different aliases on... (gocritic)
+)
+
+// Cdhash retrieves the CDHash of the process with the given PID using csops.
+// Returns a byte slice containing the CDHash or an error if the operation fails.
+// The CDHash is a unique identifier for the code signature of the executable.
+//
+// CDHash can also be obtained from an executable using the following command:
+//
+// codesign --display -vvv 2>&1 | grep 'CDHash='
+func Cdhash(pid int) ([]byte, error) {
+ buf := make([]byte, C.CS_CDHASH_LEN)
+ r, err := C.csops(
+ C.pid_t(pid),
+ C.CS_OPS_CDHASH,
+ unsafe.Pointer(&buf[0]),
+ C.size_t(len(buf)),
+ )
+ if r != 0 {
+ return nil, fmt.Errorf("csops failed: %w", err)
+ }
+ return buf, nil
+}
+
+// SelfCdhash retrieves the CDHash of the current process using csops.
+// Returns a byte slice containing the CDHash or an error if the operation fails.
+// The CDHash is a unique identifier for the code signature of the executable.
+func SelfCdhash() ([]byte, error) {
+ return Cdhash(os.Getpid())
+}
diff --git a/pkg/vzvmnet/csops/cdhash_darwin_test.go b/pkg/vzvmnet/csops/cdhash_darwin_test.go
new file mode 100644
index 00000000000..26a1b3f46cf
--- /dev/null
+++ b/pkg/vzvmnet/csops/cdhash_darwin_test.go
@@ -0,0 +1,89 @@
+// SPDX-FileCopyrightText: Copyright The Lima Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package csops
+
+import (
+ "encoding/hex"
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "slices"
+ "syscall"
+ "testing"
+
+ "gotest.tools/v3/assert"
+)
+
+// TestMain ensures that the test binary is code-signed before running the tests.
+func TestMain(m *testing.M) {
+ flag.BoolVar(&signed, "signed", false, "indicates whether the executable is already code-signed")
+ flag.Parse()
+ if _, filename, _, ok := runtime.Caller(0); !ok {
+ log.Fatal("failed to get caller info")
+ } else if !signed {
+ // declare the script path relative to this source file
+ script := filepath.Join(filepath.Dir(filename), "codesign-and-exec.sh")
+ // re-exec the current test binary via the codesign-and-exec.sh script
+ // with the -signed flag to avoid infinite recursion
+ args := append([]string{script, os.Args[0], "-signed"}, os.Args[1:]...)
+ if err := syscall.Exec(script, args, os.Environ()); err != nil {
+ log.Fatalf("failed to re-exec signed executable: %v", err)
+ }
+ }
+ // run the tests with the signed executable
+ m.Run()
+}
+
+// signed indicates whether the test executable is code-signed and re-executed via the TestMain function.
+var signed bool
+
+// TestCdhashes tests that the Cdhash function correctly detects the code directory hash
+// of various processes and compares it with the output of the "codesign" command.
+func TestCdhashes(t *testing.T) {
+ tests := []struct {
+ path string
+ pid int
+ }{
+ {path: "/sbin/launchd", pid: 1},
+ {path: executable(t), pid: os.Getpid()},
+ }
+ for _, tt := range tests {
+ t.Run(fmt.Sprintf("Cdhash(%d)", tt.pid), func(t *testing.T) {
+ // Get the expected CDHash via the "codesign" command.
+ expected := cdhashViaCodesign(t, tt.path)
+ t.Logf("Expected CDHash: %x", expected)
+
+ // Get the CDHash via Cdhash.
+ hash, err := Cdhash(tt.pid)
+ assert.NilError(t, err, "Cdhash failed for pid %d", tt.pid)
+ t.Logf("Cdhash(%d): %x", tt.pid, hash)
+ assert.Check(t, slices.Equal(hash, expected), "Cdhash(%d) returned incorrect hash value expected %x, got %x", tt.pid, expected, hash)
+ })
+ }
+}
+
+// executable returns the path to the current executable.
+func executable(t *testing.T) string {
+ path, err := os.Executable()
+ assert.NilError(t, err, "failed to get executable path")
+ return path
+}
+
+// cdhashViaCodesign retrieves the code directory hash (CDHash) of the given path
+// by invoking the "codesign" command.
+func cdhashViaCodesign(t *testing.T, path string) []byte {
+ display := exec.CommandContext(t.Context(), "codesign", "--display", "-vvv", path)
+ output, err := display.CombinedOutput()
+ assert.NilError(t, err, "failed to display codesign info for %q: %s\noutput: %s", path, string(output))
+ matches := regexp.MustCompile(`(?ms)^\s*CDHash=([0-9a-fA-F]+)`).FindStringSubmatch(string(output))
+ assert.Equal(t, len(matches), 2, "failed to parse CDHash from codesign output for %q: %s", path, string(output))
+ hash, err := hex.DecodeString(matches[1])
+ assert.NilError(t, err, "failed to decode CDHash hex string for %q", path)
+ return hash
+}
diff --git a/pkg/vzvmnet/csops/codesign-and-exec.sh b/pkg/vzvmnet/csops/codesign-and-exec.sh
new file mode 100755
index 00000000000..be827c31ce5
--- /dev/null
+++ b/pkg/vzvmnet/csops/codesign-and-exec.sh
@@ -0,0 +1,25 @@
+#!/bin/bash
+
+# SPDX-FileCopyrightText: Copyright The Lima Authors
+# SPDX-License-Identifier: Apache-2.0
+
+set -euo pipefail
+
+# If the OS is macOS and codesign is available, sign ${1} as an executable
+# with the virtualization entitlement, then exec it with the given arguments.
+# Expected to be used with `-exec .../codesign-and-exec.sh` when executing `go` command.
+if OS=$(uname -s) && [[ ${OS} == "Darwin" ]] && command -v codesign >/dev/null 2>&1; then
+ cat <<-'EOF' >"$1.entitlements"
+
+
+
+
+ com.apple.security.virtualization
+
+
+
+ EOF
+ codesign --entitlements "$1.entitlements" --force -s - -v "$1"
+ rm -f "$1.entitlements"
+fi
+exec "${@}"
diff --git a/pkg/vzvmnet/ifaddrs_darwin.go b/pkg/vzvmnet/ifaddrs_darwin.go
new file mode 100644
index 00000000000..6bf39177421
--- /dev/null
+++ b/pkg/vzvmnet/ifaddrs_darwin.go
@@ -0,0 +1,146 @@
+// SPDX-FileCopyrightText: Copyright The Lima Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package vzvmnet
+
+/*
+ #include
+ #include
+*/
+import (
+ "C" //nolint:gocritic // false positive: dupImport: package is imported 2 times under different aliases on... (gocritic)
+)
+
+import (
+ "log"
+ "net"
+ "net/netip"
+ "slices"
+ "syscall"
+ "unsafe" //nolint:gocritic // false positive: dupImport: package is imported 2 times under different aliases on... (gocritic)
+)
+
+// LookupInterfaceAndTypeByPrefix looks up a network interface by IP prefix.
+// It returns the first interface that has the specified prefix.
+// If no such interface is found, it returns (nil, nil).
+func LookupInterfaceAndTypeByPrefix(prefix netip.Prefix) (*InterfaceWithTypePrefixesRawFlags, error) {
+ ifas, err := NewInterfaces()
+ if err != nil {
+ return nil, err
+ }
+ for _, ifa := range ifas {
+ if ifa.PrefixesContains(prefix) {
+ return &ifa, nil
+ }
+ }
+ return nil, nil
+}
+
+// NewInterfaces returns a list of network interfaces with their type, prefixes, and raw flags.
+// It uses getifaddrs(3) to retrieve the list of interfaces.
+// Similar to net.NewInterfaces, but also includes interface type, prefixes, and raw flags.
+func NewInterfaces() (Interfaces, error) {
+ var ifaddrs *C.struct_ifaddrs
+ //nolint:gocritic // false positive: dupSubExpr: suspicious identical LHS and RHS for `==` operator (gocritic)
+ if res, err := C.getifaddrs(&ifaddrs); res != 0 && err != nil {
+ return nil, err
+ }
+ defer C.freeifaddrs(ifaddrs)
+
+ entries := make([]InterfaceWithTypePrefixesRawFlags, 0)
+ var entry *InterfaceWithTypePrefixesRawFlags
+ for ifa := ifaddrs; ifa != nil; ifa = ifa.ifa_next {
+ switch ifa.ifa_addr.sa_family {
+ case C.AF_LINK:
+ entries = append(entries, InterfaceWithTypePrefixesRawFlags{})
+ entry = &entries[len(entries)-1]
+ entry.Name = C.GoString(ifa.ifa_name)
+ entry.Flags = linkFlags(ifa.ifa_flags)
+ entry.Prefixes = make([]netip.Prefix, 0)
+ entry.rawFlags = uint(ifa.ifa_flags)
+ sa := (*syscall.RawSockaddrDatalink)(unsafe.Pointer(ifa.ifa_addr))
+ if ifa.ifa_data != nil {
+ ifData := (*syscall.IfData)(ifa.ifa_data)
+ entry.Index = int(sa.Index)
+ entry.MTU = int(ifData.Mtu)
+ entry.Type = ifData.Type
+ } else {
+ // Fallback to use sa_type
+ entry.Type = sa.Type
+ }
+ if sa.Alen > 0 {
+ mac := slices.Clone(unsafe.Slice((*byte)(unsafe.Pointer(&sa.Data[sa.Nlen])), sa.Alen))
+ entry.HardwareAddr = net.HardwareAddr(mac)
+ }
+ case C.AF_INET:
+ sa := (*syscall.RawSockaddrInet4)(unsafe.Pointer(ifa.ifa_addr))
+ mask := (*syscall.RawSockaddrInet4)(unsafe.Pointer(ifa.ifa_netmask))
+ ip := netip.AddrFrom4(sa.Addr)
+ ones, _ := net.IPMask(mask.Addr[0:4]).Size()
+ prefix := netip.PrefixFrom(ip, ones)
+ entry.Prefixes = append(entry.Prefixes, prefix)
+ case C.AF_INET6:
+ sa := (*syscall.RawSockaddrInet6)(unsafe.Pointer(ifa.ifa_addr))
+ mask := (*syscall.RawSockaddrInet6)(unsafe.Pointer(ifa.ifa_netmask))
+ ip := netip.AddrFrom16(sa.Addr)
+ ones, _ := net.IPMask(mask.Addr[0:16]).Size()
+ prefix := netip.PrefixFrom(ip, ones)
+ entry.Prefixes = append(entry.Prefixes, prefix)
+ default:
+ log.Printf("Skipping interface %s with sa_family %d", C.GoString(ifa.ifa_name), ifa.ifa_addr.sa_family)
+ }
+ }
+ return entries, nil
+}
+
+// Interfaces is a slice of InterfaceWithTypePrefixesRawFlags.
+type Interfaces []InterfaceWithTypePrefixesRawFlags
+
+// LookupInterface looks up an interface that contains the given [netip.Prefix].
+// Returns nil if no such interface is found.
+func (ifas Interfaces) LookupInterface(prefix netip.Prefix) *InterfaceWithTypePrefixesRawFlags {
+ for _, ifa := range ifas {
+ if ifa.PrefixesContains(prefix) {
+ return &ifa
+ }
+ }
+ return nil
+}
+
+// InterfaceWithTypePrefixesRawFlags extends net.Interface with Type, Prefixes, and RawFlags.
+type InterfaceWithTypePrefixesRawFlags struct {
+ net.Interface
+ rawFlags uint // syscall.IFF_*
+ Type uint8 // syscall.IFT_*
+ Prefixes []netip.Prefix
+}
+
+// PrefixesContains checks if the interface has a prefix that contains the given prefix.
+func (ifa *InterfaceWithTypePrefixesRawFlags) PrefixesContains(prefix netip.Prefix) bool {
+ addr := prefix.Addr()
+ return slices.ContainsFunc(ifa.Prefixes, func(p netip.Prefix) bool { return p.Contains(addr) })
+}
+
+// linkFlags converts C.uint flags to net.Flags based on net.linkFlags in interface_bsd.go.
+func linkFlags(rawFlags C.uint) net.Flags {
+ var f net.Flags
+ if rawFlags&syscall.IFF_UP != 0 {
+ f |= net.FlagUp
+ }
+ if rawFlags&syscall.IFF_RUNNING != 0 {
+ f |= net.FlagRunning
+ }
+ if rawFlags&syscall.IFF_BROADCAST != 0 {
+ f |= net.FlagBroadcast
+ }
+ if rawFlags&syscall.IFF_LOOPBACK != 0 {
+ f |= net.FlagLoopback
+ }
+ if rawFlags&syscall.IFF_POINTOPOINT != 0 {
+ f |= net.FlagPointToPoint
+ }
+ if rawFlags&syscall.IFF_MULTICAST != 0 {
+ f |= net.FlagMulticast
+ }
+ return f
+}
diff --git a/pkg/vzvmnet/ifaddrs_darwin_test.go b/pkg/vzvmnet/ifaddrs_darwin_test.go
new file mode 100644
index 00000000000..8a0c0868879
--- /dev/null
+++ b/pkg/vzvmnet/ifaddrs_darwin_test.go
@@ -0,0 +1,33 @@
+// SPDX-FileCopyrightText: Copyright The Lima Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package vzvmnet
+
+import (
+ "net"
+ "testing"
+
+ "gotest.tools/v3/assert"
+)
+
+// TestInterfaces tests that the Interfaces function correctly retrieves
+// the list of network interfaces and matches the output of net.Interfaces.
+func TestInterfaces(t *testing.T) {
+ ifas, err := net.Interfaces()
+ assert.NilError(t, err)
+ assert.Assert(t, len(ifas) > 0)
+
+ ifas2, err := NewInterfaces()
+ assert.NilError(t, err)
+ assert.Assert(t, len(ifas2) > 0)
+ assert.Equal(t, len(ifas), len(ifas2))
+ for i, ifa := range ifas {
+ ifa2 := ifas2[i]
+ assert.Equal(t, ifa.Index, ifa2.Index)
+ assert.Equal(t, ifa.MTU, ifa2.MTU)
+ assert.Equal(t, ifa.Name, ifa2.Name)
+ assert.Equal(t, ifa.HardwareAddr.String(), ifa2.HardwareAddr.String())
+ assert.Equal(t, ifa.Flags, ifa2.Flags)
+ assert.Assert(t, ifa2.Type != 0)
+ }
+}
diff --git a/pkg/vzvmnet/io.lima-vm.vz.vmnet.plist b/pkg/vzvmnet/io.lima-vm.vz.vmnet.plist
new file mode 100644
index 00000000000..a7ce71a7ec5
--- /dev/null
+++ b/pkg/vzvmnet/io.lima-vm.vz.vmnet.plist
@@ -0,0 +1,27 @@
+
+
+
+
+ Label
+ {{.Label}}
+ ProgramArguments
+
+ {{- range $arg := .ProgramArguments}}
+ {{$arg}}
+ {{- end}}
+
+ WorkingDirectory
+ {{ .WorkingDirectory }}
+ StandardErrorPath
+ {{ .WorkingDirectory }}/stderr.log
+ StandardOutPath
+ {{ .WorkingDirectory }}/stdout.log
+ MachServices
+
+ {{- range $service := .MachServices}}
+ {{$service}}
+
+ {{- end}}
+
+
+
\ No newline at end of file
diff --git a/pkg/vzvmnet/networkchange/cgo_handle_darwin.go b/pkg/vzvmnet/networkchange/cgo_handle_darwin.go
new file mode 100644
index 00000000000..990d18655d5
--- /dev/null
+++ b/pkg/vzvmnet/networkchange/cgo_handle_darwin.go
@@ -0,0 +1,53 @@
+// SPDX-FileCopyrightText: Copyright The Lima Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package networkchange
+
+/*
+#include "networkchange_darwin.h"
+*/
+import "C"
+
+import (
+ "runtime"
+ "runtime/cgo"
+)
+
+// cgoHandler holds a cgo.Handle for an Object.
+// It provides methods to hold and release the handle.
+// handle will released when cgoHandler.release is called.
+type cgoHandler struct {
+ handle cgo.Handle
+}
+
+// releaseOnCleanup registers a cleanup function to delete the cgo.Handle when cleaned up.
+func (h *cgoHandler) releaseOnCleanup() {
+ runtime.AddCleanup(h, func(h cgo.Handle) {
+ h.Delete()
+ }, h.handle)
+}
+
+// newCgoHandler creates a new cgoHandler and holds the given value.
+func newCgoHandler(v any) (handleForGo *cgoHandler, handleForC C.uintptr_t) {
+ if v == nil {
+ return nil, 0
+ }
+ h := &cgoHandler{cgo.NewHandle(v)}
+ return ReleaseOnCleanup(h), C.uintptr_t(h.handle)
+}
+
+// unwrapHandler unwraps the cgo.Handle from the given uintptr and returns the associated value.
+// It does NOT delete the handle; it expects the handle to be managed by cgoHandler or caller.
+func unwrapHandler[T any](handle uintptr) T {
+ if handle == 0 {
+ var zero T
+ return zero
+ }
+ return cgo.Handle(handle).Value().(T)
+}
+
+// ReleaseOnCleanup calls releaseOnCleanup method on the given object and returns it.
+func ReleaseOnCleanup[O interface{ releaseOnCleanup() }](o O) O {
+ o.releaseOnCleanup()
+ return o
+}
diff --git a/pkg/vzvmnet/networkchange/networkchange_darwin.go b/pkg/vzvmnet/networkchange/networkchange_darwin.go
new file mode 100644
index 00000000000..53636ec71b0
--- /dev/null
+++ b/pkg/vzvmnet/networkchange/networkchange_darwin.go
@@ -0,0 +1,67 @@
+// SPDX-FileCopyrightText: Copyright The Lima Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package networkchange
+
+/*
+#cgo darwin CFLAGS: -x objective-c -fno-objc-arc
+#cgo darwin LDFLAGS: -lobjc
+#import "networkchange_darwin.h"
+*/
+import "C"
+
+// Notifier represents a network change notifier.
+type Notifier struct {
+ token int
+ notifyHandler *cgoHandler
+}
+
+type NotifyHandler func(*Notifier)
+
+// NewNotifier creates a new Notifier instance.
+// It registers for network change notifications and sets up the provided handler to be called upon notifications.
+// The caller is responsible for calling Cancel() to clean up resources.
+//
+// It uses the Darwin notify API:
+// - https://developer.apple.com/documentation/darwinnotify/notify_register_dispatch
+// - https://developer.apple.com/documentation/darwinnotify/knotifyscnetworkchange/
+func NewNotifier(handler NotifyHandler) *Notifier {
+ if handler == nil {
+ return nil
+ }
+ var token C.int
+ cgoHandler, handle := newCgoHandler(handler)
+ res := C.notifyRegisterDispatch(&token, handle)
+ if res != 0 {
+ cgoHandler.releaseOnCleanup()
+ return nil
+ }
+ return &Notifier{
+ token: int(token),
+ notifyHandler: cgoHandler,
+ }
+}
+
+//export callNotifyHandler
+func callNotifyHandler(handlerPtr uintptr, token int) {
+ handler := unwrapHandler[NotifyHandler](handlerPtr)
+ handler(&Notifier{token: token})
+}
+
+// Suspend suspends the notifier.
+// - https://developer.apple.com/documentation/darwinnotify/notify_suspend/
+func (n *Notifier) Suspend() {
+ C.notify_suspend(C.int(n.token))
+}
+
+// Resume resumes the notifier.
+// - https://developer.apple.com/documentation/darwinnotify/notify_resume/
+func (n *Notifier) Resume() {
+ C.notify_resume(C.int(n.token))
+}
+
+// Cancel cancels the notifier.
+// - https://developer.apple.com/documentation/darwinnotify/notify_cancel/
+func (n *Notifier) Cancel() {
+ C.notify_cancel(C.int(n.token))
+}
diff --git a/pkg/vzvmnet/networkchange/networkchange_darwin.h b/pkg/vzvmnet/networkchange/networkchange_darwin.h
new file mode 100644
index 00000000000..944762c96a8
--- /dev/null
+++ b/pkg/vzvmnet/networkchange/networkchange_darwin.h
@@ -0,0 +1,11 @@
+// SPDX-FileCopyrightText: Copyright The Lima Authors
+// SPDX-License-Identifier: Apache-2.0
+
+#pragma once
+
+#import
+#import
+
+// MARK: - Darwin notify API
+
+uint32_t notifyRegisterDispatch(int *out_token, uintptr_t handler);
diff --git a/pkg/vzvmnet/networkchange/networkchange_darwin.m b/pkg/vzvmnet/networkchange/networkchange_darwin.m
new file mode 100644
index 00000000000..79e9030001c
--- /dev/null
+++ b/pkg/vzvmnet/networkchange/networkchange_darwin.m
@@ -0,0 +1,19 @@
+// SPDX-FileCopyrightText: Copyright The Lima Authors
+// SPDX-License-Identifier: Apache-2.0
+
+#import "networkchange_darwin.h"
+
+// MARK: - notify API
+
+extern void callNotifyHandler(uintptr_t handler, int token);
+
+uint32_t notifyRegisterDispatch(int *out_token, uintptr_t handler)
+{
+ dispatch_queue_t dq = dispatch_queue_create("io.lima-vm.vzvmnet.notify", DISPATCH_QUEUE_SERIAL);
+ uint32_t res = notify_register_dispatch(kNotifySCNetworkChange, out_token,
+ dq, ^(int token) {
+ callNotifyHandler(handler, token);
+ });
+ dispatch_release(dq);
+ return res;
+}
diff --git a/pkg/vzvmnet/vmnetinterface/vmnetinterface_darwin.go b/pkg/vzvmnet/vmnetinterface/vmnetinterface_darwin.go
new file mode 100644
index 00000000000..38230ddba39
--- /dev/null
+++ b/pkg/vzvmnet/vmnetinterface/vmnetinterface_darwin.go
@@ -0,0 +1,254 @@
+// SPDX-FileCopyrightText: Copyright The Lima Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package vmnetinterface
+
+/*
+#cgo darwin CFLAGS: -mmacosx-version-min=11 -x objective-c -fno-objc-arc
+#cgo darwin LDFLAGS: -lobjc -framework Foundation -framework vmnet
+#include "vmnetinterface_darwin.h"
+*/
+import (
+ "C" //nolint: gocritic // dupImport: package is imported 2 times under different aliases on lines.. (gocritic)
+)
+
+import (
+ "context"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "net"
+ "os"
+ "runtime"
+ "runtime/cgo"
+ "syscall"
+ "unsafe" //nolint: gocritic // dupImport: package is imported 2 times under different aliases on lines.. (gocritic)
+
+ "github.com/Code-Hex/vz/v3/pkg/vmnet"
+ "github.com/Code-Hex/vz/v3/pkg/xpc"
+ "github.com/sirupsen/logrus"
+
+ "github.com/lima-vm/lima/v2/pkg/networks"
+ "github.com/lima-vm/lima/v2/pkg/vzvmnet"
+)
+
+// FileDescriptorForNetwork returns a file for the given vz network.
+func FileDescriptorForNetwork(ctx context.Context, vzNetwork string) (*os.File, error) {
+ // Load network configuration
+ nwCfg, err := networks.LoadConfig()
+ if err != nil {
+ return nil, err
+ }
+ vmnetConfig, ok := nwCfg.Vz[vzNetwork]
+ if !ok {
+ return nil, fmt.Errorf("networks.yaml: 'vz: %s' is not defined", vzNetwork)
+ }
+ // Request VmnetNetwork
+ network, err := vzvmnet.RequestVmnetNetwork(ctx, vzNetwork, vmnetConfig)
+ if err != nil {
+ return nil, fmt.Errorf("RequestVmnetNetwork failed: %w", err)
+ }
+
+ // Create socketpair connection as conn and file
+ conn, file, err := connAndFile()
+ if err != nil {
+ return nil, fmt.Errorf("connAndFile failed: %w", err)
+ }
+
+ iface, err := startWithNetwork(network.Raw(), xpc.NewDictionary())
+ if err != nil {
+ return nil, fmt.Errorf("startWithNetwork failed: %w", err)
+ }
+
+ go func() {
+ logrus.Debug("[vmnet] vmnet interface goroutine started")
+ defer C.VmnetStopInterface(iface.ptr)
+
+ readBuffers, readVMPktDescs := NewVMPktDescArrayWithBuffers(iface.maxReadPacketCount, iface.maxPacketSize)
+ defer C.deallocateVMPktDescArray(readVMPktDescs)
+
+ defer conn.Close()
+ rawConn, _ := conn.(syscall.Conn).SyscallConn()
+
+ var callback packetsAvailableEventCallback = func(estimatedCount C.int) {
+ for estimatedCount > 0 {
+ count := min(estimatedCount, iface.maxReadPacketCount)
+ // Reset vmPktDescs before reading
+ C.resetVMPktDescArray(readVMPktDescs, iface.maxReadPacketCount, iface.maxPacketSize)
+ // Read packets from vmnet interface
+ if result := vmnet.Return(C.VmnetRead(iface.ptr, readVMPktDescs, &count)); result != vmnet.ErrSuccess {
+ logrus.WithError(result).Error("VmnetRead failed")
+ return
+ }
+ // Write packets to the connection
+ writeBuffers := buffersForWritingToConn(readVMPktDescs, count)
+ if _, err := writeBuffers.WriteTo(conn); err != nil {
+ logrus.WithError(err).Error("writeBuffers.WriteTo failed")
+ return
+ }
+ estimatedCount -= count
+ }
+ }
+ cgoHandle := cgo.NewHandle(callback)
+ defer cgoHandle.Delete()
+ if result := vmnet.Return(
+ C.VmnetInterfaceSetPacketsAvailableEventCallback(iface.ptr, C.uintptr_t(cgoHandle)),
+ ); result != vmnet.ErrSuccess {
+ logrus.WithError(result).Error("VmnetInterfaceSetPacketsAvailableEventCallback failed")
+ return
+ }
+ // Start reading packet from the connection (VM) and writing to vmnet interface.
+ // Packets comes one by one with 4-byte big-endian header indicating the packet size.
+ // So, writing packets also happens one by one.
+ writeBuffers, writeVMPktDesc := NewVMPktDescArrayWithBuffers(1, iface.maxPacketSize)
+ defer C.deallocateVMPktDescArray(writeVMPktDesc)
+ for {
+ var header [4]byte
+ if _, err := conn.Read(header[:]); err != nil {
+ logrus.WithError(err).Error("conn.Read failed")
+ break
+ }
+ packetLen := binary.BigEndian.Uint32(header[:])
+ if packetLen == 0 || C.uint64_t(packetLen) > iface.maxPacketSize {
+ logrus.Errorf("invalid packetLen: %d (max %d)", packetLen, iface.maxPacketSize)
+ break
+ }
+
+ // Reset writeVMPktDesc before reading
+ C.resetVMPktDescArray(writeVMPktDesc, 1, C.uint64_t(packetLen))
+
+ // Read packet from the connection
+ var read int
+ err = rawConn.Read(func(fd uintptr) (done bool) {
+ p := unsafe.SliceData(writeBuffers[0])
+ buf := unsafe.Slice((*byte)(unsafe.Add(unsafe.Pointer(p), read)), int(packetLen)-read)
+ n, err := syscall.Read(int(fd), buf)
+ if n <= 0 {
+ if errors.Is(err, syscall.EAGAIN) {
+ return false
+ }
+ logrus.WithError(err).Error("readVMPktDesc failed")
+ return true
+ }
+ read += n
+ return (read >= int(packetLen))
+ })
+ if err != nil {
+ logrus.WithError(err).Error("rawConn.Read failed")
+ break
+ }
+ // Write packet to vmnet interface
+ count := C.int(1)
+ if result := vmnet.Return(C.VmnetWrite(iface.ptr, writeVMPktDesc, &count)); result != vmnet.ErrSuccess {
+ logrus.WithError(result).Error("VmnetWrite failed")
+ break
+ }
+ }
+ // Keep readBuffers and writeBuffers alive until the goroutine ends
+ runtime.KeepAlive(readBuffers)
+ runtime.KeepAlive(writeBuffers)
+ }()
+
+ go func() {
+ <-ctx.Done()
+ if err := conn.Close(); err != nil {
+ logrus.WithError(err).Error("failed to close conn on context done")
+ }
+ }()
+
+ return file, nil
+}
+
+// connAndFile creates a socketpair and returns one end as net.Conn and the other as *os.File.
+func connAndFile() (net.Conn, *os.File, error) {
+ fds, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_STREAM, 0)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to create socketpair: %w", err)
+ }
+ if err := syscall.SetNonblock(fds[0], true); err != nil {
+ syscall.Close(fds[0])
+ syscall.Close(fds[1])
+ return nil, nil, fmt.Errorf("failed to set nonblock on socketpair[0]: %w", err)
+ }
+ if err := syscall.SetNonblock(fds[1], true); err != nil {
+ syscall.Close(fds[0])
+ syscall.Close(fds[1])
+ return nil, nil, fmt.Errorf("failed to set nonblock on socketpair[1]: %w", err)
+ }
+
+ connFile := os.NewFile(uintptr(fds[0]), "vmnet-conn")
+ conn, err := net.FileConn(connFile)
+ if err != nil {
+ connFile.Close()
+ syscall.Close(fds[1])
+ return nil, nil, fmt.Errorf("failed to create FileConn: %w", err)
+ }
+ if err = connFile.Close(); err != nil {
+ conn.Close()
+ syscall.Close(fds[1])
+ return nil, nil, fmt.Errorf("failed to close connFile: %w", err)
+ }
+ file := os.NewFile(uintptr(fds[1]), "vmnet-file")
+ return conn, file, nil
+}
+
+// iface represents a VmnetInterface instance.
+type iface struct {
+ ptr unsafe.Pointer
+ param *xpc.Dictionary
+ maxPacketSize C.uint64_t
+ maxReadPacketCount C.int
+ maxWritePacketCount C.int
+}
+
+// startWithNetwork starts a VmnetInterface with the given VmnetNetwork and interface description.
+func startWithNetwork(network unsafe.Pointer, interfaceDesc *xpc.Dictionary) (*iface, error) {
+ result := C.VmnetInterfaceStartWithNetwork(network, interfaceDesc.Raw())
+ if vmnetResult := vmnet.Return(result.vmnetReturn); vmnetResult != vmnet.ErrSuccess {
+ return nil, fmt.Errorf("VmnetInterfaceStartWithNetwork failed: %w", vmnetResult)
+ }
+ return &iface{
+ ptr: result.iface,
+ param: xpc.ReleaseOnCleanup(xpc.NewObject(result.ifaceParam).(*xpc.Dictionary)),
+ maxPacketSize: result.maxPacketSize,
+ maxReadPacketCount: result.maxReadPacketCount,
+ maxWritePacketCount: result.maxWritePacketCount,
+ }, nil
+}
+
+type packetsAvailableEventCallback func(estimatedCount C.int)
+
+//export callPacketsAvailableEventCallback
+func callPacketsAvailableEventCallback(cgoHandle uintptr, estimatedCount C.int) {
+ if cgoHandle != 0 {
+ callback := cgo.Handle(cgoHandle).Value().(packetsAvailableEventCallback)
+ callback(estimatedCount)
+ }
+}
+
+// NewVMPktDescArrayWithBuffers allocates VMPktDesc array and backing buffers.
+func NewVMPktDescArrayWithBuffers(count C.int, maxPacketSize C.uint64_t) (net.Buffers, *C.struct_vmpktdesc) {
+ vmPktDescs := C.allocateVMPktDescArray(count, maxPacketSize)
+ bufs := make(net.Buffers, 0, int(count))
+ for i := range count {
+ buf := make([]byte, maxPacketSize)
+ vmPktDesc := (*C.struct_vmpktdesc)(unsafe.Add(unsafe.Pointer(vmPktDescs), uintptr(i)*unsafe.Sizeof(*vmPktDescs)))
+ vmPktDesc.vm_pkt_iov.iov_base = unsafe.Pointer(unsafe.SliceData(buf))
+ bufs = append(bufs, buf)
+ }
+ return bufs, vmPktDescs
+}
+
+// buffersForWritingToConn creates net.Buffers for writing from the given VMPktDesc array.
+func buffersForWritingToConn(vmPktDescs *C.struct_vmpktdesc, count C.int) net.Buffers {
+ bufs := make(net.Buffers, 0, int(count*2))
+ for i := range int(count) {
+ vmPktDesc := (*C.struct_vmpktdesc)(unsafe.Add(unsafe.Pointer(vmPktDescs), uintptr(i)*unsafe.Sizeof(*vmPktDescs)))
+ var header [4]byte
+ binary.BigEndian.PutUint32(header[:], uint32(vmPktDesc.vm_pkt_size))
+ bufs = append(bufs, header[:])
+ buf := unsafe.Slice((*byte)(vmPktDesc.vm_pkt_iov.iov_base), vmPktDesc.vm_pkt_size)
+ bufs = append(bufs, buf)
+ }
+ return bufs
+}
diff --git a/pkg/vzvmnet/vmnetinterface/vmnetinterface_darwin.h b/pkg/vzvmnet/vmnetinterface/vmnetinterface_darwin.h
new file mode 100644
index 00000000000..6e5fc742f8f
--- /dev/null
+++ b/pkg/vzvmnet/vmnetinterface/vmnetinterface_darwin.h
@@ -0,0 +1,64 @@
+#pragma once
+
+#import
+#import
+#import
+#import
+#import
+
+// MARK: - Macros
+
+// To avoid including virtualization_helper.h, copied from there.
+NSDictionary *vmnetInterfaceDumpProcessinfo();
+
+#define RAISE_REASON_MESSAGE \
+ "This may possibly be a bug due to library handling errors.\n" \
+ "I would appreciate it if you could report it to https://github.com/Code-Hex/vz/issues/new/choose\n\n" \
+ "Information: %@\n"
+
+#define RAISE_UNSUPPORTED_MACOS_EXCEPTION() \
+ do { \
+ [NSException \
+ raise:@"UnhandledAvailabilityException" \
+ format:@RAISE_REASON_MESSAGE, vmnetInterfaceDumpProcessinfo()]; \
+ __builtin_unreachable(); \
+ } while (0)
+
+// for macOS 26 API
+#if __MAC_OS_X_VERSION_MAX_ALLOWED >= 260000
+#define INCLUDE_TARGET_OSX_26 1
+#else
+#pragma message("macOS 26 API has been disabled")
+#endif
+
+// MARK: - interface_ref
+
+// uint32_t vmnetInterface_setEventCallback(void *interface, interface_event_t event_mask, dispatch_queue_t queue, vmnet_interface_event_callback_t callback);
+// uint32_t vmnetStopInterface(void *interface, dispatch_queue_t queue, vmnet_interface_completion_handler_thandler);
+// uint32_t vmnet_read(void *interface, struct vmpktdesc * packets, int * pktcnt);
+// uint32_t vmnet_write(void *interface, struct vmpktdesc * packets, int * pktcnt);
+// int VmnetInterfaceStartWithNetwork(void *network, uintptr_t *outFd, void *interfaceDesc, void **outInterfaceParam, uint32_t *outError);
+
+// MARK: - helper functions
+struct vmpktdesc *allocateVMPktDescArray(int count, uint64_t maxPacketSize);
+struct vmpktdesc *resetVMPktDescArray(struct vmpktdesc *pktDescs, int count, uint64_t maxPacketSize);
+void deallocateVMPktDescArray(struct vmpktdesc *pktDescs);
+ssize_t writevVMPktDescArray(int fd, struct vmpktdesc *pktDesc, int count);
+
+// MARK: - vmnet_interface
+
+struct vmnetInterfaceStartResult {
+ void *iface;
+ void *ifaceParam;
+ uint64_t maxPacketSize;
+ int maxReadPacketCount;
+ int maxWritePacketCount;
+ uint32_t vmnetReturn;
+};
+
+struct vmnetInterfaceStartResult VmnetInterfaceStartWithNetwork(void *network, void *interfaceDesc);
+uint32_t VmnetInterfaceSetPacketsAvailableEventCallback(void *interface, uintptr_t callback);
+uint32_t VmnetWrite(void *interface, struct vmpktdesc *packets, int *pktcnt);
+uint32_t VmnetRead(void *interface, struct vmpktdesc *packets, int *pktcnt);
+
+uint32_t VmnetStopInterface(void *interface);
\ No newline at end of file
diff --git a/pkg/vzvmnet/vmnetinterface/vmnetinterface_darwin.m b/pkg/vzvmnet/vmnetinterface/vmnetinterface_darwin.m
new file mode 100644
index 00000000000..bd47e210806
--- /dev/null
+++ b/pkg/vzvmnet/vmnetinterface/vmnetinterface_darwin.m
@@ -0,0 +1,162 @@
+#import "vmnetinterface_darwin.h"
+
+// MARK: - Macros
+
+// To avoid including virtualization_helper.h, copied from there.
+NSDictionary *vmnetInterfaceDumpProcessinfo()
+{
+ NSString *osVersionString = [[NSProcessInfo processInfo] operatingSystemVersionString];
+ return @{
+ @"LLVM (Clang) Version" : @__VERSION__,
+#ifdef __arm64__
+ @"Target for arm64" : @1,
+#else
+ @"Target for arm64" : @0,
+#endif
+ // The version of the macOS on which the process is executing.
+ @"Running OS Version" : osVersionString,
+#ifdef __MAC_OS_X_VERSION_MAX_ALLOWED
+ @"Max Allowed OS Version" : @__MAC_OS_X_VERSION_MAX_ALLOWED,
+#endif
+#ifdef __MAC_OS_X_VERSION_MIN_REQUIRED
+ @"Min Required OS Version" : @__MAC_OS_X_VERSION_MIN_REQUIRED,
+#endif
+ };
+}
+
+// MARK: - Helper functions defined in Go
+
+extern void callStartInterfaceCompletionHandler(uintptr_t handlerPtr, uint32_t vmnetReturn, void *interfaceParam);
+
+// MARK: - interface_ref
+
+struct vmpktdesc *allocateVMPktDescArray(int count, uint64_t maxPacketSize)
+{
+ // Calculate total size needed for pktdesc array and iovec array
+ size_t totalSize = (sizeof(struct vmpktdesc) + sizeof(struct iovec)) * count;
+ struct vmpktdesc *pktDescs = (struct vmpktdesc *)malloc(totalSize);
+ return resetVMPktDescArray(pktDescs, count, maxPacketSize);
+}
+
+struct vmpktdesc *resetVMPktDescArray(struct vmpktdesc *pktDescs, int count, uint64_t maxPacketSize)
+{
+ struct iovec *iovecArray = (struct iovec *)(pktDescs + count);
+ for (int i = 0; i < count; i++) {
+ pktDescs[i].vm_pkt_size = maxPacketSize;
+ pktDescs[i].vm_pkt_iov = &iovecArray[i];
+ pktDescs[i].vm_pkt_iovcnt = 1;
+ pktDescs[i].vm_flags = 0;
+ iovecArray[i].iov_len = maxPacketSize;
+ }
+ return pktDescs;
+}
+
+void deallocateVMPktDescArray(struct vmpktdesc *pktDescs)
+{
+ if (pktDescs != NULL) {
+ free(pktDescs);
+ }
+}
+
+ssize_t writevVMPktDescArray(int fd, struct vmpktdesc *pktDesc, int count)
+{
+ struct iovec iov[count * 2];
+ for (int index = 0; index < count; index++) {
+ uint32_t header_be = htonl(pktDesc[index].vm_pkt_size);
+ iov[index * 2].iov_base = &header_be;
+ iov[index * 2].iov_len = sizeof(header_be);
+ iov[index * 2 + 1].iov_base = pktDesc[index].vm_pkt_iov[0].iov_base;
+ iov[index * 2 + 1].iov_len = pktDesc[index].vm_pkt_iov[0].iov_len;
+ }
+ return writev(fd, iov, count * 2);
+}
+
+struct vmnetInterfaceStartResult VmnetInterfaceStartWithNetwork(void *network, void *interfaceDesc)
+{
+#ifdef INCLUDE_TARGET_OSX_26
+ if (@available(macOS 26, *)) {
+ dispatch_semaphore_t sem = dispatch_semaphore_create(0);
+ dispatch_queue_t queue = dispatch_queue_create("vmnet.interface.start", DISPATCH_QUEUE_SERIAL);
+ __block struct vmnetInterfaceStartResult result;
+ vmnet_start_interface_completion_handler_t handler = ^(vmnet_return_t vmnetReturn, xpc_object_t ifaceParam) {
+ result.ifaceParam = xpc_retain(ifaceParam);
+ result.maxPacketSize = xpc_dictionary_get_uint64(ifaceParam, vmnet_max_packet_size_key);
+ result.maxReadPacketCount = xpc_dictionary_get_uint64(ifaceParam, vmnet_read_max_packets_key);
+ result.maxWritePacketCount = xpc_dictionary_get_uint64(ifaceParam, vmnet_write_max_packets_key);
+ result.vmnetReturn = vmnetReturn;
+ dispatch_semaphore_signal(sem);
+ };
+ interface_ref iface = vmnet_interface_start_with_network((vmnet_network_ref)network, (xpc_object_t)interfaceDesc, queue, handler);
+ result.iface = iface;
+ dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
+ dispatch_release(queue);
+ dispatch_release(sem);
+ return result;
+ }
+#endif
+ RAISE_UNSUPPORTED_MACOS_EXCEPTION();
+}
+
+extern void callPacketsAvailableEventCallback(uintptr_t cgoHandle, int estimatedCount);
+
+uint32_t VmnetInterfaceSetPacketsAvailableEventCallback(void *iface, uintptr_t callback)
+{
+#ifdef INCLUDE_TARGET_OSX_26
+ if (@available(macOS 26, *)) {
+ dispatch_queue_t queue = dispatch_queue_create("vmnet.interface.eventcallback", DISPATCH_QUEUE_SERIAL);
+ vmnet_return_t result = vmnet_interface_set_event_callback((interface_ref)iface, VMNET_INTERFACE_PACKETS_AVAILABLE, queue, ^(interface_event_t eventMask, xpc_object_t event) {
+ if ((eventMask & VMNET_INTERFACE_PACKETS_AVAILABLE) != 0) {
+ int estimated = (int)xpc_dictionary_get_uint64(event, vmnet_estimated_packets_available_key);
+ callPacketsAvailableEventCallback(callback, estimated);
+ }
+ });
+ dispatch_release(queue);
+ return result;
+ }
+#endif
+ RAISE_UNSUPPORTED_MACOS_EXCEPTION();
+}
+
+uint32_t VmnetWrite(void *interface, struct vmpktdesc *packets, int *pktcnt)
+{
+#ifdef INCLUDE_TARGET_OSX_26
+ if (@available(macOS 26, *)) {
+ return vmnet_write((interface_ref)interface, packets, pktcnt);
+ }
+#endif
+ RAISE_UNSUPPORTED_MACOS_EXCEPTION();
+}
+
+uint32_t VmnetRead(void *interface, struct vmpktdesc *packets, int *pktcnt)
+{
+#ifdef INCLUDE_TARGET_OSX_26
+ if (@available(macOS 26, *)) {
+ return vmnet_read((interface_ref)interface, packets, pktcnt);
+ }
+#endif
+ RAISE_UNSUPPORTED_MACOS_EXCEPTION();
+}
+
+uint32_t VmnetStopInterface(void *interface)
+{
+#ifdef INCLUDE_TARGET_OSX_26
+ if (@available(macOS 26, *)) {
+ dispatch_semaphore_t sem = dispatch_semaphore_create(0);
+ dispatch_queue_t queue = dispatch_queue_create("vmnet.interface.stop", DISPATCH_QUEUE_SERIAL);
+ __block vmnet_return_t status;
+ vmnet_return_t scheduleStatus = vmnet_stop_interface((interface_ref)interface, queue, ^(vmnet_return_t stopStatus) {
+ status = stopStatus;
+ dispatch_semaphore_signal(sem);
+ });
+ dispatch_release(queue);
+ if (scheduleStatus != VMNET_SUCCESS) {
+ dispatch_release(sem);
+ return scheduleStatus;
+ }
+ dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
+ dispatch_release(sem);
+ return status;
+ }
+#endif
+ RAISE_UNSUPPORTED_MACOS_EXCEPTION();
+}
diff --git a/pkg/vzvmnet/vmnetinterface/vmnetinterface_others.go b/pkg/vzvmnet/vmnetinterface/vmnetinterface_others.go
new file mode 100644
index 00000000000..44bdd91210e
--- /dev/null
+++ b/pkg/vzvmnet/vmnetinterface/vmnetinterface_others.go
@@ -0,0 +1,16 @@
+//go:build !darwin
+
+// SPDX-FileCopyrightText: Copyright The Lima Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package vmnetinterface
+
+import (
+ "context"
+ "errors"
+ "os"
+)
+
+func FileDescriptorForNetwork(_ context.Context, _ string) (*os.File, error) {
+ return nil, errors.New("FileDescriptorForNetwork is only supported on darwin")
+}
diff --git a/pkg/vzvmnet/vzvmnet_darwin.go b/pkg/vzvmnet/vzvmnet_darwin.go
new file mode 100644
index 00000000000..580922ec4f1
--- /dev/null
+++ b/pkg/vzvmnet/vzvmnet_darwin.go
@@ -0,0 +1,457 @@
+// SPDX-FileCopyrightText: Copyright The Lima Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package vzvmnet
+
+import (
+ "bytes"
+ "context"
+ _ "embed"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "math"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "slices"
+ "sync"
+ "syscall"
+ "text/template"
+ "time"
+ "unsafe"
+
+ "github.com/Code-Hex/vz/v3/pkg/vmnet"
+ "github.com/Code-Hex/vz/v3/pkg/xpc"
+ "github.com/sirupsen/logrus"
+
+ "github.com/lima-vm/lima/v2/pkg/limatype/dirnames"
+ "github.com/lima-vm/lima/v2/pkg/networks"
+ "github.com/lima-vm/lima/v2/pkg/vzvmnet/csops"
+ "github.com/lima-vm/lima/v2/pkg/vzvmnet/networkchange"
+)
+
+//go:embed io.lima-vm.vz.vmnet.plist
+var launchdTemplate string
+
+const (
+ launchdLabel = "io.lima-vm.vz.vmnet"
+ MachServiceName = launchdLabel
+)
+
+// RegisterMachService registers the "io.lima-vm.vz.vmnet" launchd service.
+//
+// - It creates a launchd plist under ~/Library/LaunchAgents and bootstraps it.
+// - The mach service "io.lima-vm.vz.vmnet" is registered.
+// - The working directory is $LIMA_HOME/_networks/vz-vmnet.
+// - It also creates a shell script named "io.lima-vm.vz.vmnet.sh" that runs
+// "limactl vz-vmnet" to avoid launching "limactl" directly from launchd.
+// macOS System Settings (General > Login Items & Extensions) shows the first
+// element of ProgramArguments as the login item name; using a shell script with
+// a fixed filename makes the item easier to identify.
+func RegisterMachService(ctx context.Context) error {
+ executablePath, workDir, scriptPath, launchdPlistPath, err := relatedPaths(launchdLabel)
+ if err != nil {
+ return err
+ }
+ // Check already registered
+ if _, err := os.Stat(launchdPlistPath); err == nil {
+ if _, err := os.Stat(scriptPath); err == nil {
+ // Both files exist; assume already registered
+ return nil
+ }
+ }
+
+ // Create a shell script that runs "limactl vz-vmnet"
+ scriptContent := "#!/bin/sh\ntest -x " + executablePath + " && exec " + executablePath + " vz-vmnet --mach-service='" + MachServiceName + "' \"$@\""
+ if err := os.WriteFile(scriptPath, []byte(scriptContent), 0o755); err != nil {
+ return fmt.Errorf("failed to write %q launch script: %w", scriptPath, err)
+ }
+
+ // Create launchd plist
+ params := struct {
+ Label string
+ ProgramArguments []string
+ WorkingDirectory string
+ MachServices []string
+ }{
+ Label: launchdLabel,
+ ProgramArguments: []string{scriptPath},
+ WorkingDirectory: workDir,
+ MachServices: []string{MachServiceName},
+ }
+ template, err := template.New("plist").Parse(launchdTemplate)
+ if err != nil {
+ return fmt.Errorf("failed to parse launchd plist template: %w", err)
+ }
+ var b bytes.Buffer
+ if err := template.Execute(&b, params); err != nil {
+ return fmt.Errorf("failed to execute launchd plist template: %w", err)
+ }
+ if err := os.WriteFile(launchdPlistPath, b.Bytes(), 0o644); err != nil {
+ return fmt.Errorf("failed to write launchd plist %q: %w", launchdPlistPath, err)
+ }
+
+ // Bootstrap launchd plist
+ cmd := exec.CommandContext(ctx, "launchctl", "bootstrap", launchdServiceDomain(), launchdPlistPath)
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to execute bootstrap: %v: %w", cmd.Args, err)
+ }
+ return nil
+}
+
+// UnregisterMachService unregisters the "io.lima-vm.vz.vmnet" launchd service.
+//
+// - It unbootstraps the launchd plist.
+// - It removes the launchd plist file under ~/Library/LaunchAgents.
+// - It removes the shell script used to launch "limactl vz-vmnet".
+func UnregisterMachService(ctx context.Context) error {
+ serviceTarget := launchdServiceTarget(launchdLabel)
+ cmd := exec.CommandContext(ctx, "launchctl", "bootout", serviceTarget)
+ if err := cmd.Run(); err != nil {
+ logrus.WithError(err).Infof("failed to execute bootout: %v", cmd.Args)
+ }
+ _, _, scriptPath, launchdPlistPath, err := relatedPaths(launchdLabel)
+ if err != nil {
+ return err
+ }
+ if err := os.Remove(launchdPlistPath); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("failed to remove launchd plist %q: %w", launchdPlistPath, err)
+ }
+ if err := os.Remove(scriptPath); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("failed to remove launch script file %q: %w", scriptPath, err)
+ }
+ return nil
+}
+
+func relatedPaths(launchdLabel string) (executablePath, workDir, scriptPath, plistPath string, err error) {
+ executablePath, err = os.Executable()
+ if err != nil {
+ return "", "", "", "", fmt.Errorf("failed to get executable path: %w", err)
+ }
+ networksDir, err := dirnames.LimaNetworksDir()
+ if err != nil {
+ return "", "", "", "", fmt.Errorf("failed to get Lima networks directory: %w", err)
+ }
+ // Working directory
+ workDir = filepath.Join(networksDir, "vz-vmnet")
+ if err := os.MkdirAll(workDir, 0o755); err != nil {
+ return "", "", "", "", fmt.Errorf("failed to create working directory %q: %w", workDir, err)
+ }
+ // Shell script path
+ scriptPath = filepath.Join(workDir, launchdLabel+".sh")
+ // Launchd plist path
+ userHomeDir, err := os.UserHomeDir()
+ if err != nil {
+ return "", "", "", "", fmt.Errorf("failed to get user home directory: %w", err)
+ }
+ plistPath = filepath.Join(userHomeDir, "Library", "LaunchAgents", launchdLabel+".plist")
+ return executablePath, workDir, scriptPath, plistPath, nil
+}
+
+func launchdServiceDomain() string {
+ return fmt.Sprintf("gui/%d", os.Getuid())
+}
+
+func launchdServiceTarget(launchdLabel string) string {
+ return fmt.Sprintf("%s/%s", launchdServiceDomain(), launchdLabel)
+}
+
+// RunMachService runs the mach service at specified service name.
+//
+// It listens for incoming mach messages requesting a VmnetNetwork
+// for a given vz network, creates the VmnetNetwork if not already created,
+// and returns the serialized network object via mach XPC.
+func RunMachService(ctx context.Context, serviceName string) (err error) {
+ // Create peer requirement to restrict clients to the same executable.
+ peerRequirement, err := peerRequirementForRestrictToSameExecutable()
+ if err != nil {
+ return fmt.Errorf("failed to create peer requirement: %w", err)
+ }
+ networkEntries := make(map[string]*Entry)
+ var mu sync.RWMutex
+ listener, err := xpc.NewListener(serviceName,
+ xpc.Accept(
+ xpc.MessageHandler(func(dic *xpc.Dictionary) *xpc.Dictionary {
+ errorReply := func(errMsg string, args ...any) *xpc.Dictionary {
+ return dic.CreateReply(
+ xpc.KeyValue("Error", xpc.NewString(fmt.Sprintf(errMsg, args...))),
+ )
+ }
+
+ // Verify that the sender satisfies the peer requirement.
+ // This ensures that only clients from the same executable can request networks.
+ // This is necessary because VZVmnetNetwork cannot be shared across different executables.
+ // The requests from external VZ drivers will be rejected here.
+ if ok, err := dic.SenderSatisfies(peerRequirement); err != nil {
+ return errorReply("failed to verify sender requirement: %v", err)
+ } else if !ok {
+ return errorReply("sender does not satisfy peer requirement")
+ }
+
+ // Handle the message
+ vzNetwork := dic.GetString("Network")
+ if vzNetwork == "" {
+ return errorReply("missing Network key")
+ }
+ // Check if the network is already registered
+ mu.RLock()
+ entry, ok := networkEntries[vzNetwork]
+ mu.RUnlock()
+ if ok {
+ logrus.Infof("Provided existing VmnetNetwork for 'vz: %q'", vzNetwork)
+ return dic.CreateReply(entry.replyEntries...)
+ }
+
+ logrus.Infof("No existing VmnetNetwork for 'vz: %q'", vzNetwork)
+ entry, err := newEntry(dic)
+ if err != nil {
+ return errorReply("failed to create Entry for 'vz: %s': %v", vzNetwork, err)
+ }
+ mu.Lock()
+ networkEntries[vzNetwork] = entry
+ mu.Unlock()
+ logrus.Infof("Created new VmnetNetwork for 'vz: %q'", vzNetwork)
+ return dic.CreateReply(entry.replyEntries...)
+ }),
+ ),
+ )
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if closeError := listener.Close(); closeError != nil {
+ if err != nil {
+ err = errors.Join(err, closeError)
+ } else {
+ err = closeError
+ }
+ }
+ }()
+ if err := listener.Activate(); err != nil {
+ return err
+ }
+ // Set up network change notifier to clear cached VmnetNetworks
+ notifyCh := make(chan struct{}, 20)
+ ctx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ go func() {
+ // Use a timer to avoid flooding logs on rapid network changes since
+ // multiple notifications may be received on a VM start or stop.
+ const distantFutureDuration time.Duration = math.MaxInt64
+ const timeoutToNextNotification time.Duration = 3 * time.Second
+ timer := time.NewTimer(distantFutureDuration)
+ defer timer.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-notifyCh:
+ // Avoid flooding logs by resetting the timer to timeoutToNextNotification
+ timer.Reset(timeoutToNextNotification)
+ continue
+ case <-timer.C:
+ // Reset the timer to distantFutureDuration
+ timer.Reset(distantFutureDuration)
+ }
+
+ // Handle network change notification here
+ logrus.Info("Network change detected; clearing cached VmnetNetworks")
+ ifaces, err := NewInterfaces()
+ if err != nil {
+ logrus.Errorf("Failed to list interfaces on network change: %v", err)
+ // Hopefully the next notification will succeed
+ continue
+ }
+ // Remove entries whose interfaces are gone
+ mu.Lock()
+ for vzNetwork, entry := range networkEntries {
+ if iface := ifaces.LookupInterface(entry.config.Subnet); iface != nil {
+ if iface.Type == syscall.IFT_BRIDGE {
+ logrus.Infof("Interface for subnet %v of 'vz: %q' exists; keeping cached VmnetNetwork", entry.config.Subnet, vzNetwork)
+ entry.existenceObserved = true
+ } else {
+ logrus.Infof("Interface for subnet %v of 'vz: %q' is found but not a bridge (type=%d); removing cached VmnetNetwork since it cannot be used", entry.config.Subnet, vzNetwork, iface.Type)
+ delete(networkEntries, vzNetwork)
+ }
+ } else if !entry.existenceObserved {
+ logrus.Infof("Interface for subnet %v of 'vz: %q' is not found yet; keeping cached VmnetNetwork", entry.config.Subnet, vzNetwork)
+ } else {
+ logrus.Infof("Interface for subnet %v of 'vz: %q' is gone; removing cached VmnetNetwork", entry.config.Subnet, vzNetwork)
+ delete(networkEntries, vzNetwork)
+ }
+ }
+ mu.Unlock()
+ if len(networkEntries) == 0 {
+ logrus.Info("No cached VmnetNetworks remain, stopping mach service")
+ cancel()
+ }
+ }
+ }()
+ notifier := networkchange.NewNotifier(func(_ *networkchange.Notifier) {
+ notifyCh <- struct{}{}
+ })
+ defer notifier.Cancel()
+ <-ctx.Done()
+ return nil
+}
+
+// peerRequirementForRestrictToSameExecutable creates a [xpc.PeerRequirement]
+// that restricts clients to the same executable by CDHash.
+func peerRequirementForRestrictToSameExecutable() (*xpc.PeerRequirement, error) {
+ cdhash, err := csops.SelfCdhash()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get self CDHash: %w", err)
+ }
+ peerRequirement, err := xpc.NewPeerRequirementLwcrWithEntries(xpc.KeyValue("cdhash", xpc.NewData(cdhash)))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create peer requirement: %w", err)
+ }
+ return peerRequirement, nil
+}
+
+// Entry represents a cached VmnetNetwork entry.
+type Entry struct {
+ config *networks.VzVmnetConfig
+ network *vmnet.Network
+ replyEntries []xpc.DictionaryEntry
+ existenceObserved bool
+}
+
+// newEntry creates a new Entry from the given xpc.Dictionary.
+func newEntry(dic *xpc.Dictionary) (*Entry, error) {
+ // The Configuration key must be provided in the message to create the VmnetNetwork.
+ var vmnetConfig networks.VzVmnetConfig
+ var vmnetNetwork *vmnet.Network
+ var serialization unsafe.Pointer
+ config := dic.GetData("Configuration")
+ if config == nil {
+ return nil, errors.New("missing Configuration key")
+ } else if err := json.Unmarshal(config, &vmnetConfig); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal VzVmnetConfig: %w", err)
+ } else if vmnetNetwork, err = newVmnetNetwork(vmnetConfig); err != nil {
+ return nil, fmt.Errorf("failed to create VmnetNetwork: %w", err)
+ } else if serialization, err = vmnetNetwork.CopySerialization(); err != nil {
+ return nil, fmt.Errorf("failed to copy VmnetNetwork serialization: %w", err)
+ }
+ return &Entry{
+ config: &vmnetConfig,
+ network: vmnetNetwork,
+ replyEntries: []xpc.DictionaryEntry{
+ xpc.KeyValue("Configuration", xpc.NewData(config)),
+ xpc.KeyValue("Serialization", xpc.NewObject(serialization)),
+ },
+ }, nil
+}
+
+// newVmnetNetwork creates a new [vz.VmnetNetwork] for the given [networks.VzVmnetConfig].
+func newVmnetNetwork(vmnetConfig networks.VzVmnetConfig) (*vmnet.Network, error) {
+ var vmnetMode vmnet.Mode
+ switch vmnetConfig.Mode {
+ case networks.VzModeShared:
+ vmnetMode = vmnet.SharedMode
+ case networks.VzModeHost:
+ vmnetMode = vmnet.HostMode
+ default:
+ return nil, fmt.Errorf("unknown VzVmnetMode: %q", vmnetConfig.Mode)
+ }
+ config, err := vmnet.NewNetworkConfiguration(vmnetMode)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create network configuration with mode: %q: %w", vmnetMode, err)
+ }
+ if !vmnetConfig.Dhcp {
+ config.DisableDhcp()
+ }
+ if !vmnetConfig.DNSProxy {
+ config.DisableDnsProxy()
+ }
+ if vmnetConfig.Mtu != 0 {
+ if err := config.SetMtu(vmnetConfig.Mtu); err != nil {
+ return nil, fmt.Errorf("failed to set MTU to %d: %w", vmnetConfig.Mtu, err)
+ }
+ }
+ if !vmnetConfig.Nat44 {
+ config.DisableNat44()
+ }
+ if !vmnetConfig.Nat66 {
+ config.DisableNat66()
+ }
+ if !vmnetConfig.RouterAdvertisement {
+ config.DisableRouterAdvertisement()
+ }
+ if vmnetConfig.Subnet.IsValid() {
+ if err := config.SetIPv4Subnet(vmnetConfig.Subnet); err != nil {
+ return nil, fmt.Errorf("failed to set IPv4 subnet to %s: %w", vmnetConfig.Subnet, err)
+ }
+ }
+
+ network, err := vmnet.NewNetwork(config)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create VmnetNetwork: %w", err)
+ }
+ return network, nil
+}
+
+// RequestVmnetNetwork requests the [vz.VmnetNetwork] serialization
+// for the given vzNetwork from the mach service "io.lima-vm.vz.vmnet.subnet".
+//
+// Payload to the mach service:
+//
+// {`Network`: , `Configuration`: }
+//
+// Reply from the mach service:
+//
+// {`Configuration`: , `Serialization`: }
+//
+// If an error occurs, the reply contains:
+//
+// {`Error`: }
+func RequestVmnetNetwork(ctx context.Context, vzNetwork string, vmnetConfig networks.VzVmnetConfig) (*vmnet.Network, error) {
+ // Ensure that the mach service is registered
+ if err := RegisterMachService(ctx); err != nil {
+ return nil, err
+ }
+
+ ourConfig, err := json.Marshal(vmnetConfig)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal our 'vz: %s' config: %w", vzNetwork, err)
+ }
+
+ session, err := xpc.NewSession(MachServiceName)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create xpc session to %q: %w", MachServiceName, err)
+ }
+ defer session.Cancel()
+ reply, err := session.SendDictionaryWithReply(
+ ctx,
+ xpc.KeyValue("Network", xpc.NewString(vzNetwork)),
+ xpc.KeyValue("Configuration", xpc.NewData(ourConfig)),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to send xpc message to %q: %w", MachServiceName, err)
+ }
+ // Check for error in reply
+ if errMsg := reply.GetString("Error"); errMsg != "" {
+ return nil, fmt.Errorf("error from mach service %q: %s", MachServiceName, errMsg)
+ }
+
+ // Check that the configuration matches our expected configuration.
+ // Warn if it does not match.
+ config := reply.GetData("Configuration")
+ if config == nil {
+ return nil, fmt.Errorf("no Configuration object in reply from %q", MachServiceName)
+ }
+ if !slices.Equal(config, ourConfig) {
+ logrus.Warnf("Existing 'vz: %s' has different configuration; our config: %s, existing config: %s", vzNetwork, string(ourConfig), string(config))
+ }
+
+ serialization := reply.GetValue("Serialization")
+ if serialization == nil {
+ return nil, fmt.Errorf("no Serialization object in reply from %q", MachServiceName)
+ }
+ network, err := vmnet.NewNetworkWithSerialization(serialization.Raw())
+ if err != nil {
+ return nil, fmt.Errorf("failed to create 'vz: %s' from serialization: %w", vzNetwork, err)
+ }
+ return network, nil
+}
diff --git a/templates/default.yaml b/templates/default.yaml
index 560e6ec03b0..01c389e31ab 100644
--- a/templates/default.yaml
+++ b/templates/default.yaml
@@ -472,6 +472,9 @@ networks:
# The "vzNAT" IP address is accessible from the host, but not from other guests.
# Needs `vmType: vz`
# - vzNAT: true
+# requires `vmType: vz` and macOS 26.0 or later.
+# - vz: shared
+# - vz: host
# Port forwarding rules. Forwarding between ports 22 and ssh.localPort cannot be overridden.
# Rules are checked sequentially until the first one matches.