Files
Jan Dubois 49c5471e28 copytool: support native Windows OpenSSH
On Windows, `limactl copy` converted host paths to the Cygwin/MSYS form
no matter which tool would consume them, and it passed ControlMaster
options unconditionally. Native OpenSSH cannot use that path form and
has no multiplexing, so scp failed with "getsockname failed".

The two backends need different path forms. scp takes whatever its own
toolchain uses, while rsync on Windows is always Cygwin-based and reads
a drive letter as a hostspec. So every path now takes the form of the
binary that reads it, operands and ssh options alike, because scp hands
its options to the ssh beside itself while rsync hands them to the ssh
it names.

Both backends now skip multiplexing on Windows, so they stop building
a control socket path they never use.

Signed-off-by: Jan Dubois <jan.dubois@suse.com>
2026-09-18 21:08:03 -07:00

797 lines
27 KiB
Go

// SPDX-FileCopyrightText: Copyright The Lima Authors
// SPDX-License-Identifier: Apache-2.0
package sshutil
import (
"bytes"
"context"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"io/fs"
"net"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
"sync"
"time"
"github.com/coreos/go-semver/semver"
"github.com/mattn/go-shellwords"
"github.com/sirupsen/logrus"
"golang.org/x/sys/cpu"
"github.com/lima-vm/lima/v2/pkg/fsutil"
"github.com/lima-vm/lima/v2/pkg/limatype/dirnames"
"github.com/lima-vm/lima/v2/pkg/limatype/filenames"
"github.com/lima-vm/lima/v2/pkg/lockutil"
"github.com/lima-vm/lima/v2/pkg/osutil"
)
// Environment variable that allows configuring the command (alias) to execute
// in place of the 'ssh' executable.
const EnvShellSSH = "SSH"
type SSHExe struct {
Exe string
Args []string
}
func NewSSHExe() (SSHExe, error) {
var sshExe SSHExe
if sshShell := os.Getenv(EnvShellSSH); sshShell != "" {
sshShellFields, err := shellwords.Parse(sshShell)
switch {
case err != nil:
logrus.WithError(err).Warnf("Failed to split %s variable into shell tokens. "+
"Falling back to 'ssh' command", EnvShellSSH)
case len(sshShellFields) > 0:
sshExe.Exe = sshShellFields[0]
if len(sshShellFields) > 1 {
sshExe.Args = sshShellFields[1:]
}
return sshExe, nil
}
}
if runtime.GOOS == "windows" {
if exe := pickCompleteSSHOnWindows(); exe != "" {
sshExe.Exe = exe
return sshExe, nil
}
}
executable, err := exec.LookPath("ssh")
if err != nil {
return SSHExe{}, err
}
sshExe.Exe = executable
return sshExe, nil
}
// pickCompleteSSHOnWindows returns an ssh.exe whose directory also has
// scp.exe and ssh-keygen.exe, which Lima needs for create and copy.
// MinGit ships ssh.exe without those, so a partial install is skipped for
// the next complete one on PATH, then the native install under
// %SystemRoot%\System32\OpenSSH. Returns "" when none qualifies, so the
// caller falls through to exec.LookPath instead of picking a partial one.
func pickCompleteSSHOnWindows() string {
for _, dir := range filepath.SplitList(os.Getenv("PATH")) {
if dir == "" {
continue
}
sshPath := filepath.Join(dir, "ssh.exe")
if _, err := os.Stat(sshPath); err != nil {
continue
}
if missing := missingSiblings(dir, "scp.exe", "ssh-keygen.exe"); len(missing) > 0 {
logrus.Debugf("skipping ssh at %#q: missing %v in same directory (likely MinGit or another partial install)", sshPath, missing)
continue
}
return sshPath
}
nativeDir := filepath.Join(systemRoot(), "System32", "OpenSSH")
nativeSSH := filepath.Join(nativeDir, "ssh.exe")
if _, err := os.Stat(nativeSSH); err == nil {
if missing := missingSiblings(nativeDir, "scp.exe", "ssh-keygen.exe"); len(missing) > 0 {
logrus.Debugf("skipping native ssh at %#q: missing %v in same directory", nativeSSH, missing)
return ""
}
return nativeSSH
}
return ""
}
// systemRoot returns %SystemRoot%, or C:\Windows when unset - honouring
// installs (Windows PE, some enterprise setups) that put it off the C: drive.
func systemRoot() string {
if r := os.Getenv("SystemRoot"); r != "" {
return r
}
return `C:\Windows`
}
// missingSiblings returns the names in siblings that do not exist in dir.
// siblings must be clean paths as they are joined with dir as-is.
func missingSiblings(dir string, siblings ...string) []string {
var missing []string
for _, s := range siblings {
if _, err := os.Stat(filepath.Join(dir, s)); err != nil {
missing = append(missing, s)
}
}
return missing
}
var (
// cygwinDetectCache maps a resolved tool path to its sibling cygpath.exe,
// or "" when that tool is not Cygwin-based. Each path is probed once.
cygwinDetectCache = map[string]string{}
cygwinDetectCacheRW sync.RWMutex
)
// resolvedToolPath returns the absolute, symlink-resolved path of toolExe's
// binary, whose directory Lima reads its companion tools from. It returns
// ("", false) when a bare name does not resolve on PATH.
func resolvedToolPath(toolExe SSHExe) (string, bool) {
if toolExe.Exe == "" {
return "", false
}
path := toolExe.Exe
if !filepath.IsAbs(path) {
resolved, err := exec.LookPath(path)
if err != nil {
logrus.WithError(err).Debugf("cannot resolve tool at %#q via PATH", path)
return "", false
}
path = resolved
}
if resolved, err := filepath.EvalSymlinks(path); err == nil {
path = resolved
}
return path, true
}
// CompanionForSSH returns tool from the same toolchain as sshExe. NewSSHExe can
// select an ssh outside PATH, so resolving a companion by bare name can pick a
// different toolchain than the one PathForSSH formats paths for. It returns tool
// unchanged on non-Windows, or when no sibling exists, leaving it to PATH.
func CompanionForSSH(sshExe SSHExe, tool string) string {
if runtime.GOOS != "windows" {
return tool
}
path, ok := resolvedToolPath(sshExe)
if !ok {
return tool
}
companion := filepath.Join(filepath.Dir(path), tool+".exe")
if _, err := os.Stat(companion); err != nil {
logrus.Debugf("no %#q beside ssh at %#q; resolving %#q from PATH", companion, path, tool)
return tool
}
return companion
}
// cygpathForSSH returns the cygpath.exe beside toolExe (resolved through
// symlinks) and whether toolExe is Cygwin-based. PathForTool passes non-ssh
// binaries here too. Callers pass the returned path to exec.Command so
// conversions run through that toolchain's own cygpath, even when $SSH points
// outside PATH. It returns ("", false) on non-Windows or empty input; results
// are cached per resolved path.
func cygpathForSSH(toolExe SSHExe) (string, bool) {
if runtime.GOOS != "windows" {
return "", false
}
path, ok := resolvedToolPath(toolExe)
if !ok {
return "", false
}
cygwinDetectCacheRW.RLock()
cached, ok := cygwinDetectCache[path]
cygwinDetectCacheRW.RUnlock()
if ok {
return cached, cached != ""
}
cygpathExe := filepath.Join(filepath.Dir(path), "cygpath.exe")
if _, err := os.Stat(cygpathExe); err != nil {
logrus.Debugf("tool at %#q detected as native Windows OpenSSH (no cygpath.exe alongside)", path)
cygpathExe = ""
} else {
logrus.Debugf("tool at %#q detected as Cygwin-based (found %#q alongside)", path, cygpathExe)
}
cygwinDetectCacheRW.Lock()
cygwinDetectCache[path] = cygpathExe
cygwinDetectCacheRW.Unlock()
return cygpathExe, cygpathExe != ""
}
// PathForSSH converts orig to the path form Lima's ssh-family invocations
// expect; unchanged on non-Windows. On Windows, Cygwin-based ssh (Git for
// Windows, MSYS2) gets a /c/Users/... form from the sibling cygpath, which
// respects the toolchain's fstab; native Windows OpenSSH gets forward
// slashes (C:/Users/...), which native ssh, ssh-keygen, and scp accept.
// It is a convenience wrapper for callers holding an SSHExe; PathForTool takes
// the binary's path directly.
func PathForSSH(ctx context.Context, sshExe SSHExe, orig string) (string, error) {
return PathForTool(ctx, sshExe.Exe, orig)
}
// PathForTool is PathForSSH keyed to an arbitrary binary, for a tool that Lima
// resolves separately from ssh. Pass the tool's own path so the form follows
// the toolchain that reads it, whether the tool parses the path itself or hands
// it to the ssh beside it. Callers whose tool is Cygwin-based on every
// Windows host should convert through cygpath directly instead: the native form
// this returns as a fallback would be wrong for them.
func PathForTool(ctx context.Context, toolPath, orig string) (string, error) {
if runtime.GOOS != "windows" {
return orig, nil
}
if cygpathExe, ok := cygpathForSSH(SSHExe{Exe: toolPath}); ok {
return fsutil.WindowsSubsystemPathWithCygpath(ctx, cygpathExe, orig)
}
return filepath.ToSlash(orig), nil
}
// SftpServerForSSH returns the path of sftp-server binary from sshExe's toolchain,
// so the server behind a reverse-sshfs mount comes from the same install as the
// ssh driving it. For Cygwin-based ssh the sibling cygpath resolves
// /usr/lib/ssh/sftp-server; for native OpenSSH it is sftp-server.exe beside
// ssh.exe. Returns "" on non-Windows, empty input, or no match, leaving the
// caller to fall back to its library's auto-detection.
func SftpServerForSSH(ctx context.Context, sshExe SSHExe) string {
if runtime.GOOS != "windows" || sshExe.Exe == "" {
return ""
}
if cygpathExe, ok := cygpathForSSH(sshExe); ok {
out, err := exec.CommandContext(ctx, cygpathExe, "-w", "/usr/lib/ssh/sftp-server").Output()
if err != nil {
return ""
}
// Validate with LookPath, which appends the ".exe" that cygpath omits.
// Under the default driver sshocker resolves this path with LookPath as
// well and fails the mount when it does not resolve, so returning
// nothing beats returning a path it will reject.
candidate := strings.TrimSpace(string(out))
sftpServer, err := exec.LookPath(candidate)
if err != nil {
logrus.WithError(err).Debugf("cannot use the sftp-server at %#q that cygpath resolved", candidate)
return ""
}
return sftpServer
}
path, ok := resolvedToolPath(sshExe)
if !ok {
return ""
}
sftpServer, err := exec.LookPath(filepath.Join(filepath.Dir(path), "sftp-server.exe"))
if err != nil {
return ""
}
return sftpServer
}
type PubKey struct {
Filename string
Content string
}
func readPublicKey(f string) (PubKey, error) {
entry := PubKey{
Filename: f,
}
content, err := os.ReadFile(f)
if err == nil {
entry.Content = strings.TrimSpace(string(content))
} else {
err = fmt.Errorf("failed to read ssh public key %#q: %w", f, err)
}
return entry, err
}
// DefaultPubKeys returns the public key from $LIMA_HOME/_config/user.pub.
// The key will be created if it does not yet exist.
//
// When loadDotSSH is true, ~/.ssh/*.pub will be appended to make the VM accessible without specifying
// an identity explicitly.
func DefaultPubKeys(ctx context.Context, loadDotSSH bool) ([]PubKey, error) {
// Read $LIMA_HOME/_config/user.pub
configDir, err := dirnames.LimaConfigDir()
if err != nil {
return nil, err
}
_, err = os.Stat(filepath.Join(configDir, filenames.UserPrivateKey))
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
if err := os.MkdirAll(configDir, 0o700); err != nil {
return nil, fmt.Errorf("could not create %#q directory: %w", configDir, err)
}
if err := lockutil.WithDirLock(configDir, func() error {
// no passphrase, no user@host comment
privPath := filepath.Join(configDir, filenames.UserPrivateKey)
keygenExe := "ssh-keygen"
if runtime.GOOS == "windows" {
sshExe, sshErr := NewSSHExe()
if sshErr != nil {
return sshErr
}
keygenExe = CompanionForSSH(sshExe, "ssh-keygen")
// ssh-keygen parses this path, and it falls back to PATH when
// the selected ssh ships none, so key the form to keygenExe.
privPath, err = PathForTool(ctx, keygenExe, privPath)
if err != nil {
return err
}
}
keygenCmd := exec.CommandContext(ctx, keygenExe, "-t", "ed25519", "-q", "-N", "",
"-C", "lima", "-f", privPath)
logrus.Debugf("executing %v", keygenCmd.Args)
if out, err := keygenCmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to run %v: %#q: %w", keygenCmd.Args, string(out), err)
}
return nil
}); err != nil {
return nil, err
}
}
entry, err := readPublicKey(filepath.Join(configDir, filenames.UserPublicKey))
if err != nil {
return nil, err
}
res := []PubKey{entry}
if !loadDotSSH {
return res, nil
}
// Append all of ~/.ssh/*.pub
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, err
}
files, err := filepath.Glob(filepath.Join(homeDir, ".ssh/*.pub"))
if err != nil {
panic(err) // Only possible error is ErrBadPattern, so this should be unreachable.
}
for _, f := range files {
if !strings.HasSuffix(f, ".pub") {
panic(fmt.Errorf("unexpected ssh public key filename %#q", f))
}
entry, err := readPublicKey(f)
if err == nil {
if !detectValidPublicKey(entry.Content) {
logrus.Warnf("public key %#q doesn't seem to be in ssh format", entry.Filename)
} else {
res = append(res, entry)
}
} else if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
}
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
// OpenSSH executable information for the version and supported options.
openSSH openSSHInfo
}
// CommonOpts returns ssh option key-value pairs like {"IdentityFile=/path/to/id_foo"}.
// The result may contain different values with the same key.
//
// The result always contains the IdentityFile option.
// The result never contains the Port option.
//
// Path options take toolPath's form, for the binary that ends up reading them.
// scp hands its options to the ssh beside itself, which need not be sshExe.
// Version detection still runs against sshExe, since only ssh reports a version.
func CommonOpts(ctx context.Context, sshExe SSHExe, toolPath string, useDotSSH bool) ([]string, error) {
configDir, err := dirnames.LimaConfigDir()
if err != nil {
return nil, err
}
privateKeyPath := filepath.Join(configDir, filenames.UserPrivateKey)
_, err = os.Stat(privateKeyPath)
if err != nil {
return nil, err
}
var opts []string
idf, err := identityFileEntry(ctx, toolPath, privateKeyPath)
if err != nil {
return nil, err
}
opts = []string{idf}
// Append all private keys corresponding to ~/.ssh/*.pub to keep old instances working
// that had been created before lima started using an internal identity.
if useDotSSH {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, err
}
files, err := filepath.Glob(filepath.Join(homeDir, ".ssh/*.pub"))
if err != nil {
panic(err) // Only possible error is ErrBadPattern, so this should be unreachable.
}
for _, f := range files {
if !strings.HasSuffix(f, ".pub") {
panic(fmt.Errorf("unexpected ssh public key filename %#q", f))
}
privateKeyPath := strings.TrimSuffix(f, ".pub")
_, err = os.Stat(privateKeyPath)
if errors.Is(err, fs.ErrNotExist) {
// Skip .pub files without a matching private key. This is reasonably common,
// due to major projects like Vault recommending the ${name}-cert.pub format
// for SSH certificate files.
//
// e.g. https://www.vaultproject.io/docs/secrets/ssh/signed-ssh-certificates
continue
}
if err != nil {
// Fail on permission-related and other path errors
return nil, err
}
idf, err = identityFileEntry(ctx, toolPath, privateKeyPath)
if err != nil {
return nil, err
}
opts = append(opts, idf)
}
}
opts = append(opts,
"StrictHostKeyChecking=no",
"UserKnownHostsFile=/dev/null",
"NoHostAuthenticationForLocalhost=yes",
"PreferredAuthentications=publickey",
"Compression=no",
"BatchMode=yes",
"IdentitiesOnly=yes",
)
sshInfo.Do(func() {
sshInfo.aesAccelerated = detectAESAcceleration()
sshInfo.openSSH = detectOpenSSHInfo(ctx, sshExe)
})
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.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)
//
// We prioritize AES algorithms when AES accelerator is available.
if sshInfo.aesAccelerated {
logrus.Debugf("AES accelerator seems available, prioritizing aes128-gcm@openssh.com and aes256-gcm@openssh.com")
if runtime.GOOS == "windows" {
opts = append(opts, "Ciphers=^aes128-gcm@openssh.com,aes256-gcm@openssh.com")
} else {
opts = append(opts, "Ciphers=\"^aes128-gcm@openssh.com,aes256-gcm@openssh.com\"")
}
} else {
logrus.Debugf("AES accelerator does not seem available, prioritizing chacha20-poly1305@openssh.com")
if runtime.GOOS == "windows" {
opts = append(opts, "Ciphers=^chacha20-poly1305@openssh.com")
} else {
opts = append(opts, "Ciphers=\"^chacha20-poly1305@openssh.com\"")
}
}
}
return opts, nil
}
func identityFileEntry(ctx context.Context, toolPath, privateKeyPath string) (string, error) {
if runtime.GOOS == "windows" {
privateKeyPath, err := PathForTool(ctx, toolPath, privateKeyPath)
if err != nil {
return "", err
}
return fmt.Sprintf(`IdentityFile='%s'`, privateKeyPath), nil
}
return fmt.Sprintf(`IdentityFile="%s"`, privateKeyPath), nil
}
// DisableControlMasterOptsFromSSHArgs returns ssh args that disable ControlMaster, ControlPath, and ControlPersist.
func DisableControlMasterOptsFromSSHArgs(sshArgs []string) []string {
argsForOverridingConfigFile := []string{
"-o", "ControlMaster=no",
"-o", "ControlPath=none",
"-o", "ControlPersist=no",
}
return slices.Concat(argsForOverridingConfigFile, removeOptsFromSSHArgs(sshArgs, "ControlMaster", "ControlPath", "ControlPersist"))
}
func removeOptsFromSSHArgs(sshArgs []string, removeOpts ...string) []string {
res := make([]string, 0, len(sshArgs))
isOpt := false
for _, arg := range sshArgs {
if isOpt {
isOpt = false
if !slices.ContainsFunc(removeOpts, func(opt string) bool {
return strings.HasPrefix(arg, opt)
}) {
res = append(res, "-o", arg)
}
} else if arg == "-o" {
isOpt = true
} else {
res = append(res, arg)
}
}
return res
}
// IsControlMasterExisting returns true if the control socket file exists.
func IsControlMasterExisting(instDir string) bool {
controlSock := filepath.Join(instDir, filenames.SSHSock)
_, err := os.Stat(controlSock)
return err == nil
}
// IsControlMasterRunning reports whether an SSH ControlMaster process is
// actively listening on the instance's control socket. Unlike
// IsControlMasterExisting, which only checks that the socket file is present,
// this dials the socket to verify that a master is alive. A stale socket left
// behind by an unclean master exit (kill -9, OOM, host crash, or the Cygwin
// emulation file outliving its process) has no listener and returns false.
func IsControlMasterRunning(ctx context.Context, instDir string) bool {
controlSock := filepath.Join(instDir, filenames.SSHSock)
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
var dialer net.Dialer
conn, err := dialer.DialContext(ctx, "unix", controlSock)
if err != nil {
return false
}
_ = conn.Close()
return true
}
// RemoveStaleControlMaster removes the SSH control socket only when no master
// process is listening on it. It returns true when a stale socket was removed.
// A live master's socket is never touched (returns false), and a missing socket
// is treated as a no-op (returns false). This lets callers recover from a wedged
// session without clobbering a healthy ControlMaster.
func RemoveStaleControlMaster(ctx context.Context, instDir string) (bool, error) {
if IsControlMasterRunning(ctx, instDir) {
return false, nil
}
existed := IsControlMasterExisting(instDir)
controlSock := filepath.Join(instDir, filenames.SSHSock)
if err := os.RemoveAll(controlSock); err != nil {
return false, err
}
return existed, nil
}
// SSHOpts adds the following options to CommonOpts: User, ControlMaster,
// ControlPath, ControlPersist, and whichever of ForwardAgent, ForwardX11, and
// ForwardX11Trusted the caller asks for.
func SSHOpts(ctx context.Context, sshExe SSHExe, instDir, username string, useDotSSH, forwardAgent, forwardX11, forwardX11Trusted bool) ([]string, error) {
controlSock := filepath.Join(instDir, filenames.SSHSock)
if len(controlSock) >= osutil.UnixPathMax {
return nil, fmt.Errorf("socket path %#q is too long: >= UNIX_PATH_MAX=%d", controlSock, osutil.UnixPathMax)
}
opts, err := SSHOptsWithoutMultiplexing(ctx, sshExe, sshExe.Exe, username, useDotSSH)
if err != nil {
return nil, err
}
controlPath := fmt.Sprintf(`ControlPath="%s"`, controlSock)
if runtime.GOOS == "windows" {
controlSock, err = PathForSSH(ctx, sshExe, controlSock)
if err != nil {
return nil, err
}
controlPath = fmt.Sprintf(`ControlPath='%s'`, controlSock)
}
opts = append(opts,
"ControlMaster=auto",
controlPath,
"ControlPersist=yes",
)
if forwardAgent {
opts = append(opts, "ForwardAgent=yes")
}
if forwardX11 {
opts = append(opts, "ForwardX11=yes")
}
if forwardX11Trusted {
opts = append(opts, "ForwardX11Trusted=yes")
}
return opts, nil
}
// SSHOptsWithoutMultiplexing returns CommonOpts plus an explicit User, adding
// neither multiplexing nor forwarding options. Use it for invocations that
// cannot share a control socket: native Windows OpenSSH has no multiplexing,
// and Cygwin ssh's is unreliable. It builds no control path, so the caller also
// escapes the socket length limit SSHOpts enforces.
// Path options take toolPath's form; pass sshExe.Exe unless another binary
// receives the options, as described on CommonOpts.
func SSHOptsWithoutMultiplexing(ctx context.Context, sshExe SSHExe, toolPath, username string, useDotSSH bool) ([]string, error) {
opts, err := CommonOpts(ctx, sshExe, toolPath, useDotSSH)
if err != nil {
return nil, err
}
// guest and host have the same username, but we should specify the username explicitly (#85)
return append(opts, fmt.Sprintf("User=%s", username)), nil
}
// SSHArgsFromOpts returns ssh args from opts.
// The result always contains {"-F", "/dev/null} in addition to {"-o", "KEY=VALUE", ...}.
func SSHArgsFromOpts(opts []string) []string {
args := []string{"-F", "/dev/null"}
for _, o := range opts {
args = append(args, "-o", o)
}
return args
}
// SSHOptsRemovingControlPath removes ControlMaster, ControlPath, and ControlPersist options from SSH options.
func SSHOptsRemovingControlPath(opts []string) []string {
// Create a copy of opts to avoid modifying the original slice, since slices.DeleteFunc modifies the slice in place.
copiedOpts := slices.Clone(opts)
return slices.DeleteFunc(copiedOpts, func(s string) bool {
return strings.HasPrefix(s, "ControlMaster") || strings.HasPrefix(s, "ControlPath") || strings.HasPrefix(s, "ControlPersist")
})
}
func ParseOpenSSHVersion(version []byte) *semver.Version {
// Win32-OpenSSH banners: "OpenSSH_for_Windows_9.5p2", and older releases
// with a space before the version.
regex := regexp.MustCompile(`(?m)^OpenSSH(?:_for_Windows[_ ]|_)(\d+\.\d+)(?:p(\d+))?\b`)
matches := regex.FindSubmatch(version)
if len(matches) == 3 {
if len(matches[2]) == 0 {
matches[2] = []byte("0")
}
return semver.New(fmt.Sprintf("%s.%s", matches[1], matches[2]))
}
// A 0.0.0 return silently downgrades version-gated behavior, so log the
// unparsed banner for --debug traces.
logrus.Debugf("ParseOpenSSHVersion: no match in %q; returning 0.0.0", string(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
Size int64
ModTime time.Time
}
var (
// 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 detectOpenSSHInfo(ctx context.Context, sshExe SSHExe) openSSHInfo {
var (
info openSSHInfo
exe sshExecutable
stderr bytes.Buffer
)
// Note: For SSH wrappers like "kitten ssh", os.Stat will check the wrapper
// executable (kitten) instead of the underlying ssh binary. This means
// cache invalidation won't work properly - ssh upgrades won't be detected
// since kitten's size/mtime won't change. This is probably acceptable.
if st, err := os.Stat(sshExe.Exe); err == nil {
exe = sshExecutable{Path: sshExe.Exe, Size: st.Size(), ModTime: st.ModTime()}
openSSHInfosRW.RLock()
info := openSSHInfos[exe]
openSSHInfosRW.RUnlock()
if info != nil {
return *info
}
}
sshArgs := append([]string{}, sshExe.Args...)
// -V should be last
sshArgs = append(sshArgs, "-o", "GSSAPIAuthentication=no", "-V")
cmd := exec.CommandContext(ctx, sshExe.Exe, sshArgs...)
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
logrus.Warnf("failed to run %v: stderr=%#q", cmd.Args, stderr.String())
} else {
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 info
}
func DetectOpenSSHVersion(ctx context.Context, sshExe SSHExe) semver.Version {
return detectOpenSSHInfo(ctx, sshExe).Version
}
// detectValidPublicKey returns whether content represent a public key.
// OpenSSH public key format have the structure of '<algorithm> <key> <comment>'.
// By checking 'algorithm' with signature format identifier in 'key' part,
// this function may report false positive but provide better compatibility.
func detectValidPublicKey(content string) bool {
if strings.ContainsRune(content, '\n') {
return false
}
spaced := strings.SplitN(content, " ", 3)
if len(spaced) < 2 {
return false
}
algo, base64Key := spaced[0], spaced[1]
decodedKey, err := base64.StdEncoding.DecodeString(base64Key)
if err != nil || len(decodedKey) < 4 {
return false
}
sigLength := binary.BigEndian.Uint32(decodedKey)
// The format identifier occupies decodedKey[4 : 4+sigLength], so the buffer
// must hold sigLength bytes beyond the 4-byte length prefix, not just
// sigLength bytes in total. len(decodedKey) >= 4 is guaranteed above, so the
// subtraction cannot underflow.
if sigLength > uint32(len(decodedKey))-4 {
return false
}
sigFormat := string(decodedKey[4 : 4+sigLength])
return algo == sigFormat
}
func detectAESAcceleration() bool {
if !cpu.Initialized {
if runtime.GOOS == "linux" && runtime.GOARCH == "arm64" {
// cpu.Initialized seems to always be false, even when the cpu.ARM64 struct is filled out
// it is only being set by readARM64Registers, but not by readHWCAP or readLinuxProcCPUInfo
return cpu.ARM64.HasAES
}
if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
// golang.org/x/sys/cpu supports darwin/amd64, linux/amd64, and linux/arm64,
// but apparently lacks support for darwin/arm64: https://github.com/golang/sys/blob/v0.5.0/cpu/cpu_arm64.go#L43-L60
//
// According to https://gist.github.com/voluntas/fd279c7b4e71f9950cfd4a5ab90b722b ,
// aes-128-gcm is faster than chacha20-poly1305 on Apple M1.
//
// So we return `true` here.
//
// This workaround will not be needed when https://go-review.googlesource.com/c/sys/+/332729 is merged.
logrus.Debug("Failed to detect CPU features. Assuming that AES acceleration is available on this Apple silicon.")
return true
}
logrus.Warn("Failed to detect CPU features. Assuming that AES acceleration is not available.")
return false
}
return cpu.ARM.HasAES || cpu.ARM64.HasAES || cpu.PPC64.IsPOWER8 || cpu.S390X.HasAES || cpu.X86.HasAES
}