Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
lima-vm
GitHub Repository: lima-vm/lima
Path: blob/master/pkg/sshutil/sshutil.go
2611 views
1
// SPDX-FileCopyrightText: Copyright The Lima Authors
2
// SPDX-License-Identifier: Apache-2.0
3
4
package sshutil
5
6
import (
7
"bytes"
8
"context"
9
"encoding/base64"
10
"encoding/binary"
11
"errors"
12
"fmt"
13
"io/fs"
14
"os"
15
"os/exec"
16
"path/filepath"
17
"regexp"
18
"runtime"
19
"slices"
20
"strings"
21
"sync"
22
"time"
23
24
"github.com/coreos/go-semver/semver"
25
"github.com/mattn/go-shellwords"
26
"github.com/sirupsen/logrus"
27
"golang.org/x/sys/cpu"
28
29
"github.com/lima-vm/lima/v2/pkg/ioutilx"
30
"github.com/lima-vm/lima/v2/pkg/limatype/dirnames"
31
"github.com/lima-vm/lima/v2/pkg/limatype/filenames"
32
"github.com/lima-vm/lima/v2/pkg/lockutil"
33
"github.com/lima-vm/lima/v2/pkg/osutil"
34
)
35
36
// Environment variable that allows configuring the command (alias) to execute
37
// in place of the 'ssh' executable.
38
const EnvShellSSH = "SSH"
39
40
type SSHExe struct {
41
Exe string
42
Args []string
43
}
44
45
func NewSSHExe() (SSHExe, error) {
46
var sshExe SSHExe
47
48
if sshShell := os.Getenv(EnvShellSSH); sshShell != "" {
49
sshShellFields, err := shellwords.Parse(sshShell)
50
switch {
51
case err != nil:
52
logrus.WithError(err).Warnf("Failed to split %s variable into shell tokens. "+
53
"Falling back to 'ssh' command", EnvShellSSH)
54
case len(sshShellFields) > 0:
55
sshExe.Exe = sshShellFields[0]
56
if len(sshShellFields) > 1 {
57
sshExe.Args = sshShellFields[1:]
58
}
59
return sshExe, nil
60
}
61
}
62
63
executable, err := exec.LookPath("ssh")
64
if err != nil {
65
return SSHExe{}, err
66
}
67
sshExe.Exe = executable
68
69
return sshExe, nil
70
}
71
72
type PubKey struct {
73
Filename string
74
Content string
75
}
76
77
func readPublicKey(f string) (PubKey, error) {
78
entry := PubKey{
79
Filename: f,
80
}
81
content, err := os.ReadFile(f)
82
if err == nil {
83
entry.Content = strings.TrimSpace(string(content))
84
} else {
85
err = fmt.Errorf("failed to read ssh public key %q: %w", f, err)
86
}
87
return entry, err
88
}
89
90
// DefaultPubKeys returns the public key from $LIMA_HOME/_config/user.pub.
91
// The key will be created if it does not yet exist.
92
//
93
// When loadDotSSH is true, ~/.ssh/*.pub will be appended to make the VM accessible without specifying
94
// an identity explicitly.
95
func DefaultPubKeys(ctx context.Context, loadDotSSH bool) ([]PubKey, error) {
96
// Read $LIMA_HOME/_config/user.pub
97
configDir, err := dirnames.LimaConfigDir()
98
if err != nil {
99
return nil, err
100
}
101
_, err = os.Stat(filepath.Join(configDir, filenames.UserPrivateKey))
102
if err != nil {
103
if !errors.Is(err, os.ErrNotExist) {
104
return nil, err
105
}
106
if err := os.MkdirAll(configDir, 0o700); err != nil {
107
return nil, fmt.Errorf("could not create %q directory: %w", configDir, err)
108
}
109
if err := lockutil.WithDirLock(configDir, func() error {
110
// no passphrase, no user@host comment
111
privPath := filepath.Join(configDir, filenames.UserPrivateKey)
112
if runtime.GOOS == "windows" {
113
privPath, err = ioutilx.WindowsSubsystemPath(ctx, privPath)
114
if err != nil {
115
return err
116
}
117
}
118
keygenCmd := exec.CommandContext(ctx, "ssh-keygen", "-t", "ed25519", "-q", "-N", "",
119
"-C", "lima", "-f", privPath)
120
logrus.Debugf("executing %v", keygenCmd.Args)
121
if out, err := keygenCmd.CombinedOutput(); err != nil {
122
return fmt.Errorf("failed to run %v: %q: %w", keygenCmd.Args, string(out), err)
123
}
124
return nil
125
}); err != nil {
126
return nil, err
127
}
128
}
129
entry, err := readPublicKey(filepath.Join(configDir, filenames.UserPublicKey))
130
if err != nil {
131
return nil, err
132
}
133
res := []PubKey{entry}
134
135
if !loadDotSSH {
136
return res, nil
137
}
138
139
// Append all of ~/.ssh/*.pub
140
homeDir, err := os.UserHomeDir()
141
if err != nil {
142
return nil, err
143
}
144
files, err := filepath.Glob(filepath.Join(homeDir, ".ssh/*.pub"))
145
if err != nil {
146
panic(err) // Only possible error is ErrBadPattern, so this should be unreachable.
147
}
148
for _, f := range files {
149
if !strings.HasSuffix(f, ".pub") {
150
panic(fmt.Errorf("unexpected ssh public key filename %q", f))
151
}
152
entry, err := readPublicKey(f)
153
if err == nil {
154
if !detectValidPublicKey(entry.Content) {
155
logrus.Warnf("public key %q doesn't seem to be in ssh format", entry.Filename)
156
} else {
157
res = append(res, entry)
158
}
159
} else if !errors.Is(err, os.ErrNotExist) {
160
return nil, err
161
}
162
}
163
return res, nil
164
}
165
166
type openSSHInfo struct {
167
// Version is set to the version of OpenSSH, or semver.New("0.0.0") if the version cannot be determined.
168
Version semver.Version
169
170
// Some distributions omit this feature by default, for example, Alpine, NixOS.
171
GSSAPISupported bool
172
}
173
174
var sshInfo struct {
175
sync.Once
176
// aesAccelerated is set to true when AES acceleration is available.
177
// Available on almost all modern Intel/AMD processors.
178
aesAccelerated bool
179
180
// OpenSSH executable information for the version and supported options.
181
openSSH openSSHInfo
182
}
183
184
// CommonOpts returns ssh option key-value pairs like {"IdentityFile=/path/to/id_foo"}.
185
// The result may contain different values with the same key.
186
//
187
// The result always contains the IdentityFile option.
188
// The result never contains the Port option.
189
func CommonOpts(ctx context.Context, sshExe SSHExe, useDotSSH bool) ([]string, error) {
190
configDir, err := dirnames.LimaConfigDir()
191
if err != nil {
192
return nil, err
193
}
194
privateKeyPath := filepath.Join(configDir, filenames.UserPrivateKey)
195
_, err = os.Stat(privateKeyPath)
196
if err != nil {
197
return nil, err
198
}
199
var opts []string
200
idf, err := identityFileEntry(ctx, privateKeyPath)
201
if err != nil {
202
return nil, err
203
}
204
opts = []string{idf}
205
206
// Append all private keys corresponding to ~/.ssh/*.pub to keep old instances working
207
// that had been created before lima started using an internal identity.
208
if useDotSSH {
209
homeDir, err := os.UserHomeDir()
210
if err != nil {
211
return nil, err
212
}
213
files, err := filepath.Glob(filepath.Join(homeDir, ".ssh/*.pub"))
214
if err != nil {
215
panic(err) // Only possible error is ErrBadPattern, so this should be unreachable.
216
}
217
for _, f := range files {
218
if !strings.HasSuffix(f, ".pub") {
219
panic(fmt.Errorf("unexpected ssh public key filename %q", f))
220
}
221
privateKeyPath := strings.TrimSuffix(f, ".pub")
222
_, err = os.Stat(privateKeyPath)
223
if errors.Is(err, fs.ErrNotExist) {
224
// Skip .pub files without a matching private key. This is reasonably common,
225
// due to major projects like Vault recommending the ${name}-cert.pub format
226
// for SSH certificate files.
227
//
228
// e.g. https://www.vaultproject.io/docs/secrets/ssh/signed-ssh-certificates
229
continue
230
}
231
if err != nil {
232
// Fail on permission-related and other path errors
233
return nil, err
234
}
235
idf, err = identityFileEntry(ctx, privateKeyPath)
236
if err != nil {
237
return nil, err
238
}
239
opts = append(opts, idf)
240
}
241
}
242
243
opts = append(opts,
244
"StrictHostKeyChecking=no",
245
"UserKnownHostsFile=/dev/null",
246
"NoHostAuthenticationForLocalhost=yes",
247
"PreferredAuthentications=publickey",
248
"Compression=no",
249
"BatchMode=yes",
250
"IdentitiesOnly=yes",
251
)
252
253
sshInfo.Do(func() {
254
sshInfo.aesAccelerated = detectAESAcceleration()
255
sshInfo.openSSH = detectOpenSSHInfo(ctx, sshExe)
256
})
257
258
if sshInfo.openSSH.GSSAPISupported {
259
opts = append(opts, "GSSAPIAuthentication=no")
260
}
261
262
// Only OpenSSH version 8.1 and later support adding ciphers to the front of the default set
263
if !sshInfo.openSSH.Version.LessThan(*semver.New("8.1.0")) {
264
// By default, `ssh` choose [email protected], even when AES accelerator is available.
265
// (OpenSSH_8.1p1, macOS 11.6, MacBookPro 2020, Core i7-1068NG7)
266
//
267
// We prioritize AES algorithms when AES accelerator is available.
268
if sshInfo.aesAccelerated {
269
logrus.Debugf("AES accelerator seems available, prioritizing [email protected] and [email protected]")
270
if runtime.GOOS == "windows" {
271
opts = append(opts, "Ciphers=^[email protected],[email protected]")
272
} else {
273
opts = append(opts, "Ciphers=\"^[email protected],[email protected]\"")
274
}
275
} else {
276
logrus.Debugf("AES accelerator does not seem available, prioritizing [email protected]")
277
if runtime.GOOS == "windows" {
278
opts = append(opts, "Ciphers=^[email protected]")
279
} else {
280
opts = append(opts, "Ciphers=\"^[email protected]\"")
281
}
282
}
283
}
284
return opts, nil
285
}
286
287
func identityFileEntry(ctx context.Context, privateKeyPath string) (string, error) {
288
if runtime.GOOS == "windows" {
289
privateKeyPath, err := ioutilx.WindowsSubsystemPath(ctx, privateKeyPath)
290
if err != nil {
291
return "", err
292
}
293
return fmt.Sprintf(`IdentityFile='%s'`, privateKeyPath), nil
294
}
295
return fmt.Sprintf(`IdentityFile="%s"`, privateKeyPath), nil
296
}
297
298
// DisableControlMasterOptsFromSSHArgs returns ssh args that disable ControlMaster, ControlPath, and ControlPersist.
299
func DisableControlMasterOptsFromSSHArgs(sshArgs []string) []string {
300
argsForOverridingConfigFile := []string{
301
"-o", "ControlMaster=no",
302
"-o", "ControlPath=none",
303
"-o", "ControlPersist=no",
304
}
305
return slices.Concat(argsForOverridingConfigFile, removeOptsFromSSHArgs(sshArgs, "ControlMaster", "ControlPath", "ControlPersist"))
306
}
307
308
func removeOptsFromSSHArgs(sshArgs []string, removeOpts ...string) []string {
309
res := make([]string, 0, len(sshArgs))
310
isOpt := false
311
for _, arg := range sshArgs {
312
if isOpt {
313
isOpt = false
314
if !slices.ContainsFunc(removeOpts, func(opt string) bool {
315
return strings.HasPrefix(arg, opt)
316
}) {
317
res = append(res, "-o", arg)
318
}
319
} else if arg == "-o" {
320
isOpt = true
321
} else {
322
res = append(res, arg)
323
}
324
}
325
return res
326
}
327
328
// IsControlMasterExisting returns true if the control socket file exists.
329
func IsControlMasterExisting(instDir string) bool {
330
controlSock := filepath.Join(instDir, filenames.SSHSock)
331
_, err := os.Stat(controlSock)
332
return err == nil
333
}
334
335
// SSHOpts adds the following options to CommonOptions: User, ControlMaster, ControlPath, ControlPersist.
336
func SSHOpts(ctx context.Context, sshExe SSHExe, instDir, username string, useDotSSH, forwardAgent, forwardX11, forwardX11Trusted bool) ([]string, error) {
337
controlSock := filepath.Join(instDir, filenames.SSHSock)
338
if len(controlSock) >= osutil.UnixPathMax {
339
return nil, fmt.Errorf("socket path %q is too long: >= UNIX_PATH_MAX=%d", controlSock, osutil.UnixPathMax)
340
}
341
opts, err := CommonOpts(ctx, sshExe, useDotSSH)
342
if err != nil {
343
return nil, err
344
}
345
controlPath := fmt.Sprintf(`ControlPath="%s"`, controlSock)
346
if runtime.GOOS == "windows" {
347
controlSock, err = ioutilx.WindowsSubsystemPath(ctx, controlSock)
348
if err != nil {
349
return nil, err
350
}
351
controlPath = fmt.Sprintf(`ControlPath='%s'`, controlSock)
352
}
353
opts = append(opts,
354
fmt.Sprintf("User=%s", username), // guest and host have the same username, but we should specify the username explicitly (#85)
355
"ControlMaster=auto",
356
controlPath,
357
"ControlPersist=yes",
358
)
359
if forwardAgent {
360
opts = append(opts, "ForwardAgent=yes")
361
}
362
if forwardX11 {
363
opts = append(opts, "ForwardX11=yes")
364
}
365
if forwardX11Trusted {
366
opts = append(opts, "ForwardX11Trusted=yes")
367
}
368
return opts, nil
369
}
370
371
// SSHArgsFromOpts returns ssh args from opts.
372
// The result always contains {"-F", "/dev/null} in addition to {"-o", "KEY=VALUE", ...}.
373
func SSHArgsFromOpts(opts []string) []string {
374
args := []string{"-F", "/dev/null"}
375
for _, o := range opts {
376
args = append(args, "-o", o)
377
}
378
return args
379
}
380
381
// SSHOptsRemovingControlPath removes ControlMaster, ControlPath, and ControlPersist options from SSH options.
382
func SSHOptsRemovingControlPath(opts []string) []string {
383
// Create a copy of opts to avoid modifying the original slice, since slices.DeleteFunc modifies the slice in place.
384
copiedOpts := slices.Clone(opts)
385
return slices.DeleteFunc(copiedOpts, func(s string) bool {
386
return strings.HasPrefix(s, "ControlMaster") || strings.HasPrefix(s, "ControlPath") || strings.HasPrefix(s, "ControlPersist")
387
})
388
}
389
390
func ParseOpenSSHVersion(version []byte) *semver.Version {
391
regex := regexp.MustCompile(`(?m)^OpenSSH_(\d+\.\d+)(?:p(\d+))?\b`)
392
matches := regex.FindSubmatch(version)
393
if len(matches) == 3 {
394
if len(matches[2]) == 0 {
395
matches[2] = []byte("0")
396
}
397
return semver.New(fmt.Sprintf("%s.%s", matches[1], matches[2]))
398
}
399
return &semver.Version{}
400
}
401
402
func parseOpenSSHGSSAPISupported(version string) bool {
403
return !strings.Contains(version, `Unsupported option "gssapiauthentication"`)
404
}
405
406
// sshExecutable beyond path also records size and mtime, in the case of ssh upgrades.
407
type sshExecutable struct {
408
Path string
409
Size int64
410
ModTime time.Time
411
}
412
413
var (
414
// openSSHInfos caches the parsed version and supported options of each ssh executable, if it is needed again.
415
openSSHInfos = map[sshExecutable]*openSSHInfo{}
416
openSSHInfosRW sync.RWMutex
417
)
418
419
func detectOpenSSHInfo(ctx context.Context, sshExe SSHExe) openSSHInfo {
420
var (
421
info openSSHInfo
422
exe sshExecutable
423
stderr bytes.Buffer
424
)
425
// Note: For SSH wrappers like "kitten ssh", os.Stat will check the wrapper
426
// executable (kitten) instead of the underlying ssh binary. This means
427
// cache invalidation won't work properly - ssh upgrades won't be detected
428
// since kitten's size/mtime won't change. This is probably acceptable.
429
if st, err := os.Stat(sshExe.Exe); err == nil {
430
exe = sshExecutable{Path: sshExe.Exe, Size: st.Size(), ModTime: st.ModTime()}
431
openSSHInfosRW.RLock()
432
info := openSSHInfos[exe]
433
openSSHInfosRW.RUnlock()
434
if info != nil {
435
return *info
436
}
437
}
438
sshArgs := append([]string{}, sshExe.Args...)
439
// -V should be last
440
sshArgs = append(sshArgs, "-o", "GSSAPIAuthentication=no", "-V")
441
cmd := exec.CommandContext(ctx, sshExe.Exe, sshArgs...)
442
cmd.Stderr = &stderr
443
if err := cmd.Run(); err != nil {
444
logrus.Warnf("failed to run %v: stderr=%q", cmd.Args, stderr.String())
445
} else {
446
info = openSSHInfo{
447
Version: *ParseOpenSSHVersion(stderr.Bytes()),
448
GSSAPISupported: parseOpenSSHGSSAPISupported(stderr.String()),
449
}
450
logrus.Debugf("OpenSSH version %s detected, is GSSAPI supported: %t", info.Version, info.GSSAPISupported)
451
openSSHInfosRW.Lock()
452
openSSHInfos[exe] = &info
453
openSSHInfosRW.Unlock()
454
}
455
return info
456
}
457
458
func DetectOpenSSHVersion(ctx context.Context, sshExe SSHExe) semver.Version {
459
return detectOpenSSHInfo(ctx, sshExe).Version
460
}
461
462
// detectValidPublicKey returns whether content represent a public key.
463
// OpenSSH public key format have the structure of '<algorithm> <key> <comment>'.
464
// By checking 'algorithm' with signature format identifier in 'key' part,
465
// this function may report false positive but provide better compatibility.
466
func detectValidPublicKey(content string) bool {
467
if strings.ContainsRune(content, '\n') {
468
return false
469
}
470
spaced := strings.SplitN(content, " ", 3)
471
if len(spaced) < 2 {
472
return false
473
}
474
algo, base64Key := spaced[0], spaced[1]
475
decodedKey, err := base64.StdEncoding.DecodeString(base64Key)
476
if err != nil || len(decodedKey) < 4 {
477
return false
478
}
479
sigLength := binary.BigEndian.Uint32(decodedKey)
480
if uint32(len(decodedKey)) < sigLength {
481
return false
482
}
483
sigFormat := string(decodedKey[4 : 4+sigLength])
484
return algo == sigFormat
485
}
486
487
func detectAESAcceleration() bool {
488
if !cpu.Initialized {
489
if runtime.GOOS == "linux" && runtime.GOARCH == "arm64" {
490
// cpu.Initialized seems to always be false, even when the cpu.ARM64 struct is filled out
491
// it is only being set by readARM64Registers, but not by readHWCAP or readLinuxProcCPUInfo
492
return cpu.ARM64.HasAES
493
}
494
if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
495
// golang.org/x/sys/cpu supports darwin/amd64, linux/amd64, and linux/arm64,
496
// but apparently lacks support for darwin/arm64: https://github.com/golang/sys/blob/v0.5.0/cpu/cpu_arm64.go#L43-L60
497
//
498
// According to https://gist.github.com/voluntas/fd279c7b4e71f9950cfd4a5ab90b722b ,
499
// aes-128-gcm is faster than chacha20-poly1305 on Apple M1.
500
//
501
// So we return `true` here.
502
//
503
// This workaround will not be needed when https://go-review.googlesource.com/c/sys/+/332729 is merged.
504
logrus.Debug("Failed to detect CPU features. Assuming that AES acceleration is available on this Apple silicon.")
505
return true
506
}
507
logrus.Warn("Failed to detect CPU features. Assuming that AES acceleration is not available.")
508
return false
509
}
510
return cpu.ARM.HasAES || cpu.ARM64.HasAES || cpu.PPC64.IsPOWER8 || cpu.S390X.HasAES || cpu.X86.HasAES
511
}
512
513