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 9ae510e8b4b..5612ff33158 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.20251217100139-19f23c615a84
diff --git a/go.sum b/go.sum
index c2a9e122a9d..882f576eedb 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.20251217100139-19f23c615a84 h1:RXa/XxLR/bTkS6ybqWcrINPBvaIYRxMpVknecsQfWZk=
+github.com/norio-nomura/vz/v3 v3.7.2-0.20251217100139-19f23c615a84/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/vz/vm_darwin.go b/pkg/driver/vz/vm_darwin.go
index 397c3d5f3d2..674b86ca5d0 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)
+ 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..2eb60e83988
--- /dev/null
+++ b/pkg/vzvmnet/networkchange/cgo_handle_darwin.go
@@ -0,0 +1,56 @@
+// 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
+}
+
+// release releases the cgo.Handle.
+func (h *cgoHandler) release() {
+ if h.handle != 0 {
+ h.handle.Delete()
+ h.handle = 0
+ }
+}
+
+// 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 ReleaseInFinalizer(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)
+}
+
+// ReleaseInFinalizer uses [runtime.SetFinalizer] to call release method when the object is garbage collected.
+func ReleaseInFinalizer[O interface{ release() }](o O) O {
+ runtime.SetFinalizer(o, func(o O) {
+ o.release()
+ })
+ return o
+}
diff --git a/pkg/vzvmnet/networkchange/networkchange_darwin.go b/pkg/vzvmnet/networkchange/networkchange_darwin.go
new file mode 100644
index 00000000000..92c8e240e73
--- /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.release()
+ 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/vzvmnet_darwin.go b/pkg/vzvmnet/vzvmnet_darwin.go
new file mode 100644
index 00000000000..75fa05f74d4
--- /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"
+ "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 = 10 * 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 *vz.VmnetNetwork
+ 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 *vz.VmnetNetwork
+ 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) (*vz.VmnetNetwork, error) {
+ var vmnetMode vz.VmnetMode
+ switch vmnetConfig.Mode {
+ case networks.VzModeShared:
+ vmnetMode = vz.SharedMode
+ case networks.VzModeHost:
+ vmnetMode = vz.HostMode
+ default:
+ return nil, fmt.Errorf("unknown VzVmnetMode: %q", vmnetConfig.Mode)
+ }
+ config, err := vz.NewVmnetNetworkConfiguration(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 := vz.NewVmnetNetwork(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) (*vz.VmnetNetwork, 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 := vz.NewVmnetNetworkWithSerialization(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.