Repository: lima-vm/lima (go)

Issue:
Automatically detect if GSSAPIAuthentication is available or not
### Description

Some hosts (e.g. NixOS) don't provide an `ssh` with `GSSAPIAuthentication` available.  (And setting `GSSAPIAuthentication=no` will return an error/warning)

Lima should detect if it is available before trying to use it. See: https://github.com/lima-vm/lima/issues/3151#issuecomment-2638389458 


Pull request description:
sshutil: suppress warnings when SSH lacks GSSAPI
Background
---

Starting with NixOS 24.11, NixOS and nixpkgs no longer enable GSSAPI support in the openssh package by default.
See:
- https://github.com/NixOS/nixpkgs/pull/302688
- https://github.com/NixOS/nixpkgs/blob/df7eb4294b7081e4b828d31f4d77971b4ca85a64/nixos/doc/manual/release-notes/rl-2411.section.md?plain=1#L316-L317
Because of this change, I am experiencing the same issue described in the Alpine Linux issue mentioned https://github.com/lima-vm/lima/issues/3151.

Fixes #3198

How
---

The `ssh -G nothing` approach requires generating a random string and making an additional call to an external ssh command.
Instead, I combined this change into the current `ssh -V` implementation. Is this approach acceptable?

Patch:
diff --git a/pkg/sshutil/sshutil.go b/pkg/sshutil/sshutil.go
index 81e81e37..fa5370a4 100644
--- a/pkg/sshutil/sshutil.go
+++ b/pkg/sshutil/sshutil.go
@@ -154,13 +154,22 @@ func DefaultPubKeys(loadDotSSH bool) ([]PubKey, error) {
 	return res, nil
 }
 
+type openSSHInfo struct {
+	// Version is set to the version of OpenSSH, or semver.New("0.0.0") if the version cannot be determined.
+	Version semver.Version
+
+	// Some distributions omit this feature by default, for example, Alpine, NixOS.
+	GSSAPISupported bool
+}
+
 var sshInfo struct {
 	sync.Once
 	// aesAccelerated is set to true when AES acceleration is available.
 	// Available on almost all modern Intel/AMD processors.
 	aesAccelerated bool
-	// openSSHVersion is set to the version of OpenSSH, or semver.New("0.0.0") if the version cannot be determined.
-	openSSHVersion semver.Version
+
+	// OpenSSH executable information for the version and supported options.
+	openSSH openSSHInfo
 }
 
 // CommonOpts returns ssh option key-value pairs like {"IdentityFile=/path/to/id_foo"}.
@@ -226,7 +235,6 @@ func CommonOpts(sshPath string, useDotSSH bool) ([]string, error) {
 		"StrictHostKeyChecking=no",
 		"UserKnownHostsFile=/dev/null",
 		"NoHostAuthenticationForLocalhost=yes",
-		"GSSAPIAuthentication=no",
 		"PreferredAuthentications=publickey",
 		"Compression=no",
 		"BatchMode=yes",
@@ -235,11 +243,15 @@ func CommonOpts(sshPath string, useDotSSH bool) ([]string, error) {
 
 	sshInfo.Do(func() {
 		sshInfo.aesAccelerated = detectAESAcceleration()
-		sshInfo.openSSHVersion = DetectOpenSSHVersion(sshPath)
+		sshInfo.openSSH = detectOpenSSHInfo(sshPath)
 	})
 
+	if sshInfo.openSSH.GSSAPISupported {
+		opts = append(opts, "GSSAPIAuthentication=no")
+	}
+
 	// Only OpenSSH version 8.1 and later support adding ciphers to the front of the default set
-	if !sshInfo.openSSHVersion.LessThan(*semver.New("8.1.0")) {
+	if !sshInfo.openSSH.Version.LessThan(*semver.New("8.1.0")) {
 		// By default, `ssh` choose chacha20-poly1305@openssh.com, even when AES accelerator is available.
 		// (OpenSSH_8.1p1, macOS 11.6, MacBookPro 2020, Core i7-1068NG7)
 		//
@@ -321,7 +333,7 @@ func SSHArgsFromOpts(opts []string) []string {
 }
 
 func ParseOpenSSHVersion(version []byte) *semver.Version {
-	regex := regexp.MustCompile(`^OpenSSH_(\d+\.\d+)(?:p(\d+))?\b`)
+	regex := regexp.MustCompile(`(?m)^OpenSSH_(\d+\.\d+)(?:p(\d+))?\b`)
 	matches := regex.FindSubmatch(version)
 	if len(matches) == 3 {
 		if len(matches[2]) == 0 {
@@ -332,6 +344,10 @@ func ParseOpenSSHVersion(version []byte) *semver.Version {
 	return &semver.Version{}
 }
 
+func parseOpenSSHGSSAPISupported(version string) bool {
+	return !strings.Contains(version, `Unsupported option "gssapiauthentication"`)
+}
+
 // sshExecutable beyond path also records size and mtime, in the case of ssh upgrades.
 type sshExecutable struct {
 	Path    string
@@ -340,14 +356,14 @@ type sshExecutable struct {
 }
 
 var (
-	// sshVersions caches the parsed version of each ssh executable, if it is needed again.
-	sshVersions   = map[sshExecutable]*semver.Version{}
-	sshVersionsRW sync.RWMutex
+	// openSSHInfos caches the parsed version and supported options of each ssh executable, if it is needed again.
+	openSSHInfos   = map[sshExecutable]*openSSHInfo{}
+	openSSHInfosRW sync.RWMutex
 )
 
-func DetectOpenSSHVersion(ssh string) semver.Version {
+func detectOpenSSHInfo(ssh string) openSSHInfo {
 	var (
-		v      semver.Version
+		info   openSSHInfo
 		exe    sshExecutable
 		stderr bytes.Buffer
 	)
@@ -357,25 +373,33 @@ func DetectOpenSSHVersion(ssh string) semver.Version {
 	} else {
 		st, _ := os.Stat(path)
 		exe = sshExecutable{Path: path, Size: st.Size(), ModTime: st.ModTime()}
-		sshVersionsRW.RLock()
-		ver := sshVersions[exe]
-		sshVersionsRW.RUnlock()
-		if ver != nil {
-			return *ver
+		openSSHInfosRW.RLock()
+		info := openSSHInfos[exe]
+		openSSHInfosRW.RUnlock()
+		if info != nil {
+			return *info
 		}
 	}
-	cmd := exec.Command(path, "-V")
+	// -V should be last
+	cmd := exec.Command(path, "-o", "GSSAPIAuthentication=no", "-V")
 	cmd.Stderr = &stderr
 	if err := cmd.Run(); err != nil {
 		logrus.Warnf("failed to run %v: stderr=%q", cmd.Args, stderr.String())
 	} else {
-		v = *ParseOpenSSHVersion(stderr.Bytes())
-		logrus.Debugf("OpenSSH version %s detected", v)
-		sshVersionsRW.Lock()
-		sshVersions[exe] = &v
-		sshVersionsRW.Unlock()
+		info = openSSHInfo{
+			Version:         *ParseOpenSSHVersion(stderr.Bytes()),
+			GSSAPISupported: parseOpenSSHGSSAPISupported(stderr.String()),
+		}
+		logrus.Debugf("OpenSSH version %s detected, is GSSAPI supported: %t", info.Version, info.GSSAPISupported)
+		openSSHInfosRW.Lock()
+		openSSHInfos[exe] = &info
+		openSSHInfosRW.Unlock()
 	}
-	return v
+	return info
+}
+
+func DetectOpenSSHVersion(ssh string) semver.Version {
+	return detectOpenSSHInfo(ssh).Version
 }
 
 // detectValidPublicKey returns whether content represent a public key.


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index c3670fd3..071c9984 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -218,7 +218,7 @@ jobs:
       with:
         # To avoid "can't parse builtin Lima version" errors
         fetch-depth: 0
-    - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
+    - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b  # v5.4.0
       with:
         go-version: 1.24.x
     - name: Unit tests
diff --git a/pkg/sshutil/sshutil_test.go b/pkg/sshutil/sshutil_test.go
index 972c3037..4b8b81cc 100644
--- a/pkg/sshutil/sshutil_test.go
+++ b/pkg/sshutil/sshutil_test.go
@@ -29,6 +29,16 @@ func TestParseOpenSSHVersion(t *testing.T) {
 
 	// OpenBSD 5.8
 	assert.Check(t, ParseOpenSSHVersion([]byte("OpenSSH_7.0, LibreSSL")).Equal(*semver.New("7.0.0")))
+
+	// NixOS 25.05
+	assert.Check(t, ParseOpenSSHVersion([]byte(`command-line line 0: Unsupported option "gssapiauthentication"
+OpenSSH_10.0p2, OpenSSL 3.4.1 11 Feb 2025`)).Equal(*semver.New("10.0.2")))
+}
+
+func TestParseOpenSSHGSSAPISupported(t *testing.T) {
+	assert.Check(t, parseOpenSSHGSSAPISupported("OpenSSH_8.4p1 Ubuntu"))
+	assert.Check(t, !parseOpenSSHGSSAPISupported(`command-line line 0: Unsupported option "gssapiauthentication"
+OpenSSH_10.0p2, OpenSSL 3.4.1 11 Feb 2025`))
 }
 
 func Test_detectValidPublicKey(t *testing.T) {