From da4d8eb3c27d233eeb02c33ef219a6aca578986c Mon Sep 17 00:00:00 2001 From: Norio Nomura Date: Fri, 10 Oct 2025 17:40:39 +0900 Subject: [PATCH 01/10] =?UTF-8?q?pkg/hostagent:=20Detect=20the=20VM?= =?UTF-8?q?=E2=80=99s=20IP=20address=20on=20the=20same=20subnet=20as=20the?= =?UTF-8?q?=20host=20OS.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guest IP address will be detected in requirement process. `hostagent.go`: - Add fields to `HostAgent` - `guestIfnameOnSameSubnetAsHost string` - `guestIPv4 net.IP` - `guestIPv6 net.IP` - Add methods `GuestIP()`, `GuestIPv4()`, and `GuestIPv6()` to `HostAgent` - Add `HostAgent.WriteSSHConfigFile()` helper to write SSHConfigFile - Add `HostAgent.sshAddressPort()` helper to provide ipAddress and port for SSH `requirements.go`: - Add `stdoutParser func(string) error` field to `requirement` - Add `HostAgent.detectGuestIfnameOnSameSubnetAtHost()` to parse `ip -j neighbor` command output - Add `HostAgent.detectGuestIPAddress()` to parse `ip -j addr` command output - Add two requirements to `HostAgent.essentialRequirements()` - "detect guest interface on same subnet as the host" - "detect guest IPv4 address" - "detect guest IPv6 address" Signed-off-by: Norio Nomura # Conflicts: # pkg/hostagent/hostagent.go pkg/hostagent: Remove `HostAgent.detectGuestIfnameOnSameSubnetAtHost()` Signed-off-by: Norio Nomura # Conflicts: # pkg/hostagent/requirements.go --- pkg/hostagent/hostagent.go | 51 ++++++++++++++- pkg/hostagent/requirements.go | 117 ++++++++++++++++++++++++++++++++-- 2 files changed, 159 insertions(+), 9 deletions(-) diff --git a/pkg/hostagent/hostagent.go b/pkg/hostagent/hostagent.go index 9355a2c91e9..296faa26c51 100644 --- a/pkg/hostagent/hostagent.go +++ b/pkg/hostagent/hostagent.go @@ -83,6 +83,11 @@ type HostAgent struct { statusMu sync.RWMutex currentStatus events.Status + + // Guest IP address on the same subnet as the host. + guestIPv4 net.IP + guestIPv6 net.IP + guestIPMu sync.RWMutex } type options struct { @@ -258,6 +263,27 @@ func New(ctx context.Context, instName string, stdout io.Writer, signalCh chan o return a, nil } +func (a *HostAgent) WriteSSHConfigFile(ctx context.Context) error { + sshExe, err := sshutil.NewSSHExe() + if err != nil { + return err + } + sshOpts, err := sshutil.SSHOpts( + ctx, + sshExe, + a.instDir, + *a.instConfig.User.Name, + *a.instConfig.SSH.LoadDotSSHPubKeys, + *a.instConfig.SSH.ForwardAgent, + *a.instConfig.SSH.ForwardX11, + *a.instConfig.SSH.ForwardX11Trusted) + if err != nil { + return err + } + sshAddress, sshPort := a.sshAddressPort() + return writeSSHConfigFile(sshExe.Exe, a.instName, a.instDir, sshAddress, sshPort, sshOpts) +} + func writeSSHConfigFile(sshPath, instName, instDir, instSSHAddress string, sshLocalPort int, sshOpts []string) error { if instDir == "" { return fmt.Errorf("directory is unknown for the instance %q", instName) @@ -481,6 +507,19 @@ func (a *HostAgent) startRoutinesAndWait(ctx context.Context, errCh <-chan error return a.driver.Stop(ctx) } +// GuestIP returns the guest's IPv4 address if available; otherwise the IPv6 address. +// It returns nil if the guest is not reachable by a direct IP. +func (a *HostAgent) GuestIP() net.IP { + a.guestIPMu.RLock() + defer a.guestIPMu.RUnlock() + if a.guestIPv4 != nil { + return a.guestIPv4 + } else if a.guestIPv6 != nil { + return a.guestIPv6 + } + return nil +} + func (a *HostAgent) Info(_ context.Context) (*hostagentapi.Info, error) { info := &hostagentapi.Info{ AutoStartedIdentifier: autostart.AutoStartedIdentifier(), @@ -492,6 +531,12 @@ func (a *HostAgent) Info(_ context.Context) (*hostagentapi.Info, error) { func (a *HostAgent) sshAddressPort() (sshAddress string, sshPort int) { sshAddress = a.instSSHAddress sshPort = a.sshLocalPort + guestIP := a.GuestIP() + if guestIP != nil { + sshAddress = guestIP.String() + sshPort = 22 + logrus.Debugf("Using the guest IP address %q directly", sshAddress) + } return sshAddress, sshPort } @@ -513,7 +558,8 @@ func (a *HostAgent) startHostAgentRoutines(ctx context.Context) error { return nil } logrus.Debugf("shutting down the SSH master") - if exitMasterErr := ssh.ExitMaster(a.instSSHAddress, a.sshLocalPort, a.sshConfig); exitMasterErr != nil { + sshAddress, sshPort := a.sshAddressPort() + if exitMasterErr := ssh.ExitMaster(sshAddress, sshPort, a.sshConfig); exitMasterErr != nil { logrus.WithError(exitMasterErr).Warn("failed to exit SSH master") } return nil @@ -529,7 +575,8 @@ sudo mkdir -p -m 700 /run/host-services sudo ln -sf "${SSH_AUTH_SOCK}" /run/host-services/ssh-auth.sock sudo chown -R "${USER}" /run/host-services` faDesc := "linking ssh auth socket to static location /run/host-services/ssh-auth.sock" - stdout, stderr, err := ssh.ExecuteScript(a.instSSHAddress, a.sshLocalPort, a.sshConfig, faScript, faDesc) + sshAddress, sshPort := a.sshAddressPort() + stdout, stderr, err := ssh.ExecuteScript(sshAddress, sshPort, a.sshConfig, faScript, faDesc) logrus.Debugf("stdout=%q, stderr=%q, err=%v", stdout, stderr, err) if err != nil { errs = append(errs, fmt.Errorf("stdout=%q, stderr=%q: %w", stdout, stderr, err)) diff --git a/pkg/hostagent/requirements.go b/pkg/hostagent/requirements.go index 2873cebd786..f9904e9e7b8 100644 --- a/pkg/hostagent/requirements.go +++ b/pkg/hostagent/requirements.go @@ -4,8 +4,11 @@ package hostagent import ( + "context" + "encoding/json" "errors" "fmt" + "net" "runtime" "strings" "time" @@ -122,20 +125,25 @@ func (a *HostAgent) waitForRequirement(r requirement) error { AdditionalArgs: sshutil.DisableControlMasterOptsFromSSHArgs(sshConfig.AdditionalArgs), } } - stdout, stderr, err := ssh.ExecuteScript(a.instSSHAddress, a.sshLocalPort, sshConfig, script, r.description) + sshAddress, sshPort := a.sshAddressPort() + stdout, stderr, err := ssh.ExecuteScript(sshAddress, sshPort, sshConfig, script, r.description) logrus.Debugf("stdout=%q, stderr=%q, err=%v", stdout, stderr, err) if err != nil { return fmt.Errorf("stdout=%q, stderr=%q: %w", stdout, stderr, err) } + if r.stdoutParser != nil { + return r.stdoutParser(stdout) + } return nil } type requirement struct { - description string - script string - debugHint string - fatal bool - noMaster bool + description string + script string + debugHint string + fatal bool + noMaster bool + stdoutParser func(string) error } func (a *HostAgent) essentialRequirements() []requirement { @@ -151,7 +159,24 @@ Make sure that the YAML field "ssh.localPort" is not used by other processes on If any private key under ~/.ssh is protected with a passphrase, you need to have ssh-agent to be running. `, noMaster: true, - }) + }, + ) + if runtime.GOOS == "darwin" { + // Limit the Guest IP address detection only to macOS for now. + req = append(req, + requirement{ + description: "detect guest IP address", + script: `#!/bin/bash +ip -j addr +`, + debugHint: `Detecting the guest IP address on the interface in same subnet on the host. +This is only supported on macOS for now. +If the interface does not have IPv4 address, SSH connection against the guest OS will be made via the localhost port forwarding.`, + noMaster: true, + stdoutParser: a.detectGuestIPAddress, + }, + ) + } startControlMasterReq := requirement{ description: "Explicitly start ssh ControlMaster", script: `#!/bin/bash @@ -280,3 +305,81 @@ Check "/var/log/cloud-init-output.log" in the guest to see where the process is }) return req } + +// detectGuestIPAddress detects the guest IP address on the interface in same subnet on the host +// by parsing the output of "ip -j addr" command in the guest. +func (a *HostAgent) detectGuestIPAddress(stdout string) error { + var guestIfs []struct { + IFNAME string `json:"ifname"` + ADDRS []struct { + Family string `json:"family"` + Local net.IP `json:"local"` + Scope string `json:"scope"` + } `json:"addr_info"` + } + if err := json.Unmarshal([]byte(stdout), &guestIfs); err != nil { + return fmt.Errorf("failed to parse ip addr output %q: %w", stdout, err) + } + var ( + guestIPv4 net.IP + guestIPv6 net.IP + ) + hostIfs, err := net.Interfaces() + if err != nil { + return fmt.Errorf("failed to get network interfaces: %w", err) + } + for _, hostIf := range hostIfs { + if hostIf.Flags&net.FlagUp == 0 { + continue + } + hostAddrs, err := hostIf.Addrs() + if err != nil { + return fmt.Errorf("failed to get addresses for interface %q: %w", hostIf.Name, err) + } + for _, hostAddr := range hostAddrs { + hostIPNet, ok := hostAddr.(*net.IPNet) + if !ok { + continue + } + for _, guestIf := range guestIfs { + if hostIPv4 := hostIPNet.IP.To4(); hostIPv4 != nil { + for _, guestAddr := range guestIf.ADDRS { + if guestAddr.Scope != "global" { + continue + } else if guestAddr.Family != "inet" { + continue + } else if hostIPNet.Contains(guestAddr.Local) { + guestIPv4 = guestAddr.Local + } + } + } else if hostIPv6 := hostIPNet.IP.To16(); hostIPv6 != nil { + for _, guestAddr := range guestIf.ADDRS { + if guestAddr.Scope != "global" { + continue + } else if guestAddr.Family != "inet6" { + continue + } else if hostIPNet.Contains(guestAddr.Local) { + guestIPv6 = guestAddr.Local + } + } + } + } + } + } + if guestIPv4 == nil && guestIPv6 == nil { + logrus.Infof("The guest IPv4/IPv6 address is not found") + return nil + } + if guestIPv4 != nil { + logrus.Infof("The guest IPv4 address is %q", guestIPv4) + } + if guestIPv6 != nil { + logrus.Infof("The guest IPv6 address is %q", guestIPv6) + } + a.guestIPMu.Lock() + a.guestIPv4 = guestIPv4 + a.guestIPv6 = guestIPv6 + a.guestIPMu.Unlock() + ctx := context.Background() + return a.WriteSSHConfigFile(ctx) +} From 1a7483523515167436f0840253b87d502e8dfabb Mon Sep 17 00:00:00 2001 From: Norio Nomura Date: Fri, 10 Oct 2025 18:24:52 +0900 Subject: [PATCH 02/10] pkg/hostagent: Add `GuestIP` field to `GET /v1/info` endpoint` Update to use guestIPAddress: - `limactl shell` - `limactl show-ssh` - `limactl tunnel` pkg/limatype: - Add `GuestIP net.IP` to `Instance` - Add `Instance.SSHAddressPort()` helper to provide ipAddress and port for SSH Signed-off-by: Norio Nomura # Conflicts: # pkg/hostagent/api/api.go # pkg/hostagent/hostagent.go # pkg/limatype/lima_instance.go --- cmd/limactl/shell.go | 5 +++-- cmd/limactl/show-ssh.go | 5 +++-- cmd/limactl/tunnel.go | 5 +++-- pkg/hostagent/api/api.go | 6 +++++- pkg/hostagent/hostagent.go | 2 ++ pkg/limatype/lima_instance.go | 13 +++++++++++++ pkg/store/instance.go | 4 +++- 7 files changed, 32 insertions(+), 8 deletions(-) diff --git a/cmd/limactl/shell.go b/cmd/limactl/shell.go index 017aa1f579b..c358039054d 100644 --- a/cmd/limactl/shell.go +++ b/cmd/limactl/shell.go @@ -282,10 +282,11 @@ func shellAction(cmd *cobra.Command, args []string) error { if olderSSH { logLevel = "QUIET" } + sshAddress, sshPort := inst.SSHAddressPort() sshArgs = append(sshArgs, []string{ "-o", fmt.Sprintf("LogLevel=%s", logLevel), - "-p", strconv.Itoa(inst.SSHLocalPort), - inst.SSHAddress, + "-p", strconv.Itoa(sshPort), + sshAddress, "--", script, }...) diff --git a/cmd/limactl/show-ssh.go b/cmd/limactl/show-ssh.go index ecaf98e43da..0ed7f5eb6ca 100644 --- a/cmd/limactl/show-ssh.go +++ b/cmd/limactl/show-ssh.go @@ -109,8 +109,9 @@ func showSSHAction(cmd *cobra.Command, args []string) error { if err != nil { return err } - opts = append(opts, "Hostname=127.0.0.1") - opts = append(opts, fmt.Sprintf("Port=%d", inst.SSHLocalPort)) + sshAddress, sshPort := inst.SSHAddressPort() + opts = append(opts, fmt.Sprintf("Hostname=%s", sshAddress)) + opts = append(opts, fmt.Sprintf("Port=%d", sshPort)) return sshutil.Format(w, "ssh", instName, format, opts) } diff --git a/cmd/limactl/tunnel.go b/cmd/limactl/tunnel.go index 68b16cbf674..c5290774d69 100644 --- a/cmd/limactl/tunnel.go +++ b/cmd/limactl/tunnel.go @@ -103,13 +103,14 @@ func tunnelAction(cmd *cobra.Command, args []string) error { } sshArgs := append([]string{}, sshExe.Args...) sshArgs = append(sshArgs, sshutil.SSHArgsFromOpts(sshOpts)...) + sshAddress, sshPort := inst.SSHAddressPort() sshArgs = append(sshArgs, []string{ "-q", // quiet "-f", // background "-N", // no command "-D", fmt.Sprintf("127.0.0.1:%d", port), - "-p", strconv.Itoa(inst.SSHLocalPort), - inst.SSHAddress, + "-p", strconv.Itoa(sshPort), + sshAddress, }...) sshCmd := exec.CommandContext(ctx, sshExe.Exe, sshArgs...) sshCmd.Stdout = stderr diff --git a/pkg/hostagent/api/api.go b/pkg/hostagent/api/api.go index d893b6acddd..6fe82989584 100644 --- a/pkg/hostagent/api/api.go +++ b/pkg/hostagent/api/api.go @@ -3,9 +3,13 @@ package api +import "net" + type Info struct { // indicate instance is started by launchd or systemd if not empty AutoStartedIdentifier string `json:"autoStartedIdentifier,omitempty"` - // SSHLocalPort is the local port on the host for SSH access to the VM. + // Guest IP address directly accessible from the host. + GuestIP net.IP `json:"guestIP,omitempty"` + // SSH local port on the host forwarded to the guest's port 22. SSHLocalPort int `json:"sshLocalPort,omitempty"` } diff --git a/pkg/hostagent/hostagent.go b/pkg/hostagent/hostagent.go index 296faa26c51..24c4f94b2c3 100644 --- a/pkg/hostagent/hostagent.go +++ b/pkg/hostagent/hostagent.go @@ -521,8 +521,10 @@ func (a *HostAgent) GuestIP() net.IP { } func (a *HostAgent) Info(_ context.Context) (*hostagentapi.Info, error) { + guestIP := a.GuestIP() info := &hostagentapi.Info{ AutoStartedIdentifier: autostart.AutoStartedIdentifier(), + GuestIP: guestIP, SSHLocalPort: a.sshLocalPort, } return info, nil diff --git a/pkg/limatype/lima_instance.go b/pkg/limatype/lima_instance.go index fada2f34e98..0702ac6e9d9 100644 --- a/pkg/limatype/lima_instance.go +++ b/pkg/limatype/lima_instance.go @@ -6,6 +6,7 @@ package limatype import ( "encoding/json" "errors" + "net" "os" "path/filepath" @@ -48,6 +49,8 @@ type Instance struct { LimaVersion string `json:"limaVersion"` Param map[string]string `json:"param,omitempty"` AutoStartedIdentifier string `json:"autoStartedIdentifier,omitempty"` + // Guest IP address directly accessible from the host. + GuestIP net.IP `json:"guestIP,omitempty"` } // Protect protects the instance to prohibit accidental removal. @@ -108,3 +111,13 @@ func (inst *Instance) UnmarshalJSON(data []byte) error { } return nil } + +func (inst *Instance) SSHAddressPort() (sshAddress string, sshPort int) { + sshAddress = inst.SSHAddress + sshPort = inst.SSHLocalPort + if inst.GuestIP != nil { + sshAddress = inst.GuestIP.String() + sshPort = 22 + } + return sshAddress, sshPort +} diff --git a/pkg/store/instance.go b/pkg/store/instance.go index 54e7a9c1f3e..91a248c3ab1 100644 --- a/pkg/store/instance.go +++ b/pkg/store/instance.go @@ -82,6 +82,7 @@ func Inspect(ctx context.Context, instName string) (*limatype.Instance, error) { inst.Status = limatype.StatusBroken inst.Errors = append(inst.Errors, fmt.Errorf("failed to get Info from %q: %w", haSock, err)) } else { + inst.GuestIP = info.GuestIP inst.SSHLocalPort = info.SSHLocalPort inst.AutoStartedIdentifier = info.AutoStartedIdentifier } @@ -347,10 +348,11 @@ func PrintInstances(w io.Writer, instances []*limatype.Instance, format string, if strings.HasPrefix(dir, homeDir) { dir = strings.Replace(dir, homeDir, "~", 1) } + sshAddress, sshPort := instance.SSHAddressPort() fmt.Fprintf(w, "%s\t%s\t%s", instance.Name, instance.Status, - fmt.Sprintf("%s:%d", instance.SSHAddress, instance.SSHLocalPort), + fmt.Sprintf("%s:%d", sshAddress, sshPort), ) if !hideType { fmt.Fprintf(w, "\t%s", From 9ca9f675dac6ef1aa1ff1f5ef987de374701f8b2 Mon Sep 17 00:00:00 2001 From: Norio Nomura Date: Fri, 10 Oct 2025 18:26:47 +0900 Subject: [PATCH 03/10] pkg/hostagent/events: Add `GuestIP net.IP` field to `Status` Support printing "Guest IP Address: %s" on `limactl start` Signed-off-by: Norio Nomura # Conflicts: # pkg/hostagent/requirements.go --- pkg/hostagent/events/events.go | 4 ++++ pkg/hostagent/hostagent.go | 11 +++++++++++ pkg/hostagent/requirements.go | 1 + pkg/instance/start.go | 9 +++++++++ 4 files changed, 25 insertions(+) diff --git a/pkg/hostagent/events/events.go b/pkg/hostagent/events/events.go index 3afa14319ff..3819c3d05fa 100644 --- a/pkg/hostagent/events/events.go +++ b/pkg/hostagent/events/events.go @@ -4,6 +4,7 @@ package events import ( + "net" "time" ) @@ -16,6 +17,9 @@ type Status struct { Errors []string `json:"errors,omitempty"` + // Guest IP address directly accessible from the host. + GuestIP net.IP `json:"guestIP,omitempty"` + // SSH local port on the host forwarded to the guest's port 22. SSHLocalPort int `json:"sshLocalPort,omitempty"` // Cloud-init progress information diff --git a/pkg/hostagent/hostagent.go b/pkg/hostagent/hostagent.go index 24c4f94b2c3..40dda6212ed 100644 --- a/pkg/hostagent/hostagent.go +++ b/pkg/hostagent/hostagent.go @@ -362,6 +362,17 @@ func (a *HostAgent) emitCloudInitProgressEvent(ctx context.Context, progress *ev a.emitEvent(ctx, ev) } +func (a *HostAgent) emitGuestIPEvent(ctx context.Context, ip string) { + a.statusMu.RLock() + currentStatus := a.currentStatus + a.statusMu.RUnlock() + + currentStatus.GuestIP = net.ParseIP(ip) + + ev := events.Event{Status: currentStatus} + a.emitEvent(ctx, ev) +} + func generatePassword(length int) (string, error) { // avoid any special symbols, to make it easier to copy/paste return password.Generate(length, length/4, 0, false, false) diff --git a/pkg/hostagent/requirements.go b/pkg/hostagent/requirements.go index f9904e9e7b8..b2883d26ae8 100644 --- a/pkg/hostagent/requirements.go +++ b/pkg/hostagent/requirements.go @@ -381,5 +381,6 @@ func (a *HostAgent) detectGuestIPAddress(stdout string) error { a.guestIPv6 = guestIPv6 a.guestIPMu.Unlock() ctx := context.Background() + a.emitGuestIPEvent(ctx, a.GuestIP().String()) return a.WriteSSHConfigFile(ctx) } diff --git a/pkg/instance/start.go b/pkg/instance/start.go index 8e5c3806f81..83340f7903a 100644 --- a/pkg/instance/start.go +++ b/pkg/instance/start.go @@ -290,6 +290,7 @@ func watchHostAgentEvents(ctx context.Context, inst *limatype.Instance, haStdout var ( printedSSHLocalPort bool + printedGuestIP bool receivedRunningEvent bool cloudInitCompleted bool err error @@ -304,6 +305,14 @@ func watchHostAgentEvents(ctx context.Context, inst *limatype.Instance, haStdout inst.SSHLocalPort = ev.Status.SSHLocalPort } + if !printedGuestIP && ev.Status.GuestIP != nil { + logrus.Infof("Guest IP Address: %s", ev.Status.GuestIP.String()) + printedGuestIP = true + + // Update the instance's Guest IP address + inst.GuestIP = ev.Status.GuestIP + } + if showProgress && ev.Status.CloudInitProgress != nil { progress := ev.Status.CloudInitProgress if progress.Active && progress.LogLine == "" { From 2ef79c9591ee6aa890a3e21b3189eccd072ec9c1 Mon Sep 17 00:00:00 2001 From: Norio Nomura Date: Sun, 12 Oct 2025 13:24:23 +0900 Subject: [PATCH 04/10] pkg/hostagent: Support port forwarding to direct guest IP Change to use `github.com/inetaf/tcpproxy`: - pkg/driver/vz/vsock_forwarder.go - pkg/portfwd/client.go - pkg/portfwdserver/server.go pkg/portfwd: - Use `dialContext` instead of `client` to use `net.Conn` other than `GrpcClientRW`. - Change to create proxies from `dialContext` parameters instead of `conn`. - Add `DialContextToGRPCTunnel()` to return `DialContext` function that connects to GRPC tunnel. pkg/hostagent: - Add `HostAgent.DialContextToGuestIP()` to return `DialContext` function that connects to the guest IP directly. If the guest IP is not known, it returns nil. - Prefer `HostAgent.DialContextToGuestIP()` over `portfwd.DialContextToGRPCTunnel()`. Signed-off-by: Norio Nomura pkg/hostagent: Use `HostAgent.DialContextToGuestIP()` if the IP is accessible directly. Signed-off-by: Norio Nomura Revert to "github.com/containers/gvisor-tap-vsock/pkg/tcpproxy" Signed-off-by: Norio Nomura # Conflicts: # pkg/hostagent/hostagent.go pkg/hostagent: Aware forwarding guest address is IPv4 or IPv6 Signed-off-by: Norio Nomura pkg/hostagent: Fallback to GRPC forwarder if dialing to a direct ip fails Signed-off-by: Norio Nomura # Conflicts: # pkg/hostagent/hostagent.go --- pkg/hostagent/hostagent.go | 58 +++++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/pkg/hostagent/hostagent.go b/pkg/hostagent/hostagent.go index 40dda6212ed..c67dc133b04 100644 --- a/pkg/hostagent/hostagent.go +++ b/pkg/hostagent/hostagent.go @@ -531,6 +531,13 @@ func (a *HostAgent) GuestIP() net.IP { return nil } +// GuestIPs returns the guest's IPv4 and IPv6 addresses if available; otherwise nil. +func (a *HostAgent) GuestIPs() (ipv4, ipv6 net.IP) { + a.guestIPMu.RLock() + defer a.guestIPMu.RUnlock() + return a.guestIPv4, a.guestIPv6 +} + func (a *HostAgent) Info(_ context.Context) (*hostagentapi.Info, error) { guestIP := a.GuestIP() info := &hostagentapi.Info{ @@ -896,7 +903,38 @@ func (a *HostAgent) processGuestAgentEvents(ctx context.Context, client *guestag if useSSHFwd { a.portForwarder.OnEvent(ctx, ev) } else { - dialContext := portfwd.DialContextToGRPCTunnel(client) + dialContext := func(ctx context.Context, network, guestAddress string) (net.Conn, error) { + guestIPv4, guestIPv6 := a.GuestIPs() + if guestIPv4 == nil && guestIPv6 == nil { + return portfwd.DialContextToGRPCTunnel(client)(ctx, network, guestAddress) + } + // Check if the host part of guestAddress is either unspecified address or matches the known guest IP. + // If so, replace it with the known guest IP to avoid issues with dual-stack setups and DNS resolution. + // Otherwise, fall back to the gRPC tunnel. + if host, _, err := net.SplitHostPort(guestAddress); err != nil { + return nil, err + } else if ip := net.ParseIP(host); ip.IsUnspecified() || ip.Equal(guestIPv4) || ip.Equal(guestIPv6) { + if ip.To4() != nil { + if guestIPv4 != nil { + conn, err := DialContextToGuestIP(guestIPv4)(ctx, network, guestAddress) + if err == nil { + return conn, nil + } + logrus.WithError(err).Warn("failed to connect to the guest IPv4 directly, falling back to gRPC tunnel") + } + } else if ip.To16() != nil { + if guestIPv6 != nil { + conn, err := DialContextToGuestIP(guestIPv6)(ctx, network, guestAddress) + if err == nil { + return conn, nil + } + logrus.WithError(err).Warn("failed to connect to the guest IPv6 directly, falling back to gRPC tunnel") + } + } + // If we reach here, it means we couldn't find a suitable guest IP + } + return portfwd.DialContextToGRPCTunnel(client)(ctx, network, guestAddress) + } a.grpcPortForwarder.OnEvent(ctx, dialContext, ev) } } @@ -910,6 +948,24 @@ func (a *HostAgent) processGuestAgentEvents(ctx context.Context, client *guestag return io.EOF } +// DialContextToGuestIP returns a DialContext function that connects to the guest IP directly. +// If the guest IP is not known, it returns nil. +func DialContextToGuestIP(guestIP net.IP) func(ctx context.Context, network, address string) (net.Conn, error) { + if guestIP == nil { + return nil + } + return func(ctx context.Context, network, address string) (net.Conn, error) { + var d net.Dialer + _, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + // Host part of address is ignored, because it already has been checked by forwarding rules + // and we want to connect to the guest IP directly. + return d.DialContext(ctx, network, net.JoinHostPort(guestIP.String(), port)) + } +} + const ( verbForward = "forward" verbCancel = "cancel" From f813d4d6247b5f41952c3c54ac074588031476f7 Mon Sep 17 00:00:00 2001 From: Norio Nomura Date: Sun, 12 Oct 2025 19:41:01 +0900 Subject: [PATCH 05/10] .github/workflows/test.yml: Add test using `default.yaml` with `--network=vzNAT` Signed-off-by: Norio Nomura --- .github/workflows/test.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index da3ee770dba..797e7d860f2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -516,10 +516,13 @@ jobs: matrix: template: - default.yaml + create_arg: + - "" + - "--network=vzNAT" steps: - name: "Adjust LIMACTL_CREATE_ARGS" # --cpus=1 is needed for running vz on GHA: https://github.com/lima-vm/lima/pull/1511#issuecomment-1574937888 - run: echo "LIMACTL_CREATE_ARGS=${LIMACTL_CREATE_ARGS} --cpus 1 --memory 1" >>$GITHUB_ENV + run: echo "LIMACTL_CREATE_ARGS=${LIMACTL_CREATE_ARGS} --cpus 1 --memory 1 ${{ matrix.create_arg }}" >>$GITHUB_ENV - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 with: @@ -541,7 +544,7 @@ jobs: - if: failure() uses: ./.github/actions/upload_failure_logs_if_exists with: - suffix: ${{ matrix.template }} + suffix: ${{ matrix.template }}${{ matrix.create_arg }} # gomodjail is a library sandbox for Go # https://github.com/AkihiroSuda/gomodjail From 097efc6f885042defd7da790eb47b4b2c4205044 Mon Sep 17 00:00:00 2001 From: Norio Nomura Date: Mon, 13 Oct 2025 20:33:54 +0900 Subject: [PATCH 06/10] hack/test-port-forwarding.pl: print cmd in guest Signed-off-by: Norio Nomura --- hack/test-port-forwarding.pl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hack/test-port-forwarding.pl b/hack/test-port-forwarding.pl index 4c586dbf442..4e792f9a8a3 100755 --- a/hack/test-port-forwarding.pl +++ b/hack/test-port-forwarding.pl @@ -191,6 +191,7 @@ my $sudo = $test->{guest_port} < 1024 ? "sudo " : ""; print $lima "${sudo}${cmd} >$listener.${id} 2>/dev/null &\n"; + print "Running in guest: ${sudo}${cmd} >$listener.${id} 2>/dev/null &\n"; } # Make sure the guest- and hostagents had enough time to set up the forwards @@ -211,7 +212,7 @@ my $tcp_dest = $test->{host_ip} =~ /:/ ? "TCP6:[$test->{host_ip}]:$test->{host_port}" : "TCP:$test->{host_ip}:$test->{host_port}"; $cmd = $test->{host_socket} eq "" ? "socat -u STDIN $tcp_dest,connect-timeout=$connectionTimeout" : "socat -u STDIN UNIX-CONNECT:$test->{host_socket}"; } - print "Running: $cmd\n"; + print "Running in host: $cmd\n"; open(my $netcat, "| $cmd") or die "Can't run '$cmd': $!"; print $netcat "$test->{log_msg}\n"; # Don't check for errors on close; macOS nc seems to return non-zero exit code even on success From e6a7f6a89fd48f49275f0fc913578fac444acfb4 Mon Sep 17 00:00:00 2001 From: Norio Nomura Date: Tue, 14 Oct 2025 18:25:32 +0900 Subject: [PATCH 07/10] hack/test-templates.sh: Change order of `LIMA_SSH_OVER_VSOCK` tests To following tests will be run with enabled. Signed-off-by: Norio Nomura # Conflicts: # hack/test-templates.sh --- hack/test-templates.sh | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/hack/test-templates.sh b/hack/test-templates.sh index a8ae97da0b1..812bf53936b 100755 --- a/hack/test-templates.sh +++ b/hack/test-templates.sh @@ -336,30 +336,20 @@ if [[ -n ${CHECKS["ssh-over-vsock"]} ]]; then if [[ "$(limactl ls "${NAME}" --yq .vmType)" == "vz" ]]; then INFO "Testing SSH over vsock" set -x - INFO "Testing LIMA_SSH_OVER_VSOCK=true environment" - limactl stop "${NAME}" - # Detection of the SSH server on VSOCK may fail; however, a failing log indicates that controlling detection via the environment variable works as expected. - if ! LIMA_SSH_OVER_VSOCK=true limactl start "${NAME}" 2>&1 | grep -i -E "(started vsock forwarder|Failed to detect SSH server on vsock)"; then - set +x - diagnose "${NAME}" - ERROR "LIMA_SSH_OVER_VSOCK=true did not enable vsock forwarder" - exit 1 - fi - INFO 'Testing LIMA_SSH_OVER_VSOCK="" environment' + INFO "Testing LIMA_SSH_OVER_VSOCK=false environment" limactl stop "${NAME}" - # Detection of the SSH server on VSOCK may fail; however, a failing log indicates that controlling detection via the environment variable works as expected. - if ! LIMA_SSH_OVER_VSOCK="" limactl start "${NAME}" 2>&1 | grep -i -E "(started vsock forwarder|Failed to detect SSH server on vsock)"; then + if ! LIMA_SSH_OVER_VSOCK=false limactl start "${NAME}" 2>&1 | grep -i "skipping detection of SSH server on vsock port"; then set +x diagnose "${NAME}" - ERROR "LIMA_SSH_OVER_VSOCK= did not enable vsock forwarder" + ERROR "LIMA_SSH_OVER_VSOCK=false did not disable vsock forwarder" exit 1 fi - INFO "Testing LIMA_SSH_OVER_VSOCK=false environment" + INFO "Testing LIMA_SSH_OVER_VSOCK=true environment" limactl stop "${NAME}" - if ! LIMA_SSH_OVER_VSOCK=false limactl start "${NAME}" 2>&1 | grep -i "skipping detection of SSH server on vsock port"; then + if ! LIMA_SSH_OVER_VSOCK=true limactl start "${NAME}" 2>&1 | grep -i -E "(started vsock forwarder|Failed to detect SSH server on vsock)"; then set +x diagnose "${NAME}" - ERROR "LIMA_SSH_OVER_VSOCK=false did not disable vsock forwarder" + ERROR "LIMA_SSH_OVER_VSOCK=true did not enable vsock forwarder" exit 1 fi set +x From a3877fe1d6bf82df04b3821dd62cc00f0594dc63 Mon Sep 17 00:00:00 2001 From: Norio Nomura Date: Wed, 15 Oct 2025 19:00:21 +0900 Subject: [PATCH 08/10] hack/test-templates.sh: Print routing table before executing `test-port-forwarding.pl` Signed-off-by: Norio Nomura --- hack/test-templates.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/hack/test-templates.sh b/hack/test-templates.sh index 812bf53936b..f2987bf1d68 100755 --- a/hack/test-templates.sh +++ b/hack/test-templates.sh @@ -435,6 +435,13 @@ if [[ -n ${CHECKS["port-forwards"]} ]]; then if limactl shell "${NAME}" command -v dnf; then limactl shell "${NAME}" sudo dnf install -y nc socat fi + # print routing table for debugging + case "${OS_HOST}" in + "Darwin") netstat -rn ;; + "GNU/Linux") ip route show ;; + "Msys") route print ;; + *) ;; + esac if "${scriptdir}/test-port-forwarding.pl" "${NAME}" socat $PORT_FORWARDING_CONNECTION_TIMEOUT; then INFO "Port forwarding rules work" else From f7881d80855919efeb746c29f0b9edfd36ca294e Mon Sep 17 00:00:00 2001 From: Norio Nomura Date: Thu, 16 Oct 2025 18:16:20 +0900 Subject: [PATCH 09/10] pkg/hostagent: Add the `_LIMA_DIRECT_IP_PORT_FORWARDER` environment variable to enable direct IP port forwarding to the VM. ### Direct IP Port Forwarding To enable direct IP port forwarding, set the `_LIMA_DIRECT_IP_PORT_FORWARDER` environment variable to `true`: ```bash export _LIMA_DIRECT_IP_PORT_FORWARDER=true ``` This feature makes Lima to use direct IP port forwarding instead of gRPC port forwarding. When this feature is enabled, Lima tries to connect to the guest's IP address directly for port forwarding. #### Fallback to gRPC Port Forwarding Lima may fall back to gRPC port forwarding in the following cases: - If the guest's IP address is not available, Lima falls back to gRPC port forwarding. - If the guest's IP address is available but the connection to the guest's IP address fails, Lima also falls back to gRPC port forwarding. - If the guest's IP address is not accessible from the host (e.g. localhost on guest), Lima falls back to gRPC port forwarding as well. Signed-off-by: Norio Nomura --- .github/workflows/test.yml | 8 +++ pkg/hostagent/hostagent.go | 65 ++++++++++++------- pkg/hostagent/requirements.go | 1 + .../en/docs/config/environment-variables.md | 13 ++++ website/content/en/docs/config/port.md | 16 +++++ 5 files changed, 78 insertions(+), 25 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 797e7d860f2..0ef30a16a60 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -519,6 +519,11 @@ jobs: create_arg: - "" - "--network=vzNAT" + include: + - template: default.yaml + create_arg: "--network=vzNAT" + additional_env: | + _LIMA_DIRECT_IP_PORT_FORWARDER=true steps: - name: "Adjust LIMACTL_CREATE_ARGS" # --cpus=1 is needed for running vz on GHA: https://github.com/lima-vm/lima/pull/1511#issuecomment-1574937888 @@ -539,6 +544,9 @@ jobs: run: brew install bash coreutils w3m socat - name: Uninstall qemu run: brew uninstall --ignore-dependencies --force qemu + - name: Set additional environment variables + if: matrix.additional_env != null + run: echo "${{ matrix.additional_env }}" >>$GITHUB_ENV - name: Test run: ./hack/test-templates.sh templates/${{ matrix.template }} - if: failure() diff --git a/pkg/hostagent/hostagent.go b/pkg/hostagent/hostagent.go index c67dc133b04..1eeea518337 100644 --- a/pkg/hostagent/hostagent.go +++ b/pkg/hostagent/hostagent.go @@ -903,37 +903,52 @@ func (a *HostAgent) processGuestAgentEvents(ctx context.Context, client *guestag if useSSHFwd { a.portForwarder.OnEvent(ctx, ev) } else { - dialContext := func(ctx context.Context, network, guestAddress string) (net.Conn, error) { - guestIPv4, guestIPv6 := a.GuestIPs() - if guestIPv4 == nil && guestIPv6 == nil { - return portfwd.DialContextToGRPCTunnel(client)(ctx, network, guestAddress) + useDirectIPPortForwarding := false + if envVar := os.Getenv("_LIMA_DIRECT_IP_PORT_FORWARDER"); envVar != "" { + b, err := strconv.ParseBool(envVar) + if err != nil { + logrus.WithError(err).Warnf("invalid _LIMA_DIRECT_IP_PORT_FORWARDER value %q", envVar) + } else { + useDirectIPPortForwarding = b } - // Check if the host part of guestAddress is either unspecified address or matches the known guest IP. - // If so, replace it with the known guest IP to avoid issues with dual-stack setups and DNS resolution. - // Otherwise, fall back to the gRPC tunnel. - if host, _, err := net.SplitHostPort(guestAddress); err != nil { - return nil, err - } else if ip := net.ParseIP(host); ip.IsUnspecified() || ip.Equal(guestIPv4) || ip.Equal(guestIPv6) { - if ip.To4() != nil { - if guestIPv4 != nil { - conn, err := DialContextToGuestIP(guestIPv4)(ctx, network, guestAddress) - if err == nil { - return conn, nil + } + var dialContext func(ctx context.Context, network string, guestAddress string) (net.Conn, error) + if useDirectIPPortForwarding { + logrus.Warn("Direct IP Port forwarding is enabled. It may fall back to GRPC Port Forwarding in some cases.") + dialContext = func(ctx context.Context, network, guestAddress string) (net.Conn, error) { + guestIPv4, guestIPv6 := a.GuestIPs() + if guestIPv4 == nil && guestIPv6 == nil { + return portfwd.DialContextToGRPCTunnel(client)(ctx, network, guestAddress) + } + // Check if the host part of guestAddress is either unspecified address or matches the known guest IP. + // If so, replace it with the known guest IP to avoid issues with dual-stack setups and DNS resolution. + // Otherwise, fall back to the gRPC tunnel. + if host, _, err := net.SplitHostPort(guestAddress); err != nil { + return nil, err + } else if ip := net.ParseIP(host); ip.IsUnspecified() || ip.Equal(guestIPv4) || ip.Equal(guestIPv6) { + if ip.To4() != nil { + if guestIPv4 != nil { + conn, err := DialContextToGuestIP(guestIPv4)(ctx, network, guestAddress) + if err == nil { + return conn, nil + } + logrus.WithError(err).Warn("failed to connect to the guest IPv4 directly, falling back to gRPC tunnel") } - logrus.WithError(err).Warn("failed to connect to the guest IPv4 directly, falling back to gRPC tunnel") - } - } else if ip.To16() != nil { - if guestIPv6 != nil { - conn, err := DialContextToGuestIP(guestIPv6)(ctx, network, guestAddress) - if err == nil { - return conn, nil + } else if ip.To16() != nil { + if guestIPv6 != nil { + conn, err := DialContextToGuestIP(guestIPv6)(ctx, network, guestAddress) + if err == nil { + return conn, nil + } + logrus.WithError(err).Warn("failed to connect to the guest IPv6 directly, falling back to gRPC tunnel") } - logrus.WithError(err).Warn("failed to connect to the guest IPv6 directly, falling back to gRPC tunnel") } + // If we reach here, it means we couldn't find a suitable guest IP } - // If we reach here, it means we couldn't find a suitable guest IP + return portfwd.DialContextToGRPCTunnel(client)(ctx, network, guestAddress) } - return portfwd.DialContextToGRPCTunnel(client)(ctx, network, guestAddress) + } else { + dialContext = portfwd.DialContextToGRPCTunnel(client) } a.grpcPortForwarder.OnEvent(ctx, dialContext, ev) } diff --git a/pkg/hostagent/requirements.go b/pkg/hostagent/requirements.go index b2883d26ae8..90a70b52f56 100644 --- a/pkg/hostagent/requirements.go +++ b/pkg/hostagent/requirements.go @@ -161,6 +161,7 @@ If any private key under ~/.ssh is protected with a passphrase, you need to have noMaster: true, }, ) + if runtime.GOOS == "darwin" { // Limit the Guest IP address detection only to macOS for now. req = append(req, diff --git a/website/content/en/docs/config/environment-variables.md b/website/content/en/docs/config/environment-variables.md index 7881d5eddf4..6d13c5c8c0d 100644 --- a/website/content/en/docs/config/environment-variables.md +++ b/website/content/en/docs/config/environment-variables.md @@ -140,6 +140,19 @@ This page documents the environment variables used in Lima. export LIMA_USERNET_RESOLVE_IP_ADDRESS_TIMEOUT=5 ``` +### `_LIMA_DIRECT_IP_PORT_FORWARDER` + +- **Description**: Specifies to use direct IP port forwarding instead of gRPC. +- **Default**: `false` +- **Usage**: + ```sh + export _LIMA_DIRECT_IP_PORT_FORWARDER=true + ``` +- **Note**: Direct IP port forwarding may fall back to gRPC port forwarding in some cases: + - If the guest's IP address is not available, Lima falls back to gRPC port forwarding. + - If the guest's IP address is available but the connection to the guest's IP address fails, Lima also falls back to gRPC port forwarding. + - If the guest's IP address is not accessible from the host (e.g. localhost on guest), Lima falls back to gRPC port forwarding as well. + ### `_LIMA_QEMU_UEFI_IN_BIOS` - **Description**: Commands QEMU to load x86_64 UEFI images using `-bios` instead of `pflash` drives. diff --git a/website/content/en/docs/config/port.md b/website/content/en/docs/config/port.md index 05d538cdb56..082f4d42956 100644 --- a/website/content/en/docs/config/port.md +++ b/website/content/en/docs/config/port.md @@ -88,6 +88,22 @@ lima ip addr show lima0 See [Config » Network » VMNet networks](./network/vmnet.md) for the further information. +### Direct IP Port Forwarding + +To enable direct IP port forwarding, set the `_LIMA_DIRECT_IP_PORT_FORWARDER` environment variable to `true`: + +```bash +export _LIMA_DIRECT_IP_PORT_FORWARDER=true +``` +This feature makes Lima to use direct IP port forwarding instead of gRPC port forwarding. +When this feature is enabled, Lima tries to connect to the guest's IP address directly for port forwarding. + +#### Fallback to gRPC Port Forwarding +Lima may fall back to gRPC port forwarding in the following cases: +- If the guest's IP address is not available, Lima falls back to gRPC port forwarding. +- If the guest's IP address is available but the connection to the guest's IP address fails, Lima also falls back to gRPC port forwarding. +- If the guest's IP address is not accessible from the host (e.g. localhost on guest), Lima falls back to gRPC port forwarding as well. + ## Benchmarks From 7a7b3791abe2e21beef391359cc84a9220c95308 Mon Sep 17 00:00:00 2001 From: Norio Nomura Date: Fri, 17 Oct 2025 10:50:44 +0900 Subject: [PATCH 10/10] pkg/hostagent: Add "Skipping the guest IP address detection..." on hosts other than macOS Signed-off-by: Norio Nomura --- pkg/hostagent/requirements.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/hostagent/requirements.go b/pkg/hostagent/requirements.go index 90a70b52f56..20d27fef232 100644 --- a/pkg/hostagent/requirements.go +++ b/pkg/hostagent/requirements.go @@ -177,6 +177,8 @@ If the interface does not have IPv4 address, SSH connection against the guest OS stdoutParser: a.detectGuestIPAddress, }, ) + } else { + logrus.Info("Skipping the guest IP address detection because it is only tested on macOS for now") } startControlMasterReq := requirement{ description: "Explicitly start ssh ControlMaster",