From abe4cb15898658d9db2b4afc040854df30e2e092 Mon Sep 17 00:00:00 2001 From: Balaji Vijayakumar Date: Tue, 24 Mar 2026 20:56:36 +0530 Subject: [PATCH] feat(macos): implement guest agent daemon and support for port forwarding Signed-off-by: Balaji Vijayakumar --- cmd/lima-guestagent/daemon_darwin.go | 112 ++++++++++++++ cmd/lima-guestagent/install_launchd_darwin.go | 110 ++++++++++++++ .../lima-guestagent.TEMPLATE.plist | 25 +++ cmd/lima-guestagent/root_darwin.go | 2 + go.mod | 2 + go.sum | 4 +- .../fakecloudinit/fakecloudinit_darwin.go | 106 +++++++++++++ ...guestagent_linux.go => guestagent_impl.go} | 0 pkg/guestagent/sockets/sockets_darwin.go | 143 ++++++++++++++++++ pkg/hostagent/hostagent.go | 2 +- 10 files changed, 503 insertions(+), 3 deletions(-) create mode 100644 cmd/lima-guestagent/daemon_darwin.go create mode 100644 cmd/lima-guestagent/install_launchd_darwin.go create mode 100644 cmd/lima-guestagent/lima-guestagent.TEMPLATE.plist rename pkg/guestagent/{guestagent_linux.go => guestagent_impl.go} (100%) create mode 100644 pkg/guestagent/sockets/sockets_darwin.go diff --git a/cmd/lima-guestagent/daemon_darwin.go b/cmd/lima-guestagent/daemon_darwin.go new file mode 100644 index 00000000000..3d4d79a8ed8 --- /dev/null +++ b/cmd/lima-guestagent/daemon_darwin.go @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "errors" + "net" + "os" + "os/signal" + "syscall" + "time" + + "github.com/mdlayher/vsock" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "github.com/lima-vm/lima/v2/pkg/guestagent" + "github.com/lima-vm/lima/v2/pkg/guestagent/api/server" + "github.com/lima-vm/lima/v2/pkg/guestagent/ticker" + "github.com/lima-vm/lima/v2/pkg/portfwdserver" +) + +const hostCID = 2 + +type cidFilteredListener struct { + *vsock.Listener +} + +func (l *cidFilteredListener) Accept() (net.Conn, error) { + for { + conn, err := l.Listener.Accept() + if err != nil { + return nil, err + } + if vsockConn, ok := conn.(*vsock.Conn); ok { + if addr, ok := vsockConn.RemoteAddr().(*vsock.Addr); ok { + if addr.ContextID != hostCID { + logrus.Warnf("rejected vsock connection from unauthorized CID %d", addr.ContextID) + conn.Close() + continue + } + } + } + return conn, nil + } +} + +func newDaemonCommand() *cobra.Command { + daemonCommand := &cobra.Command{ + Use: "daemon", + Short: "Run the daemon", + RunE: daemonAction, + } + daemonCommand.Flags().String("runtime-dir", "/var/run/lima-guestagent", "Directory to store runtime state") + daemonCommand.Flags().Duration("tick", 3*time.Second, "Tick for polling events") + daemonCommand.Flags().Int("vsock-port", 0, "vsock port to listen on") + return daemonCommand +} + +func daemonAction(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + runtimeDir, err := cmd.Flags().GetString("runtime-dir") + if err != nil { + return err + } + if err := os.MkdirAll(runtimeDir, 0o755); err != nil { + return err + } + tick, err := cmd.Flags().GetDuration("tick") + if err != nil { + return err + } + vSockPort, err := cmd.Flags().GetInt("vsock-port") + if err != nil { + return err + } + if tick == 0 { + return errors.New("tick must be specified") + } + if vSockPort == 0 { + return errors.New("vsock-port must be specified for macOS guests") + } + if os.Geteuid() != 0 { + return errors.New("must run as the root user") + } + + logrus.Infof("event tick: %v", tick) + tickerInst := ticker.NewSimpleTicker(time.NewTicker(tick)) + + ctx, stop := signal.NotifyContext(ctx, syscall.SIGTERM) + defer stop() + go func() { + <-ctx.Done() + logrus.Debug("Received SIGTERM, shutting down the guest agent") + }() + + agent, err := guestagent.New(ctx, tickerInst, runtimeDir) + if err != nil { + return err + } + + vsockL, err := vsock.Listen(uint32(vSockPort), nil) + if err != nil { + return err + } + l := &cidFilteredListener{Listener: vsockL} + logrus.Infof("serving the guest agent on vsock port: %d (host CID only)", vSockPort) + + defer logrus.Debug("exiting lima-guestagent daemon") + return server.StartServer(ctx, l, &server.GuestServer{Agent: agent, TunnelS: portfwdserver.NewTunnelServer()}) +} diff --git a/cmd/lima-guestagent/install_launchd_darwin.go b/cmd/lima-guestagent/install_launchd_darwin.go new file mode 100644 index 00000000000..26e7e242c14 --- /dev/null +++ b/cmd/lima-guestagent/install_launchd_darwin.go @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + _ "embed" + "errors" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "github.com/lima-vm/lima/v2/pkg/textutil" +) + +func newInstallLaunchdCommand() *cobra.Command { + installLaunchdCommand := &cobra.Command{ + Use: "install-launchd", + Short: "Install a launchd LaunchDaemon", + RunE: installLaunchdAction, + } + installLaunchdCommand.Flags().Bool("guestagent-updated", false, "Indicate that the guest agent has been updated") + installLaunchdCommand.Flags().Int("vsock-port", 0, "Use vsock server on specified port") + return installLaunchdCommand +} + +func installLaunchdAction(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + guestAgentUpdated, err := cmd.Flags().GetBool("guestagent-updated") + if err != nil { + return err + } + vsockPort, err := cmd.Flags().GetInt("vsock-port") + if err != nil { + return err + } + debug, err := cmd.Flags().GetBool("debug") + if err != nil { + return err + } + plist, err := generateLaunchdPlist(vsockPort, debug) + if err != nil { + return err + } + plistPath := "/Library/LaunchDaemons/io.lima-vm.lima-guestagent.plist" + plistFileChanged := true + if _, err := os.Stat(plistPath); !errors.Is(err, os.ErrNotExist) { + if existingPlist, err := os.ReadFile(plistPath); err == nil && bytes.Equal(plist, existingPlist) { + logrus.Infof("File %q is up-to-date", plistPath) + plistFileChanged = false + } else { + logrus.Infof("File %q needs update", plistPath) + } + } + if plistFileChanged { + if err := os.WriteFile(plistPath, plist, 0o644); err != nil { + return err + } + logrus.Infof("Written file %q", plistPath) + } else if !guestAgentUpdated { + logrus.Info("io.lima-vm.lima-guestagent already up-to-date") + return nil + } + // plistFileChanged || guestAgentUpdated + // Unload existing service (ignore errors if not currently loaded) + unloadCmd := exec.CommandContext(ctx, "launchctl", "unload", plistPath) + logrus.Infof("Executing: %s", strings.Join(unloadCmd.Args, " ")) + if err := unloadCmd.Run(); err != nil { + logrus.Debugf("launchctl unload (expected on first install): %v", err) + } + + loadCmd := exec.CommandContext(ctx, "launchctl", "load", "-w", plistPath) + loadCmd.Stdout = os.Stdout + loadCmd.Stderr = os.Stderr + logrus.Infof("Executing: %s", strings.Join(loadCmd.Args, " ")) + if err := loadCmd.Run(); err != nil { + return err + } + logrus.Info("Done") + return nil +} + +//go:embed lima-guestagent.TEMPLATE.plist +var launchdPlistTemplate string + +func generateLaunchdPlist(vsockPort int, debug bool) ([]byte, error) { + selfExeAbs, err := os.Executable() + if err != nil { + return nil, err + } + + var extraArgs []string + if vsockPort != 0 { + extraArgs = append(extraArgs, "--vsock-port", fmt.Sprintf("%d", vsockPort)) + } + if debug { + extraArgs = append(extraArgs, "--debug") + } + + m := map[string]any{ + "Binary": selfExeAbs, + "ExtraArgs": extraArgs, + } + return textutil.ExecuteTemplate(launchdPlistTemplate, m) +} diff --git a/cmd/lima-guestagent/lima-guestagent.TEMPLATE.plist b/cmd/lima-guestagent/lima-guestagent.TEMPLATE.plist new file mode 100644 index 00000000000..4842afd9d98 --- /dev/null +++ b/cmd/lima-guestagent/lima-guestagent.TEMPLATE.plist @@ -0,0 +1,25 @@ + + + + + Label + io.lima-vm.lima-guestagent + ProgramArguments + + {{.Binary}} + daemon +{{- range .ExtraArgs}} + {{.}} +{{- end}} + + RunAtLoad + + KeepAlive + + StandardOutPath + /var/log/lima-guestagent.log + StandardErrorPath + /var/log/lima-guestagent.log + + diff --git a/cmd/lima-guestagent/root_darwin.go b/cmd/lima-guestagent/root_darwin.go index 1948b692783..195583b511b 100644 --- a/cmd/lima-guestagent/root_darwin.go +++ b/cmd/lima-guestagent/root_darwin.go @@ -10,5 +10,7 @@ import ( func addRootCommands(rootCmd *cobra.Command) { rootCmd.AddCommand( newFakeCloudInitCommand(), + newDaemonCommand(), + newInstallLaunchdCommand(), ) } diff --git a/go.mod b/go.mod index 06d8af900ce..a58994fbe2b 100644 --- a/go.mod +++ b/go.mod @@ -122,3 +122,5 @@ require ( // gomodjail:unconfined gvisor.dev/gvisor v0.0.0-20240916094835-a174eb65023f // indirect ) + +replace github.com/mdlayher/vsock => github.com/Fred78290/vsock v0.0.1 diff --git a/go.sum b/go.sum index b4d11568c93..a81357293c6 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/Code-Hex/go-infinity-channel v1.0.0 h1:M8BWlfDOxq9or9yvF9+YkceoTkDI1p 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/Fred78290/vsock v0.0.1 h1:FJGDEHHmRQbVNJb9Ef9UQ0nYvkrEm+0cNEhtRck25j0= +github.com/Fred78290/vsock v0.0.1/go.mod h1:LrkNgswZMZAeUr5cCxHIIwY/aIzLlqYqYwwyjGKbqY8= 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= @@ -176,8 +178,6 @@ github.com/mdlayher/packet v1.1.2 h1:3Up1NG6LZrsgDVn6X4L9Ge/iyRyxFEFD9o6Pr3Q1nQY github.com/mdlayher/packet v1.1.2/go.mod h1:GEu1+n9sG5VtiRE4SydOmX5GTwyyYlteZiFU+x0kew4= github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= -github.com/mdlayher/vsock v1.2.1 h1:pC1mTJTvjo1r9n9fbm7S1j04rCgCzhCOS5DY0zqHlnQ= -github.com/mdlayher/vsock v1.2.1/go.mod h1:NRfCibel++DgeMD8z/hP+PPTjlNJsdPOmxcnENvE+SE= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= diff --git a/pkg/guestagent/fakecloudinit/fakecloudinit_darwin.go b/pkg/guestagent/fakecloudinit/fakecloudinit_darwin.go index b49b5118cfb..dcf964d5f30 100644 --- a/pkg/guestagent/fakecloudinit/fakecloudinit_darwin.go +++ b/pkg/guestagent/fakecloudinit/fakecloudinit_darwin.go @@ -7,9 +7,12 @@ package fakecloudinit import ( + "bytes" "context" + "crypto/sha256" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -39,6 +42,9 @@ func Run(ctx context.Context) error { if err := runBootScripts(ctx); err != nil { errs = append(errs, fmt.Errorf("failed to run boot scripts: %w", err)) } + if err := installGuestAgent(ctx, mnt); err != nil { + errs = append(errs, fmt.Errorf("failed to install guest agent: %w", err)) + } return errors.Join(errs...) } @@ -362,6 +368,106 @@ func setResolvConf(ctx context.Context, resolvConf *cloudinittypes.ResolvConf) e return nil } +// installGuestAgent installs the guest agent binary from cidata and sets up +// a launchd LaunchDaemon so the agent runs persistently inside the macOS guest. +func installGuestAgent(ctx context.Context, mnt string) error { + envPath := filepath.Join(mnt, "lima.env") + env, err := loadEnvFile(envPath) + if err != nil { + return fmt.Errorf("failed to load %q: %w", envPath, err) + } + + installPrefix := env["LIMA_CIDATA_GUEST_INSTALL_PREFIX"] + if installPrefix == "" { + installPrefix = "/usr/local" + } + + binDir := filepath.Join(installPrefix, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + return fmt.Errorf("failed to create %q: %w", binDir, err) + } + + src := filepath.Join(mnt, "lima-guestagent") + dst := filepath.Join(binDir, "lima-guestagent") + + srcHash, err := fileSHA256(src) + if err != nil { + return fmt.Errorf("failed to hash %q: %w", src, err) + } + dstHash, _ := fileSHA256(dst) + guestAgentUpdated := !bytes.Equal(srcHash, dstHash) + if guestAgentUpdated { + srcData, err := os.ReadFile(src) + if err != nil { + return fmt.Errorf("failed to read %q: %w", src, err) + } + if err := os.WriteFile(dst, srcData, 0o755); err != nil { + return fmt.Errorf("failed to write %q: %w", dst, err) + } + logrus.Infof("Installed %q", dst) + } else { + logrus.Infof("%q is up-to-date", dst) + } + + vsockPort := env["LIMA_CIDATA_VSOCK_PORT"] + if vsockPort == "" || vsockPort == "0" { + logrus.Info("LIMA_CIDATA_VSOCK_PORT is not set, skipping guest agent daemon installation") + return nil + } + + debug := env["LIMA_CIDATA_DEBUG"] + args := []string{"install-launchd", + "--vsock-port", vsockPort, + fmt.Sprintf("--debug=%s", debug), + } + if guestAgentUpdated { + args = append(args, "--guestagent-updated") + } + cmd := exec.CommandContext(ctx, dst, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + logrus.Infof("Executing command: %v", cmd.Args) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to execute command %v: %w", cmd.Args, err) + } + return nil +} + +// fileSHA256 returns the SHA-256 digest of the file at path. +func fileSHA256(path string) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return nil, err + } + return h.Sum(nil), nil +} + +// loadEnvFile reads a KEY=VALUE file (one per line) and returns a map. +func loadEnvFile(path string) (map[string]string, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + env := make(map[string]string) + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + env[k] = v + } + return env, nil +} + func runBootScripts(ctx context.Context) error { dirs := []string{ "/var/lib/cloud/scripts/per-boot", diff --git a/pkg/guestagent/guestagent_linux.go b/pkg/guestagent/guestagent_impl.go similarity index 100% rename from pkg/guestagent/guestagent_linux.go rename to pkg/guestagent/guestagent_impl.go diff --git a/pkg/guestagent/sockets/sockets_darwin.go b/pkg/guestagent/sockets/sockets_darwin.go new file mode 100644 index 00000000000..562b7a1578a --- /dev/null +++ b/pkg/guestagent/sockets/sockets_darwin.go @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package sockets + +import ( + "bufio" + "fmt" + "net" + "os/exec" + "strconv" + "strings" +) + +func NewLister() (*Lister, error) { + return &Lister{}, nil +} + +type Lister struct{} + +func (lister *Lister) List() ([]Socket, error) { + var sockets []Socket + for _, proto := range []string{"tcp", "udp"} { + // -an: all sockets, numeric; -p: protocol filter + // Without -f, netstat shows both inet and inet6. + out, err := exec.Command("netstat", "-an", "-p", proto).Output() + if err != nil { + continue + } + parsed := parseNetstatOutput(string(out), proto) + sockets = append(sockets, parsed...) + } + return sockets, nil +} + +func (lister *Lister) Close() error { + return nil +} + +// parseNetstatOutput parses macOS `netstat -an -p {tcp,udp}` output. +// The protocol field (tcp4/tcp6/udp4/udp6) determines the address family. +// +// Example lines: +// +// tcp4 0 0 *.22 *.* LISTEN +// tcp4 0 0 127.0.0.1.8080 *.* LISTEN +// tcp6 0 0 *.22 *.* LISTEN +// udp4 0 0 *.5353 *.* +func parseNetstatOutput(output, proto string) []Socket { + var sockets []Socket + isTCP := proto == "tcp" + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + + // Minimum fields: proto recvq sendq local foreign [state] + if len(fields) < 5 { + continue + } + + protoField := fields[0] + if !strings.HasPrefix(protoField, proto) { + continue + } + + // Determine address family from protocol field suffix + isIPv6 := strings.HasSuffix(protoField, "6") + + if isTCP { + // TCP requires a 6th field (state) to be LISTEN + if len(fields) < 6 || fields[5] != "LISTEN" { + continue + } + } else { + // UDP: unconnected sockets have foreign address *.* + if fields[4] != "*.*" { + continue + } + } + + ip, port, err := parseNetstatAddr(fields[3], isIPv6) + if err != nil { + continue + } + + kind := proto + if isIPv6 { + kind += "6" + } + + var state State + if isTCP { + state = TCPListen + } else { + state = UDPUnconnected + } + + sockets = append(sockets, Socket{ + Kind: kind, + IP: ip, + Port: port, + State: state, + }) + } + return sockets +} + +// parseNetstatAddr parses a macOS netstat local address. +// The format is IP.Port where the last dot separates the port. +// Examples: "127.0.0.1.8080", "*.22", "::1.8080", "fe80::1%lo0.22". +func parseNetstatAddr(addr string, isIPv6 bool) (net.IP, uint16, error) { + lastDot := strings.LastIndex(addr, ".") + if lastDot < 0 { + return nil, 0, fmt.Errorf("no dot in address %q", addr) + } + + ipStr := addr[:lastDot] + port, err := strconv.ParseUint(addr[lastDot+1:], 10, 16) + if err != nil { + return nil, 0, fmt.Errorf("invalid port in %q: %w", addr, err) + } + + var ip net.IP + switch { + case ipStr == "*": + if isIPv6 { + ip = net.IPv6zero + } else { + ip = net.IPv4zero + } + default: + // Strip zone ID (e.g., "%lo0") before parsing + if i := strings.IndexByte(ipStr, '%'); i >= 0 { + ipStr = ipStr[:i] + } + ip = net.ParseIP(ipStr) + if ip == nil { + return nil, 0, fmt.Errorf("invalid IP in %q", addr) + } + } + + return ip, uint16(port), nil +} diff --git a/pkg/hostagent/hostagent.go b/pkg/hostagent/hostagent.go index 08e9d48007a..451c91e96c1 100644 --- a/pkg/hostagent/hostagent.go +++ b/pkg/hostagent/hostagent.go @@ -608,7 +608,7 @@ sudo chown -R "${USER}" /run/host-services` staticPortForwards := a.separateStaticPortForwards() a.addStaticPortForwardsFromList(ctx, staticPortForwards) - hasGuestAgentDaemon := !*a.instConfig.Plain && *a.instConfig.OS == limatype.LINUX + hasGuestAgentDaemon := !*a.instConfig.Plain if hasGuestAgentDaemon { go a.watchGuestAgentEvents(ctx) go a.startTimeSync(ctx)