From 81253237cc7cca28a41734208d12e225eaa2d9be 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 8c198e0c2ac..c060e2e0a6c 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 480b7b419da..8144a9f2ba2 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 57553f8749da319d94460809cec84254c0a612d3 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-shared` It shares `VmnetNetwork` serialization between VMs using SharedMode. - `limactl vz-vmnet-shared --enable-mach-service`: register Mach service and launch - `limactl vz-vmnet-shared --enable-mach-service=false`: unregister Mach service When the `limactl` executable file is updated due to rebuilds, etc., the VM using the serialization data held by the Mach server before the update cannot be booted. It is necessary to add a version check and restart the service as appropriate. Also, it seems that it cannot be used with an external vz driver. Signed-off-by: Norio Nomura --- cmd/limactl/main.go | 1 + cmd/limactl/vz-vmnet-shared.go | 27 ++ cmd/limactl/vz-vmnet-shared_darwin.go | 41 +++ cmd/limactl/vz-vmnet-shared_nodarwin.go | 16 ++ go.mod | 2 +- go.sum | 4 +- pkg/driver/vz/vm_darwin.go | 9 +- .../io.lima-vm.vz.vmnet.shared.plist | 29 +++ pkg/vzvmnetshared/vzvmnetshared_darwin.go | 234 ++++++++++++++++++ 9 files changed, 355 insertions(+), 8 deletions(-) create mode 100644 cmd/limactl/vz-vmnet-shared.go create mode 100644 cmd/limactl/vz-vmnet-shared_darwin.go create mode 100644 cmd/limactl/vz-vmnet-shared_nodarwin.go create mode 100644 pkg/vzvmnetshared/io.lima-vm.vz.vmnet.shared.plist create mode 100644 pkg/vzvmnetshared/vzvmnetshared_darwin.go diff --git a/cmd/limactl/main.go b/cmd/limactl/main.go index 408e3a9e9d5..15dbfaf8246 100644 --- a/cmd/limactl/main.go +++ b/cmd/limactl/main.go @@ -208,6 +208,7 @@ func newApp() *cobra.Command { newNetworkCommand(), newCloneCommand(), newRenameCommand(), + newVzVmnetSharedCommand(), ) addPluginCommands(rootCmd) diff --git a/cmd/limactl/vz-vmnet-shared.go b/cmd/limactl/vz-vmnet-shared.go new file mode 100644 index 00000000000..fe7b45a21a5 --- /dev/null +++ b/cmd/limactl/vz-vmnet-shared.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 newVzVmnetSharedCommand() *cobra.Command { + newCommand := &cobra.Command{ + Use: "vz-vmnet-shared", + Short: "Run vz-vmnet-shared", + Args: cobra.ExactArgs(0), + RunE: newVzVmnetSharedAction, + ValidArgsFunction: newVzVmnetSharedComplete, + 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 newVzVmnetSharedComplete(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return bashCompleteInstanceNames(cmd) +} diff --git a/cmd/limactl/vz-vmnet-shared_darwin.go b/cmd/limactl/vz-vmnet-shared_darwin.go new file mode 100644 index 00000000000..46a9c122287 --- /dev/null +++ b/cmd/limactl/vz-vmnet-shared_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/vzvmnetshared" +) + +func newVzVmnetSharedAction(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-shared 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 vzvmnetshared.RunMachService(ctx, machServiceName) + } else if enableMachService, _ := cmd.Flags().GetBool("enable-mach-service"); enableMachService { + return vzvmnetshared.RegisterMachService(ctx) + } + return vzvmnetshared.UnregisterMachService(ctx) +} diff --git a/cmd/limactl/vz-vmnet-shared_nodarwin.go b/cmd/limactl/vz-vmnet-shared_nodarwin.go new file mode 100644 index 00000000000..c5b409cd7d4 --- /dev/null +++ b/cmd/limactl/vz-vmnet-shared_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 newVzVmnetSharedAction(_ *cobra.Command, _ []string) error { + return errors.New("vz-vmnet-shared command is only supported on macOS") +} diff --git a/go.mod b/go.mod index c060e2e0a6c..23fb1fc08fd 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.20251203072611-007c2a5b352c diff --git a/go.sum b/go.sum index 8144a9f2ba2..cbad2b9e122 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.20251203072611-007c2a5b352c h1:0QGVXjk6/KA2G5yLQZGKZf9GB8caGCMS5H3sy0zPoH0= +github.com/norio-nomura/vz/v3 v3.7.2-0.20251203072611-007c2a5b352c/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 e8555b80bfe..e2adf8822c9 100644 --- a/pkg/driver/vz/vm_darwin.go +++ b/pkg/driver/vz/vm_darwin.go @@ -11,6 +11,7 @@ import ( "fmt" "io/fs" "net" + "net/netip" "os" "path/filepath" "runtime" @@ -37,6 +38,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/vzvmnetshared" ) // diskImageCachingMode is set to DiskImageCachingModeCached so as to avoid disk corruption on ARM: @@ -374,11 +376,8 @@ func attachNetwork(ctx context.Context, inst *limatype.Instance, vmConfig *vz.Vi } 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) + subnet := netip.MustParsePrefix("192.168.107.0/24") + network, err := vzvmnetshared.RequestSharedVmnetNetwork(ctx, subnet) if err != nil { return err } diff --git a/pkg/vzvmnetshared/io.lima-vm.vz.vmnet.shared.plist b/pkg/vzvmnetshared/io.lima-vm.vz.vmnet.shared.plist new file mode 100644 index 00000000000..d7182e802ef --- /dev/null +++ b/pkg/vzvmnetshared/io.lima-vm.vz.vmnet.shared.plist @@ -0,0 +1,29 @@ + + + + + Label + {{.Label}} + ProgramArguments + + {{- range $arg := .ProgramArguments}} + {{$arg}} + {{- end}} + + RunAtLoad + + 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/vzvmnetshared/vzvmnetshared_darwin.go b/pkg/vzvmnetshared/vzvmnetshared_darwin.go new file mode 100644 index 00000000000..f2b2989f8d9 --- /dev/null +++ b/pkg/vzvmnetshared/vzvmnetshared_darwin.go @@ -0,0 +1,234 @@ +package vzvmnetshared + +import ( + "bytes" + "context" + _ "embed" + "errors" + "fmt" + "net/netip" + "os" + "os/exec" + "path/filepath" + "text/template" + + "github.com/Code-Hex/vz/v3" + "github.com/Code-Hex/vz/v3/pkg/xpc" + + "github.com/lima-vm/lima/v2/pkg/limatype/dirnames" +) + +//go:embed io.lima-vm.vz.vmnet.shared.plist +var launchdTemplate string + +const ( + launchdLabel = "io.lima-vm.vz.vmnet.shared" + MachServiceName = launchdLabel + ".subnet" +) + +// RegisterMachService registers the "io.lima-vm.vz.vmnet.shared" launchd service. +// +// - It creates a launchd plist under ~/Library/LaunchAgents and bootstraps it. +// - The mach service "io.lima-vm.vz.vmnet.shared.subnet" is registered. +// - The working directory is $LIMA_HOME/_networks/vz-vmnet-shared. +// - It also creates a shell script named "io.lima-vm.vz.vmnet.shared.sh" that runs +// "limactl vz-vmnet-shared" 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 + } + + // Create a shell script that runs "limactl vz-vmnet-shared" + scriptContent := "#!/bin/sh\nexec " + executablePath + " vz-vmnet-shared --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", serviceDomain(), 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.shared" 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-shared". +func UnregisterMachService(ctx context.Context) error { + serviceTarget := serviceTarget(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-shared") + 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 serviceDomain() string { + return fmt.Sprintf("gui/%d", os.Getuid()) +} + +func serviceTarget(launchdLabel string) string { + return fmt.Sprintf("%s/%s", serviceDomain(), launchdLabel) +} + +// RunMachService runs the mach service at specified service name. +// +// It listens for incoming mach messages requesting a shared VmnetNetwork +// for a given subnet, creates the VmnetNetwork if not already created, +// and returns the serialized network object via mach IPC. +func RunMachService(ctx context.Context, serviceName string) (err error) { + serializationStore := make(map[netip.Prefix]*xpc.Object) + listener, err := xpc.NewListener(serviceName, + xpc.NewSessionHandler( + func(msg *xpc.Object) *xpc.Object { + errorReply := func(errMsg string, args ...any) *xpc.Object { + return msg.DictionaryCreateReply( + xpc.WithString("Error", fmt.Sprintf(errMsg, args...)), + ) + } + // Handle the message + subnetStr := msg.DictionaryGetString("Subnet") + if subnetStr == "" { + return errorReply("missing Subnet key") + } + prefix, err := netip.ParsePrefix(subnetStr) + if err != nil { + return errorReply("failed to parse Subnet %q: %v", subnetStr, err) + } + // Modify the prefix to having IP that VmnetNetwork can accept. + prefix = netip.PrefixFrom(prefix.Masked().Addr().Next(), prefix.Bits()) + serialization, ok := serializationStore[prefix] + if !ok { + if config, err := vz.NewVmnetNetworkConfiguration(vz.SharedMode); err != nil { + return errorReply("failed to create network configuration: %v", err) + } else if err := config.SetIPv4Subnet(prefix); err != nil { + return errorReply("failed to set IPv4 subnet: %v", err) + } else if newNetwork, err := vz.NewVmnetNetwork(config); err != nil { + return errorReply("failed to create VmnetNetwork: %v", err) + } else if rawSerialization, err := newNetwork.CopySerialization(); err != nil { + return errorReply("failed to copy network serialization: %v", err) + } else if serialization = xpc.NewObject(rawSerialization); serialization == nil { + return errorReply("failed to create XPC object from serialization") + } + serializationStore[prefix] = serialization + } + return msg.DictionaryCreateReply( + xpc.WithValue("Network", serialization), + ) + }, + nil, + ), + ) + 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 + } + <-ctx.Done() + return nil +} + +// RequestSharedVmnetNetwork requests a shared VmnetNetwork for the given subnet +// from the mach service "io.lima-vm.vz.vmnet.shared.subnet". +func RequestSharedVmnetNetwork(ctx context.Context, subnet netip.Prefix) (*vz.VmnetNetwork, error) { + 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.WithString("Subnet", subnet.String()), + ) + if err != nil { + return nil, fmt.Errorf("failed to send xpc message to %q: %w", MachServiceName, err) + } + if errMsg := reply.DictionaryGetString("Error"); errMsg != "" { + return nil, fmt.Errorf("error from mach service %q: %s", MachServiceName, errMsg) + } + serialization := reply.DictionaryGetValue("Network") + if serialization == nil { + return nil, fmt.Errorf("no Network object in reply from %q", MachServiceName) + } + network, err := vz.NewVmnetNetworkWithSerialization(serialization.XpcObject) + if err != nil { + return nil, fmt.Errorf("failed to create VmnetNetwork (%v) from serialization: %w", subnet, err) + } + return network, nil +}