From 0f6d2d9d231f6d2ee0577456c0195a66cf979629 Mon Sep 17 00:00:00 2001 From: Norio Nomura Date: Sat, 22 Nov 2025 21:55:11 +0900 Subject: [PATCH 1/2] feat: add `VZVmnetNetworkDeviceAttachment` support on macOS 26+ Based on `VMNET_SHARED_MODE`, and `VMNET_HOST_MODE` ```yaml networks: - vzShared: true - vzHost: true ``` But, to sharing network between multiple VMs, `VZVmnetNetworkDeviceAttachment` requires VMs are launched by same process. It depends on https://github.com/Code-Hex/vz/pull/205 Signed-off-by: Norio Nomura --- go.mod | 2 ++ go.sum | 4 +-- pkg/driver/vz/vm_darwin.go | 36 +++++++++++++++++++++++++ pkg/driver/vz/vz_driver_darwin.go | 7 +++++ pkg/limatmpl/embed.go | 8 ++++++ pkg/limatype/lima_yaml.go | 4 +++ pkg/limayaml/validate.go | 44 +++++++++++++++++++++++++++++++ templates/default.yaml | 3 +++ 8 files changed, 106 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 9ae510e8b4b..29022727d65 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.20251122122159-6617c8faa123 diff --git a/go.sum b/go.sum index c2a9e122a9d..9537ca378c8 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.20251122122159-6617c8faa123 h1:3Xzg1W5gel17So2d2NSA+flx6yoyknx5nG9Pb6eZU6s= +github.com/norio-nomura/vz/v3 v3.7.2-0.20251122122159-6617c8faa123/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/driver/vz/vm_darwin.go b/pkg/driver/vz/vm_darwin.go index 397c3d5f3d2..e8555b80bfe 100644 --- a/pkg/driver/vz/vm_darwin.go +++ b/pkg/driver/vz/vm_darwin.go @@ -373,6 +373,42 @@ func attachNetwork(ctx context.Context, inst *limatype.Instance, vmConfig *vz.Vi return err } configurations = append(configurations, networkConfig) + } else if nw.VZShared != nil && *nw.VZShared { + config, err := vz.NewVmnetNetworkConfiguration(vz.SharedMode) + if err != nil { + return err + } + network, err := vz.NewVmnetNetwork(config) + 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) + } else if nw.VZHost != nil && *nw.VZHost { + config, err := vz.NewVmnetNetworkConfiguration(vz.HostMode) + if err != nil { + return err + } + network, err := vz.NewVmnetNetwork(config) + 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) } else if nw.Lima != "" { nwCfg, err := networks.LoadConfig() if err != nil { diff --git a/pkg/driver/vz/vz_driver_darwin.go b/pkg/driver/vz/vz_driver_darwin.go index 566ebe602e1..ec7185b8647 100644 --- a/pkg/driver/vz/vz_driver_darwin.go +++ b/pkg/driver/vz/vz_driver_darwin.go @@ -280,6 +280,8 @@ func validateConfig(_ context.Context, cfg *limatype.LimaYAML) error { for i, nw := range cfg.Networks { if unknown := reflectutil.UnknownNonEmptyFields(nw, "VZNAT", + "VZShared", + "VZHost", "Lima", "Socket", "MACAddress", @@ -288,6 +290,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.VZShared != nil && *nw.VZShared) || (nw.VZHost != nil && *nw.VZHost) { + if macOSProductVersion.LessThan(*semver.New("26.0.0")) { + return fmt.Errorf("networks[%d]: VZShared and VZHost require macOS 26.0 or later", i) + } + } } switch audioDevice := *cfg.Audio.Device; audioDevice { diff --git a/pkg/limatmpl/embed.go b/pkg/limatmpl/embed.go index 4797369580a..3f494b44427 100644 --- a/pkg/limatmpl/embed.go +++ b/pkg/limatmpl/embed.go @@ -543,6 +543,14 @@ func (tmpl *Template) combineNetworks() { tmpl.copyListEntryField(networks, dst, src, "vzNAT") dest.VZNAT = nw.VZNAT } + if dest.VZShared == nil && nw.VZShared != nil { + tmpl.copyListEntryField(networks, dst, src, "vzShared") + dest.VZShared = nw.VZShared + } + if dest.VZHost == nil && nw.VZHost != nil { + tmpl.copyListEntryField(networks, dst, src, "vzHost") + dest.VZHost = nw.VZHost + } 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..80a644366a8 100644 --- a/pkg/limatype/lima_yaml.go +++ b/pkg/limatype/lima_yaml.go @@ -317,6 +317,10 @@ 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"` + // VZShared, and VZHost use VZVmnetNetworkDeviceAttachment. Needs VZ. No root privilege is required. + // Requires macOS 26.0 or later. + VZShared *bool `yaml:"vzShared,omitempty" json:"vzShared,omitempty"` + VZHost *bool `yaml:"vzHost,omitempty" json:"vzHost,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..849435f7560 100644 --- a/pkg/limayaml/validate.go +++ b/pkg/limayaml/validate.go @@ -466,22 +466,66 @@ 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.VZShared != nil && *nw.VZShared { + errs = errors.Join(errs, fmt.Errorf("field `%s.lima` and field `%s.vzShared` are mutually exclusive", field, field)) + } + if nw.VZHost != nil && *nw.VZHost { + errs = errors.Join(errs, fmt.Errorf("field `%s.lima` and field `%s.vzHost` 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.VZShared != nil && *nw.VZShared { + errs = errors.Join(errs, fmt.Errorf("field `%s.socket` and field `%s.vzShared` are mutually exclusive", field, field)) + } + if nw.VZHost != nil && *nw.VZHost { + errs = errors.Join(errs, fmt.Errorf("field `%s.socket` and field `%s.vzHost` 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.VZShared != nil && *nw.VZShared { + errs = errors.Join(errs, fmt.Errorf("field `%s.vzNAT` and field `%s.vzShared` are mutually exclusive", field, field)) + } + if nw.VZHost != nil && *nw.VZHost { + errs = errors.Join(errs, fmt.Errorf("field `%s.vzNAT` and field `%s.vzHost` 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.VZShared != nil && *nw.VZShared: + if nw.VZNAT != nil && *nw.VZNAT { + errs = errors.Join(errs, fmt.Errorf("field `%s.vzShared` and field `%s.vzNAT` are mutually exclusive", field, field)) + } + if nw.VZHost != nil && *nw.VZHost { + errs = errors.Join(errs, fmt.Errorf("field `%s.vzShared` and field `%s.vzHost` are mutually exclusive", field, field)) + } + if nw.Lima != "" { + errs = errors.Join(errs, fmt.Errorf("field `%s.vzShared` and field `%s.lima` are mutually exclusive", field, field)) + } + if nw.Socket != "" { + errs = errors.Join(errs, fmt.Errorf("field `%s.vzShared` and field `%s.socket` are mutually exclusive", field, field)) + } + case nw.VZHost != nil && *nw.VZHost: + if nw.VZNAT != nil && *nw.VZNAT { + errs = errors.Join(errs, fmt.Errorf("field `%s.vzHost` and field `%s.vzNAT` are mutually exclusive", field, field)) + } + if nw.VZShared != nil && *nw.VZShared { + errs = errors.Join(errs, fmt.Errorf("field `%s.vzHost` and field `%s.vzShared` are mutually exclusive", field, field)) + } + if nw.Lima != "" { + errs = errors.Join(errs, fmt.Errorf("field `%s.vzHost` and field `%s.lima` are mutually exclusive", field, field)) + } + if nw.Socket != "" { + errs = errors.Join(errs, fmt.Errorf("field `%s.vzHost` 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/templates/default.yaml b/templates/default.yaml index 560e6ec03b0..79b20ccf838 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. +# - vzShared: true +# - vzHost: true # Port forwarding rules. Forwarding between ports 22 and ssh.localPort cannot be overridden. # Rules are checked sequentially until the first one matches. From aa15ed70a1653473e17e759fef03da8df46fe41f Mon Sep 17 00:00:00 2001 From: Norio Nomura Date: Wed, 3 Dec 2025 16:59:30 +0900 Subject: [PATCH 2/2] feat: Add `limactl vz-vmnet` It provides `VmnetNetwork` serialization to VMs. `limactl vz-vmnet` takes flags: - `--enable-mach-service[=true,false]`: register/unregister Mach service to `launchd` - 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. - There is no need to register manually because the VZ driver is registered as appropriate. - `--mach-service=`: launched as Mach server by `launchd` via `io.lima-vm.vz.vmnet.plist` - Launched on demand to connection from VZ driver by `launchd`. - Receives a request payload from VZ driver with fields: - `Network`: name of the network ("shared", "host", etc) - `Configuration`: `[]bytes@ representing `VzNetworkConfig` in JSON. - Validates clients are the same executable (cdhash) by using xpc_peer_requirement API. - Create `VmnetNetwork` from provided `Configuration` if cached one does not exist. - Replies to VZ driver with fields: - `Configuration`: If `VmnetNetwork` is cached, it may be created by different configuration. - `Serialization`: newly created or cached. - Monitors changes of networks - When the interface created by `VmnetNetwork` disappears from host, remove them from cache. - If all `VmnetNetwork` are removed, `limactl vz-vmnet` exits. VZ driver (hostagent) does: - Read `.vz` VzVmnetConfig from `networks.yaml` - Use them on `- vz: ` fields; "shared" and "host" network are predefined. - Register `limactl vz-vmnet` to `launchd` if not registered. - Request serialization to the Mach service "io.lima-vm.vz.vmnet". - Create `VmnetNetwork` by provided serialization, then use them. Additional changes: - Because shutdown takes longer on using `VmnetNetwork`: - Extend VZ driver's shutdown timeout from 5 seconds to 15 seconds - Add `ExitTimeOut` key with 20 seconds to autostart `io.lima-vm.autostart.INSTANCE.plist` - `lima.yaml`: `- vzShared` and `- vzHost` are renamed to `- vz: shared` and `- vz: host` Signed-off-by: Norio Nomura --- cmd/limactl/main.go | 1 + cmd/limactl/vz-vmnet.go | 27 ++ cmd/limactl/vz-vmnet_darwin.go | 41 ++ cmd/limactl/vz-vmnet_nodarwin.go | 16 + go.mod | 2 +- go.sum | 4 +- .../io.lima-vm.autostart.INSTANCE.plist | 2 + pkg/driver/vz/vm_darwin.go | 34 +- pkg/driver/vz/vz_driver_darwin.go | 10 +- pkg/limatmpl/embed.go | 10 +- pkg/limatype/lima_yaml.go | 5 +- pkg/limayaml/validate.go | 45 +- pkg/networks/config.go | 7 + pkg/networks/networks.TEMPLATE.yaml | 21 + pkg/networks/networks.go | 30 +- pkg/vzvmnet/csops/cdhash_darwin.go | 59 +++ pkg/vzvmnet/csops/cdhash_darwin_test.go | 89 ++++ pkg/vzvmnet/csops/codesign-and-exec.sh | 25 + pkg/vzvmnet/ifaddrs_darwin.go | 146 ++++++ pkg/vzvmnet/ifaddrs_darwin_test.go | 33 ++ pkg/vzvmnet/io.lima-vm.vz.vmnet.plist | 27 ++ pkg/vzvmnet/networkchange/cgo_handle.go | 56 +++ pkg/vzvmnet/networkchange/networkchange.h | 12 + pkg/vzvmnet/networkchange/networkchange.m | 19 + .../networkchange/networkchange_darwin.go | 75 +++ pkg/vzvmnet/vzvmnet_darwin.go | 457 ++++++++++++++++++ templates/default.yaml | 4 +- 27 files changed, 1175 insertions(+), 82 deletions(-) create mode 100644 cmd/limactl/vz-vmnet.go create mode 100644 cmd/limactl/vz-vmnet_darwin.go create mode 100644 cmd/limactl/vz-vmnet_nodarwin.go create mode 100644 pkg/vzvmnet/csops/cdhash_darwin.go create mode 100644 pkg/vzvmnet/csops/cdhash_darwin_test.go create mode 100755 pkg/vzvmnet/csops/codesign-and-exec.sh create mode 100644 pkg/vzvmnet/ifaddrs_darwin.go create mode 100644 pkg/vzvmnet/ifaddrs_darwin_test.go create mode 100644 pkg/vzvmnet/io.lima-vm.vz.vmnet.plist create mode 100644 pkg/vzvmnet/networkchange/cgo_handle.go create mode 100644 pkg/vzvmnet/networkchange/networkchange.h create mode 100644 pkg/vzvmnet/networkchange/networkchange.m create mode 100644 pkg/vzvmnet/networkchange/networkchange_darwin.go create mode 100644 pkg/vzvmnet/vzvmnet_darwin.go 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..ae3122c5d1a --- /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("enable-mach-service", false, "Enable 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..e4c26be4fb5 --- /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 enableMachService, _ := cmd.Flags().GetBool("enable-mach-service"); enableMachService { + return vzvmnet.RegisterMachService(ctx) + } + return vzvmnet.UnregisterMachService(ctx) +} 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 29022727d65..31820c3060d 100644 --- a/go.mod +++ b/go.mod @@ -148,4 +148,4 @@ require ( 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.20251122122159-6617c8faa123 +replace github.com/Code-Hex/vz/v3 => github.com/norio-nomura/vz/v3 v3.7.2-0.20251216141401-33858c083cdd diff --git a/go.sum b/go.sum index 9537ca378c8..c4e78c3fa7e 100644 --- a/go.sum +++ b/go.sum @@ -207,8 +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.20251122122159-6617c8faa123 h1:3Xzg1W5gel17So2d2NSA+flx6yoyknx5nG9Pb6eZU6s= -github.com/norio-nomura/vz/v3 v3.7.2-0.20251122122159-6617c8faa123/go.mod h1:+0IVfZY7N/7Vv5KpZWbEgTRK6jMg4s7DVM+op2hdyrs= +github.com/norio-nomura/vz/v3 v3.7.2-0.20251216141401-33858c083cdd h1:X9tGAA8K75F8IALhl+1frY2sLpbdp1bzxOaKEaOakj0= +github.com/norio-nomura/vz/v3 v3.7.2-0.20251216141401-33858c083cdd/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/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 e8555b80bfe..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,30 +375,16 @@ func attachNetwork(ctx context.Context, inst *limatype.Instance, vmConfig *vz.Vi return err } configurations = append(configurations, networkConfig) - } else if nw.VZShared != nil && *nw.VZShared { - config, err := vz.NewVmnetNetworkConfiguration(vz.SharedMode) - if err != nil { - return err - } - network, err := vz.NewVmnetNetwork(config) - if err != nil { - return err - } - attachment, err := vz.NewVmnetNetworkDeviceAttachment(network) - if err != nil { - return err - } - networkConfig, err := newVirtioNetworkDeviceConfiguration(attachment, nw.MACAddress) + case nw.Vz != "": + nwCfg, err := networks.LoadConfig() if err != nil { return err } - configurations = append(configurations, networkConfig) - } else if nw.VZHost != nil && *nw.VZHost { - config, err := vz.NewVmnetNetworkConfiguration(vz.HostMode) - 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 := vz.NewVmnetNetwork(config) + network, err := vzvmnet.RequestVmnetNetwork(ctx, nw.Vz, vzCfg) if err != nil { return err } @@ -409,7 +397,7 @@ func attachNetwork(ctx context.Context, inst *limatype.Instance, vmConfig *vz.Vi return err } configurations = append(configurations, networkConfig) - } else if nw.Lima != "" { + case nw.Lima != "": nwCfg, err := networks.LoadConfig() if err != nil { return err @@ -461,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 ec7185b8647..2b079e13b96 100644 --- a/pkg/driver/vz/vz_driver_darwin.go +++ b/pkg/driver/vz/vz_driver_darwin.go @@ -280,8 +280,7 @@ func validateConfig(_ context.Context, cfg *limatype.LimaYAML) error { for i, nw := range cfg.Networks { if unknown := reflectutil.UnknownNonEmptyFields(nw, "VZNAT", - "VZShared", - "VZHost", + "Vz", "Lima", "Socket", "MACAddress", @@ -290,9 +289,9 @@ 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.VZShared != nil && *nw.VZShared) || (nw.VZHost != nil && *nw.VZHost) { + if nw.Vz != "" { if macOSProductVersion.LessThan(*semver.New("26.0.0")) { - return fmt.Errorf("networks[%d]: VZShared and VZHost require macOS 26.0 or later", i) + return fmt.Errorf("networks[%d]: 'vz: %s' require macOS 26.0 or later", i, nw.Vz) } } } @@ -375,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 3f494b44427..a2792415ff8 100644 --- a/pkg/limatmpl/embed.go +++ b/pkg/limatmpl/embed.go @@ -543,13 +543,9 @@ func (tmpl *Template) combineNetworks() { tmpl.copyListEntryField(networks, dst, src, "vzNAT") dest.VZNAT = nw.VZNAT } - if dest.VZShared == nil && nw.VZShared != nil { - tmpl.copyListEntryField(networks, dst, src, "vzShared") - dest.VZShared = nw.VZShared - } - if dest.VZHost == nil && nw.VZHost != nil { - tmpl.copyListEntryField(networks, dst, src, "vzHost") - dest.VZHost = nw.VZHost + 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") diff --git a/pkg/limatype/lima_yaml.go b/pkg/limatype/lima_yaml.go index 80a644366a8..02a9e472548 100644 --- a/pkg/limatype/lima_yaml.go +++ b/pkg/limatype/lima_yaml.go @@ -317,10 +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"` - // VZShared, and VZHost use VZVmnetNetworkDeviceAttachment. Needs VZ. No root privilege is required. + // Vz uses VZVmnetNetworkDeviceAttachment. Needs VZ. No root privilege is required. // Requires macOS 26.0 or later. - VZShared *bool `yaml:"vzShared,omitempty" json:"vzShared,omitempty"` - VZHost *bool `yaml:"vzHost,omitempty" json:"vzHost,omitempty"` + 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 849435f7560..a26df72162c 100644 --- a/pkg/limayaml/validate.go +++ b/pkg/limayaml/validate.go @@ -466,21 +466,15 @@ 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.VZShared != nil && *nw.VZShared { - errs = errors.Join(errs, fmt.Errorf("field `%s.lima` and field `%s.vzShared` are mutually exclusive", field, field)) - } - if nw.VZHost != nil && *nw.VZHost { - errs = errors.Join(errs, fmt.Errorf("field `%s.lima` and field `%s.vzHost` 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.VZShared != nil && *nw.VZShared { - errs = errors.Join(errs, fmt.Errorf("field `%s.socket` and field `%s.vzShared` are mutually exclusive", field, field)) - } - if nw.VZHost != nil && *nw.VZHost { - errs = errors.Join(errs, fmt.Errorf("field `%s.socket` and field `%s.vzHost` 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) @@ -488,11 +482,8 @@ func validateNetwork(y *limatype.LimaYAML) error { 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.VZShared != nil && *nw.VZShared { - errs = errors.Join(errs, fmt.Errorf("field `%s.vzNAT` and field `%s.vzShared` are mutually exclusive", field, field)) - } - if nw.VZHost != nil && *nw.VZHost { - errs = errors.Join(errs, fmt.Errorf("field `%s.vzNAT` and field `%s.vzHost` are mutually exclusive", field, field)) + 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)) @@ -500,31 +491,15 @@ func validateNetwork(y *limatype.LimaYAML) error { if nw.Socket != "" { errs = errors.Join(errs, fmt.Errorf("field `%s.vzNAT` and field `%s.socket` are mutually exclusive", field, field)) } - case nw.VZShared != nil && *nw.VZShared: + case nw.Vz != "": if nw.VZNAT != nil && *nw.VZNAT { - errs = errors.Join(errs, fmt.Errorf("field `%s.vzShared` and field `%s.vzNAT` are mutually exclusive", field, field)) - } - if nw.VZHost != nil && *nw.VZHost { - errs = errors.Join(errs, fmt.Errorf("field `%s.vzShared` and field `%s.vzHost` are mutually exclusive", field, field)) - } - if nw.Lima != "" { - errs = errors.Join(errs, fmt.Errorf("field `%s.vzShared` and field `%s.lima` are mutually exclusive", field, field)) - } - if nw.Socket != "" { - errs = errors.Join(errs, fmt.Errorf("field `%s.vzShared` and field `%s.socket` are mutually exclusive", field, field)) - } - case nw.VZHost != nil && *nw.VZHost: - if nw.VZNAT != nil && *nw.VZNAT { - errs = errors.Join(errs, fmt.Errorf("field `%s.vzHost` and field `%s.vzNAT` are mutually exclusive", field, field)) - } - if nw.VZShared != nil && *nw.VZShared { - errs = errors.Join(errs, fmt.Errorf("field `%s.vzHost` and field `%s.vzShared` are mutually exclusive", field, field)) + 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.vzHost` and field `%s.lima` are mutually exclusive", field, field)) + 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.vzHost` and field `%s.socket` are mutually exclusive", field, field)) + 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.go b/pkg/vzvmnet/networkchange/cgo_handle.go new file mode 100644 index 00000000000..84418c005cb --- /dev/null +++ b/pkg/vzvmnet/networkchange/cgo_handle.go @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package networkchange + +/* +#include "networkchange.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.h b/pkg/vzvmnet/networkchange/networkchange.h new file mode 100644 index 00000000000..aa3626eb11b --- /dev/null +++ b/pkg/vzvmnet/networkchange/networkchange.h @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#import +#import +#import + +// MARK: - Darwin notify API + +uint32_t notifyRegisterDispatch(int *out_token, uintptr_t handler); diff --git a/pkg/vzvmnet/networkchange/networkchange.m b/pkg/vzvmnet/networkchange/networkchange.m new file mode 100644 index 00000000000..16cc3f147e8 --- /dev/null +++ b/pkg/vzvmnet/networkchange/networkchange.m @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +#import "networkchange.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/networkchange/networkchange_darwin.go b/pkg/vzvmnet/networkchange/networkchange_darwin.go new file mode 100644 index 00000000000..88f77246224 --- /dev/null +++ b/pkg/vzvmnet/networkchange/networkchange_darwin.go @@ -0,0 +1,75 @@ +// 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.h" +*/ +import ( + "C" //nolint:gocritic // false positive: dupImport: package is imported 2 times under different aliases on... (gocritic) +) + +import ( + "unsafe" //nolint:gocritic // false positive: dupImport: package is imported 2 times under different aliases on... (gocritic) +) + +// 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 + cs := C.CString("io.lima-vm.vzvmnet.notify") + defer C.free(unsafe.Pointer(cs)) + 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/vzvmnet_darwin.go b/pkg/vzvmnet/vzvmnet_darwin.go new file mode 100644 index 00000000000..1dcc8b973f2 --- /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 { + return fmt.Errorf("failed to execute bootout: %v: %w", cmd.Args, err) + } + _, _, 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 79b20ccf838..01c389e31ab 100644 --- a/templates/default.yaml +++ b/templates/default.yaml @@ -473,8 +473,8 @@ networks: # Needs `vmType: vz` # - vzNAT: true # requires `vmType: vz` and macOS 26.0 or later. -# - vzShared: true -# - vzHost: true +# - 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.