Skip to content

Commit 16b4782

Browse files
committed
pkg/driver/vz: Try SSH handshake to check if SSH port is available.
Check the SSH server in a way that complies with the SSH protocol using x/crypto/ssh. This change fixes #4334 by falling back to usernet port forwarder on failing SSH connections over VSOCK. - pkg/networks/usernet: Rename entry point from `/extension/wait_port` to `/extension/wait_ssh_server` Because it changed to an SSH server-specific entry point. When a client accesses the old entry point, it fails and continues with falling back to the usernet forwarder. - pkg/sshutil: Add `WaitSSHReady()` WaitSSHReady waits until the SSH server is ready to accept connections. The dialContext function is used to create a connection to the SSH server. The addr, user, privateKeyPath parameter is used for ssh.ClientConn creation. The timeoutSeconds parameter specifies the maximum number of seconds to wait. Signed-off-by: Norio Nomura <[email protected]>
1 parent e21b634 commit 16b4782

File tree

6 files changed

+119
-41
lines changed

6 files changed

+119
-41
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ require (
117117
github.com/x448/float16 v0.8.4 // indirect
118118
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
119119
github.com/yuin/gopher-lua v1.1.1 // indirect
120-
golang.org/x/crypto v0.43.0 // indirect
120+
golang.org/x/crypto v0.43.0
121121
golang.org/x/mod v0.29.0 // indirect
122122
golang.org/x/oauth2 v0.30.0 // indirect
123123
golang.org/x/term v0.36.0 // indirect

pkg/driver/vz/vm_darwin.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -113,18 +113,18 @@ func startVM(ctx context.Context, inst *limatype.Instance, sshLocalPort int) (vm
113113
useSSHOverVsock = b
114114
}
115115
}
116+
hostAddress := net.JoinHostPort(inst.SSHAddress, strconv.Itoa(usernetSSHLocalPort))
116117
if !useSSHOverVsock {
117118
logrus.Info("LIMA_SSH_OVER_VSOCK is false, skipping detection of SSH server on vsock port")
118-
} else if err := usernetClient.WaitOpeningSSHPort(ctx, inst); err == nil {
119-
hostAddress := net.JoinHostPort(inst.SSHAddress, strconv.Itoa(usernetSSHLocalPort))
120-
if err := wrapper.startVsockForwarder(ctx, 22, hostAddress); err == nil {
121-
logrus.Infof("Detected SSH server is listening on the vsock port; changed %s to proxy for the vsock port", hostAddress)
122-
usernetSSHLocalPort = 0 // disable gvisor ssh port forwarding
123-
} else {
124-
logrus.WithError(err).Warn("Failed to detect SSH server on vsock port, falling back to usernet forwarder")
125-
}
119+
} else if err := usernetClient.WaitOpeningSSHPort(ctx, inst); err != nil {
120+
logrus.WithError(err).Info("Failed to wait for the guest SSH server to become available, falling back to usernet forwarder")
121+
} else if err := wrapper.checkSSHOverVsockAvailable(ctx, inst); err != nil {
122+
logrus.WithError(err).Info("Failed to detect SSH server on vsock port, falling back to usernet forwarder")
123+
} else if err := wrapper.startVsockForwarder(ctx, 22, hostAddress); err != nil {
124+
logrus.WithError(err).Info("Failed to start SSH server forwarder on vsock port, falling back to usernet forwarder")
126125
} else {
127-
logrus.WithError(err).Warn("Failed to wait for the guest SSH server to become available, falling back to usernet forwarder")
126+
logrus.Infof("Detected SSH server is listening on the vsock port; changed %s to proxy for the vsock port", hostAddress)
127+
usernetSSHLocalPort = 0 // disable gvisor ssh port forwarding
128128
}
129129
err := usernetClient.ConfigureDriver(ctx, inst, usernetSSHLocalPort)
130130
if err != nil {

pkg/driver/vz/vsock_forwarder.go

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,20 @@ import (
99
"context"
1010
"errors"
1111
"net"
12+
"path/filepath"
1213

1314
"github.com/containers/gvisor-tap-vsock/pkg/tcpproxy"
1415
"github.com/sirupsen/logrus"
16+
17+
"github.com/lima-vm/lima/v2/pkg/limatype"
18+
"github.com/lima-vm/lima/v2/pkg/limatype/dirnames"
19+
"github.com/lima-vm/lima/v2/pkg/limatype/filenames"
20+
"github.com/lima-vm/lima/v2/pkg/sshutil"
1521
)
1622

1723
func (m *virtualMachineWrapper) startVsockForwarder(ctx context.Context, vsockPort uint32, hostAddress string) error {
18-
// Test if the vsock port is open
19-
conn, err := m.dialVsock(ctx, vsockPort)
20-
if err != nil {
21-
return err
22-
}
23-
conn.Close()
2424
// Start listening on localhost:hostPort and forward to vsock:vsockPort
25-
_, _, err = net.SplitHostPort(hostAddress)
25+
_, _, err := net.SplitHostPort(hostAddress)
2626
if err != nil {
2727
return err
2828
}
@@ -73,3 +73,17 @@ func (m *virtualMachineWrapper) dialVsock(_ context.Context, port uint32) (conn
7373
}
7474
return nil, err
7575
}
76+
77+
func (m *virtualMachineWrapper) checkSSHOverVsockAvailable(ctx context.Context, inst *limatype.Instance) error {
78+
user := *inst.Config.User.Name
79+
configDir, err := dirnames.LimaConfigDir()
80+
if err != nil {
81+
return err
82+
}
83+
privateKeyPath := filepath.Join(configDir, filenames.UserPrivateKey)
84+
vsockPort := uint32(22)
85+
addr := "vsock:22"
86+
return sshutil.WaitSSHReady(ctx, func(ctx context.Context) (net.Conn, error) {
87+
return m.dialVsock(ctx, vsockPort)
88+
}, addr, user, privateKeyPath, 1)
89+
}

pkg/networks/usernet/client.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"net"
1212
"net/http"
1313
"os"
14+
"path/filepath"
1415
"strconv"
1516
"time"
1617

@@ -19,6 +20,8 @@ import (
1920

2021
"github.com/lima-vm/lima/v2/pkg/httpclientutil"
2122
"github.com/lima-vm/lima/v2/pkg/limatype"
23+
"github.com/lima-vm/lima/v2/pkg/limatype/dirnames"
24+
"github.com/lima-vm/lima/v2/pkg/limatype/filenames"
2225
"github.com/lima-vm/lima/v2/pkg/limayaml"
2326
"github.com/lima-vm/lima/v2/pkg/networks/usernet/dnshosts"
2427
)
@@ -140,8 +143,14 @@ func (c *Client) WaitOpeningSSHPort(ctx context.Context, inst *limatype.Instance
140143
if err != nil {
141144
return err
142145
}
146+
user := *inst.Config.User.Name
147+
configDir, err := dirnames.LimaConfigDir()
148+
if err != nil {
149+
return err
150+
}
151+
privateKeyPath := filepath.Join(configDir, filenames.UserPrivateKey)
143152
// -1 avoids both sides timing out simultaneously.
144-
u := fmt.Sprintf("%s/extension/wait_port?ip=%s&port=22&timeout=%d", c.base, ipAddr, timeoutSeconds-1)
153+
u := fmt.Sprintf("%s/extension/wait_ssh_server?ip=%s&port=22&timeout=%d&user=%s&privateKeyPath=%s", c.base, ipAddr, timeoutSeconds-1, user, privateKeyPath)
145154
res, err := httpclientutil.Get(ctx, c.client, u)
146155
if err != nil {
147156
return err

pkg/networks/usernet/gvproxy.go

Lines changed: 19 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import (
2222
"github.com/containers/gvisor-tap-vsock/pkg/virtualnetwork"
2323
"github.com/sirupsen/logrus"
2424
"golang.org/x/sync/errgroup"
25+
26+
"github.com/lima-vm/lima/v2/pkg/sshutil"
2527
)
2628

2729
type GVisorNetstackOpts struct {
@@ -243,7 +245,7 @@ func httpServe(ctx context.Context, g *errgroup.Group, ln net.Listener, mux http
243245

244246
func muxWithExtension(n *virtualnetwork.VirtualNetwork) *http.ServeMux {
245247
m := n.Mux()
246-
m.HandleFunc("/extension/wait_port", func(w http.ResponseWriter, r *http.Request) {
248+
m.HandleFunc("/extension/wait_ssh_server", func(w http.ResponseWriter, r *http.Request) {
247249
ip := r.URL.Query().Get("ip")
248250
if net.ParseIP(ip) == nil {
249251
msg := fmt.Sprintf("invalid ip address: %s", ip)
@@ -255,8 +257,15 @@ func muxWithExtension(n *virtualnetwork.VirtualNetwork) *http.ServeMux {
255257
http.Error(w, err.Error(), http.StatusBadRequest)
256258
return
257259
}
258-
port := uint16(port16)
259-
addr := fmt.Sprintf("%s:%d", ip, port)
260+
addr := net.JoinHostPort(ip, fmt.Sprintf("%d", uint16(port16)))
261+
262+
user := r.URL.Query().Get("user")
263+
privateKeyPath := r.URL.Query().Get("privateKeyPath")
264+
if user == "" || privateKeyPath == "" {
265+
msg := "user and privateKeyPath query parameters are required"
266+
http.Error(w, msg, http.StatusBadRequest)
267+
return
268+
}
260269

261270
timeoutSeconds := 10
262271
if timeoutString := r.URL.Query().Get("timeout"); timeoutString != "" {
@@ -267,27 +276,14 @@ func muxWithExtension(n *virtualnetwork.VirtualNetwork) *http.ServeMux {
267276
}
268277
timeoutSeconds = int(timeout16)
269278
}
270-
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSeconds)*time.Second)
271-
defer cancel()
279+
dialContext := func(ctx context.Context) (net.Conn, error) {
280+
return n.DialContextTCP(ctx, addr)
281+
}
272282
// Wait until the port is available.
273-
for {
274-
conn, err := n.DialContextTCP(ctx, addr)
275-
if err == nil {
276-
conn.Close()
277-
logrus.Debugf("Port is available on %s", addr)
278-
w.WriteHeader(http.StatusOK)
279-
break
280-
}
281-
select {
282-
case <-ctx.Done():
283-
msg := fmt.Sprintf("timed out waiting for port to become available on %s", addr)
284-
logrus.Warn(msg)
285-
http.Error(w, msg, http.StatusRequestTimeout)
286-
return
287-
default:
288-
}
289-
logrus.Debugf("Waiting for port to become available on %s", addr)
290-
time.Sleep(1 * time.Second)
283+
if err = sshutil.WaitSSHReady(r.Context(), dialContext, addr, user, privateKeyPath, timeoutSeconds); err != nil {
284+
http.Error(w, err.Error(), http.StatusRequestTimeout)
285+
} else {
286+
w.WriteHeader(http.StatusOK)
291287
}
292288
})
293289
return m

pkg/sshutil/sshutil.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"errors"
1212
"fmt"
1313
"io/fs"
14+
"net"
1415
"os"
1516
"os/exec"
1617
"path/filepath"
@@ -19,11 +20,13 @@ import (
1920
"slices"
2021
"strings"
2122
"sync"
23+
"syscall"
2224
"time"
2325

2426
"github.com/coreos/go-semver/semver"
2527
"github.com/mattn/go-shellwords"
2628
"github.com/sirupsen/logrus"
29+
"golang.org/x/crypto/ssh"
2730
"golang.org/x/sys/cpu"
2831

2932
"github.com/lima-vm/lima/v2/pkg/ioutilx"
@@ -509,3 +512,59 @@ func detectAESAcceleration() bool {
509512
}
510513
return cpu.ARM.HasAES || cpu.ARM64.HasAES || cpu.PPC64.IsPOWER8 || cpu.S390X.HasAES || cpu.X86.HasAES
511514
}
515+
516+
// WaitSSHReady waits until the SSH server is ready to accept connections.
517+
// The dialContext function is used to create a connection to the SSH server.
518+
// The addr, user, privateKeyPath parameter is used for ssh.ClientConn creation.
519+
// The timeoutSeconds parameter specifies the maximum number of seconds to wait.
520+
func WaitSSHReady(ctx context.Context, dialContext func(context.Context) (net.Conn, error), addr, user, privateKeyPath string, timeoutSeconds int) error {
521+
ctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSeconds)*time.Second)
522+
defer cancel()
523+
524+
// Prepare signer
525+
key, err := os.ReadFile(privateKeyPath)
526+
if err != nil {
527+
return fmt.Errorf("failed to read private key %q: %w", privateKeyPath, err)
528+
}
529+
signer, err := ssh.ParsePrivateKey(key)
530+
if err != nil {
531+
return fmt.Errorf("failed to parse private key %q: %w", privateKeyPath, err)
532+
}
533+
// Prepare ssh client config
534+
sshConfig := &ssh.ClientConfig{
535+
User: user,
536+
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
537+
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
538+
Timeout: 10 * time.Second,
539+
}
540+
541+
// Wait until the SSH server is available.
542+
for {
543+
conn, err := dialContext(ctx)
544+
if err == nil {
545+
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, sshConfig)
546+
if err == nil {
547+
sshClient := ssh.NewClient(sshConn, chans, reqs)
548+
return sshClient.Close()
549+
}
550+
conn.Close()
551+
if !isRetryableError(err) {
552+
return fmt.Errorf("failed to create ssh.Conn to %q: %w", addr, err)
553+
}
554+
}
555+
logrus.Debugf("Waiting for SSH port to accept connections on %s", addr)
556+
select {
557+
case <-ctx.Done():
558+
return fmt.Errorf("failed to waiting for SSH port to become available on %s: %w", addr, ctx.Err())
559+
case <-time.After(1 * time.Second):
560+
continue
561+
}
562+
}
563+
}
564+
565+
func isRetryableError(err error) bool {
566+
// Port forwarder accepted the connection, but the destination is not ready yet.
567+
return errors.Is(err, syscall.ECONNRESET) ||
568+
// SSH server not ready yet (e.g. host key not generated on initial boot).
569+
strings.HasSuffix(err.Error(), "no supported methods remain")
570+
}

0 commit comments

Comments
 (0)