gomote: add ssh <cmd> and scp Fixes golang/go#21140. Change-Id: I88cf60b2b4615b1f96a3624964c89a60046be4d5 Reviewed-on: https://go-review.googlesource.com/c/build/+/808700 LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com> Reviewed-by: Carlos Amedee <carlos@golang.org> Reviewed-by: David Chase <drchase@google.com>
diff --git a/cmd/buildlet/ssh.go b/cmd/buildlet/ssh.go index f757210..67bb31f 100644 --- a/cmd/buildlet/ssh.go +++ b/cmd/buildlet/ssh.go
@@ -7,6 +7,7 @@ package main import ( + "context" "fmt" "io" "log" @@ -19,8 +20,9 @@ func startSSHServerSwarming() { buildletSSHServer = &ssh.Server{ - Addr: "localhost:" + sshPort(), - Handler: sshHandler, + Addr: "localhost:" + sshPort(), + Handler: sshHandler, + SubsystemHandlers: sshSubsystems, PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool { allowed, _, _, _, err := ssh.ParseAuthorizedKey(buldletAuthKeys) if err != nil { @@ -42,10 +44,20 @@ }) } +// shellCommand returns a command that runs rawCmd using the shell, +// or the shell itself reading commands from standard input +// if rawCmd is empty. +func shellCommand(ctx context.Context, rawCmd string) *exec.Cmd { + if rawCmd == "" { + return exec.CommandContext(ctx, shell()) + } + return exec.CommandContext(ctx, shell(), "-c", rawCmd) +} + func sshHandler(s ssh.Session) { ptyReq, winCh, isPty := s.Pty() if !isPty { - fmt.Fprint(s, "scp is not supported\n") + sshHandlerDirect(s) return } var cmd *exec.Cmd
diff --git a/cmd/buildlet/ssh_direct.go b/cmd/buildlet/ssh_direct.go new file mode 100644 index 0000000..3dbc085 --- /dev/null +++ b/cmd/buildlet/ssh_direct.go
@@ -0,0 +1,90 @@ +// Copyright 2026 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build !plan9 + +package main + +import ( + "errors" + "fmt" + "io" + "log" + "os/exec" + + "github.com/gliderlabs/ssh" + "github.com/pkg/sftp" +) + +// sshSubsystems are the subsystem handlers for the buildlet's SSH server. +var sshSubsystems = map[string]ssh.SubsystemHandler{ + "sftp": sshHandlerSFTP, +} + +// sshHandlerSFTP serves the sftp subsystem, which scp and sftp use to +// copy files to and from the buildlet (go.dev/issue/21140). Relative +// paths name files in the buildlet's work directory. +func sshHandlerSFTP(s ssh.Session) { + srv, err := sftp.NewServer(s, sftp.WithServerWorkingDirectory(*workDir)) + if err != nil { + log.Printf("starting sftp server: %s", err) + fmt.Fprintf(s.Stderr(), "starting sftp server: %s\n", err) + s.Exit(255) + return + } + defer srv.Close() + if err := srv.Serve(); err != nil && !errors.Is(err, io.EOF) { + log.Printf("sftp server: %s", err) + fmt.Fprintf(s.Stderr(), "sftp server: %s\n", err) + s.Exit(255) + return + } + s.Exit(0) +} + +// sshHandlerDirect handles a session that did not request a pty: an +// exec request ("gomote ssh instance cmd..."), a shell reading +// commands from piped standard input, or the legacy scp protocol +// (go.dev/issue/21140). It connects the command to the session's own +// streams instead of a pty, so data passes through byte for byte, and +// it propagates the command's exit status. +func sshHandlerDirect(s ssh.Session) { + fail := func(format string, args ...any) { + fmt.Fprintf(s.Stderr(), format, args...) + s.Exit(255) + } + cmd := shellCommand(s.Context(), s.RawCommand()) + cmd.Dir = *workDir + stdin, err := cmd.StdinPipe() + if err != nil { + fail("%v\n", err) + return + } + cmd.Stdout = s + cmd.Stderr = s.Stderr() + if err := cmd.Start(); err != nil { + log.Printf("unable to start shell: %s", err) + fail("unable to start shell %q: %s\n", shell(), err) + return + } + // Copy session input on the side: the copy blocks until the client + // sends EOF or disconnects, which must not keep Wait from returning + // once the command exits. + go func() { + io.Copy(stdin, s) + stdin.Close() + }() + err = cmd.Wait() + code := 0 + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + if code = exitErr.ExitCode(); code < 0 { + code = 255 + } + } else if err != nil { + fail("running shell %q: %s\n", shell(), err) + return + } + s.Exit(code) +}
diff --git a/cmd/buildlet/ssh_direct_test.go b/cmd/buildlet/ssh_direct_test.go new file mode 100644 index 0000000..f33ad75 --- /dev/null +++ b/cmd/buildlet/ssh_direct_test.go
@@ -0,0 +1,195 @@ +// Copyright 2026 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build !plan9 && !windows + +package main + +import ( + "bytes" + "errors" + "io" + "net" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + gssh "github.com/gliderlabs/ssh" + "github.com/pkg/sftp" + "golang.org/x/crypto/ssh" +) + +// testSSHClient starts an SSH server running sshHandler, like the one +// the buildlet runs on swarming bots, and returns a client connected +// to it. +func testSSHClient(t *testing.T) *ssh.Client { + t.Helper() + // Use a known shell: the tests below use POSIX shell syntax, + // and shell() consults $SHELL on some systems. + t.Setenv("SHELL", "/bin/sh") + + oldWorkDir := *workDir + *workDir = t.TempDir() + t.Cleanup(func() { *workDir = oldWorkDir }) + + ln, err := net.Listen("tcp", "localhost:0") + if err != nil { + t.Fatal(err) + } + srv := &gssh.Server{Handler: sshHandler, SubsystemHandlers: sshSubsystems} + go srv.Serve(ln) + t.Cleanup(func() { srv.Close() }) + + c, err := ssh.Dial("tcp", ln.Addr().String(), &ssh.ClientConfig{ + User: "test", + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { c.Close() }) + return c +} + +// TestSSHHandlerExec tests an exec request without a pty, +// as in "gomote ssh instance cmd...". +func TestSSHHandlerExec(t *testing.T) { + c := testSSHClient(t) + sess, err := c.NewSession() + if err != nil { + t.Fatal(err) + } + defer sess.Close() + var stdout, stderr bytes.Buffer + sess.Stdout = &stdout + sess.Stderr = &stderr + err = sess.Run("echo out; echo err >&2; exit 7") + var exitErr *ssh.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("Run = %v, want exit status 7", err) + } + if code := exitErr.ExitStatus(); code != 7 { + t.Errorf("exit status = %d, want 7", code) + } + if got := stdout.String(); got != "out\n" { + t.Errorf("stdout = %q, want %q", got, "out\n") + } + if got := stderr.String(); got != "err\n" { + t.Errorf("stderr = %q, want %q", got, "err\n") + } +} + +// TestSSHHandlerShell tests a shell request without a pty, +// which reads commands from standard input. +func TestSSHHandlerShell(t *testing.T) { + c := testSSHClient(t) + sess, err := c.NewSession() + if err != nil { + t.Fatal(err) + } + defer sess.Close() + var stdout bytes.Buffer + sess.Stdin = strings.NewReader("echo hello\nexit 3\n") + sess.Stdout = &stdout + if err := sess.Shell(); err != nil { + t.Fatal(err) + } + err = sess.Wait() + var exitErr *ssh.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("Wait = %v, want exit status 3", err) + } + if code := exitErr.ExitStatus(); code != 3 { + t.Errorf("exit status = %d, want 3", code) + } + if got := stdout.String(); got != "hello\n" { + t.Errorf("stdout = %q, want %q", got, "hello\n") + } +} + +// TestSSHHandlerSFTP tests the sftp subsystem that scp uses, +// including that relative paths name files in the work directory. +func TestSSHHandlerSFTP(t *testing.T) { + c := testSSHClient(t) + client, err := sftp.NewClient(c) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + f, err := client.Create("hello.txt") + if err != nil { + t.Fatal(err) + } + if _, err := f.Write([]byte("hello\n")); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(*workDir, "hello.txt")) + if err != nil { + t.Fatal(err) + } + if string(data) != "hello\n" { + t.Errorf("hello.txt = %q, want %q", data, "hello\n") + } + + f, err = client.Open("hello.txt") + if err != nil { + t.Fatal(err) + } + defer f.Close() + data, err = io.ReadAll(f) + if err != nil { + t.Fatal(err) + } + if string(data) != "hello\n" { + t.Errorf("read back hello.txt = %q, want %q", data, "hello\n") + } +} + +// TestSSHHandlerStdinOpen tests that a command that ignores its +// standard input finishes even though the client is still holding +// standard input open, as an interactive ssh client does. +func TestSSHHandlerStdinOpen(t *testing.T) { + c := testSSHClient(t) + sess, err := c.NewSession() + if err != nil { + t.Fatal(err) + } + defer sess.Close() + pr, pw := io.Pipe() + defer pw.Close() + sess.Stdin = pr + stdout, err := sess.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := sess.Start("echo done"); err != nil { + t.Fatal(err) + } + // If the handler waits for standard input to close, the session + // channel never closes and the read below never returns; closing + // the connection makes the test fail instead of time out. + var stuck atomic.Bool + timer := time.AfterFunc(30*time.Second, func() { + stuck.Store(true) + c.Close() + }) + defer timer.Stop() + out, err := io.ReadAll(stdout) + if stuck.Load() { + t.Fatal("session did not finish while standard input was open") + } + if err != nil { + t.Fatalf("reading stdout: %v", err) + } + if got := string(out); got != "done\n" { + t.Errorf("stdout = %q, want %q", got, "done\n") + } +}
diff --git a/cmd/buildlet/ssh_windows.go b/cmd/buildlet/ssh_windows.go index 55abf42..ccaced4 100644 --- a/cmd/buildlet/ssh_windows.go +++ b/cmd/buildlet/ssh_windows.go
@@ -5,9 +5,12 @@ package main import ( + "context" "fmt" "io" "log" + "os/exec" + "syscall" "github.com/UserExistsError/conpty" "github.com/gliderlabs/ssh" @@ -15,8 +18,9 @@ func startSSHServerSwarming() { buildletSSHServer = &ssh.Server{ - Addr: "localhost:" + sshPort(), - Handler: sshHandler, + Addr: "localhost:" + sshPort(), + Handler: sshHandler, + SubsystemHandlers: sshSubsystems, PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool { allowed, _, _, _, err := ssh.ParseAuthorizedKey(buldletAuthKeys) if err != nil { @@ -38,10 +42,26 @@ }) } +// shellCommand returns a command that runs rawCmd using the shell, +// or the shell itself reading commands from standard input +// if rawCmd is empty. +func shellCommand(ctx context.Context, rawCmd string) *exec.Cmd { + cmd := exec.CommandContext(ctx, shell()) + if rawCmd != "" { + // cmd.exe does not parse its command line using the standard + // Windows quoting rules that os/exec applies to Args, so set + // the command line directly and pass rawCmd through verbatim. + cmd.SysProcAttr = &syscall.SysProcAttr{ + CmdLine: `"` + shell() + `" /c ` + rawCmd, + } + } + return cmd +} + func sshHandler(s ssh.Session) { ptyReq, winCh, isPty := s.Pty() if !isPty { - fmt.Fprint(s, "scp is not supported\n") + sshHandlerDirect(s) return } f, err := conpty.Start(shell(), conpty.ConPtyDimensions(ptyReq.Window.Width, ptyReq.Window.Height), conpty.ConPtyWorkDir(*workDir))
diff --git a/cmd/gomote/gomote.go b/cmd/gomote/gomote.go index e3d8469..ec6c567 100644 --- a/cmd/gomote/gomote.go +++ b/cmd/gomote/gomote.go
@@ -37,6 +37,7 @@ rdp RDP (Remote Desktop Protocol) to a Windows buildlet repro reproduce a build by LUCI build ID run run a command on a buildlet + scp copy files to or from a buildlet ssh ssh to a buildlet To list all the builder types available, run "create" with no arguments: @@ -209,6 +210,7 @@ registerCommand("rdp", "Unimplimented: RDP (Remote Desktop Protocol) to a Windows buildlet", rdp) registerCommand("rm", "delete files or directories", rm) registerCommand("run", "run a command on a buildlet", run) + registerCommand("scp", "copy files to or from a buildlet", scp) registerCommand("ssh", "ssh to a buildlet", ssh) }
diff --git a/cmd/gomote/ssh.go b/cmd/gomote/ssh.go index 51a1681..d3dea53 100644 --- a/cmd/gomote/ssh.go +++ b/cmd/gomote/ssh.go
@@ -13,23 +13,31 @@ "os" "os/exec" "path/filepath" + "slices" "strings" "golang.org/x/build/internal/gomote/protos" ) +// sshServer is the ssh proxy through which ssh and scp +// reach buildlets. +const sshServer = "gomotessh.golang.org" + func ssh(args []string) error { fs := flag.NewFlagSet("ssh", flag.ContinueOnError) fs.Usage = func() { - usageLogger.Print("ssh usage: gomote ssh <instance>") + usageLogger.Print("ssh usage: gomote ssh [-n] <instance> [cmd...]") fs.PrintDefaults() os.Exit(1) } + printOnly := fs.Bool("n", false, "print the ssh command line but do not run it") fs.Parse(args) var name string - if fs.NArg() == 1 { + var remoteCmd []string + if fs.NArg() >= 1 { name = fs.Arg(0) + remoteCmd = fs.Args()[1:] } else if activeGroup != nil { if len(activeGroup.Instances) != 1 { return fmt.Errorf("command only supports groups with exactly one member") @@ -39,17 +47,98 @@ fs.Usage() } - sshKeyDir, err := sshConfigDirectory() + priKey, certPath, err := sshCertificate(name) if err != nil { return err } + return sshConnect(name, priKey, certPath, remoteCmd, *printOnly) +} + +// scp copies files to or from buildlets. +// Arguments of the form instance:path name files on that instance; +// all other arguments, including scp flags, pass through to scp. +func scp(args []string) error { + instances, rewritten := scpArgs(args) + if len(instances) == 0 { + usageLogger.Print("scp usage: gomote scp [scp-args] [<instance>:]file... [<instance>:]file") + os.Exit(1) + } + scpPath, err := exec.LookPath("scp") + if err != nil { + return fmt.Errorf("path to scp not found: %w", err) + } + cli := []string{"-P", "2222"} + var priKey string + for _, inst := range instances { + pk, certPath, err := sshCertificate(inst) + if err != nil { + return err + } + priKey = pk + cli = append(cli, "-o", "CertificateFile="+certPath) + } + cli = append(cli, "-i", priKey) + cli = append(cli, rewritten...) + fmt.Printf("$ %s\n", shellJoin(append([]string{scpPath}, cli...))) + cmd := exec.Command(scpPath, cli...) + cmd.Stdout = os.Stdout + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("unable to scp: %w", err) + } + return nil +} + +// scpArgs rewrites the arguments for an scp command, replacing +// arguments of the form instance:path with instance@sshServer:path +// and returning the instances mentioned along with the rewritten +// argument list. +func scpArgs(args []string) (instances, rewritten []string) { + for _, arg := range args { + inst, ok := scpInstance(arg) + if !ok { + rewritten = append(rewritten, arg) + continue + } + if !slices.Contains(instances, inst) { + instances = append(instances, inst) + } + rewritten = append(rewritten, inst+"@"+sshServer+strings.TrimPrefix(arg, inst)) + } + return instances, rewritten +} + +// scpInstance reports the instance name if arg has the remote form +// instance:path, using scp's own rule: an argument is remote if it +// contains a colon before any slash. Flags and arguments that +// already name a user with @ are left alone. +func scpInstance(arg string) (string, bool) { + if strings.HasPrefix(arg, "-") { + return "", false + } + i := strings.IndexAny(arg, ":/@") + if i <= 0 || arg[i] != ':' { + return "", false + } + return arg[:i], true +} + +// sshCertificate signs the local SSH public key for the named +// instance, returning the paths of the local private key and the +// signed certificate. +func sshCertificate(name string) (priKey, certPath string, err error) { + sshKeyDir, err := sshConfigDirectory() + if err != nil { + return "", "", err + } pubKey, priKey, err := localKeyPair(sshKeyDir) if err != nil { - return err + return "", "", err } pubKeyBytes, err := os.ReadFile(pubKey) if err != nil { - return err + return "", "", err } ctx := context.Background() client := gomoteServerClient(ctx) @@ -58,13 +147,13 @@ PublicSshKey: []byte(pubKeyBytes), }) if err != nil { - return fmt.Errorf("unable to retrieve SSH certificate: %w", err) + return "", "", fmt.Errorf("unable to retrieve SSH certificate: %w", err) } - certPath, err := writeCertificateToDisk(resp.GetSignedPublicSshKey()) + certPath, err = writeCertificateToDisk(resp.GetSignedPublicSshKey()) if err != nil { - return err + return "", "", err } - return sshConnect(name, priKey, certPath) + return priKey, certPath, nil } func sshConfigDirectory() (string, error) { @@ -118,14 +207,18 @@ return tf.Name(), tf.Close() } -func sshConnect(name string, priKey, certPath string) error { +func sshConnect(name string, priKey, certPath string, remoteCmd []string, printOnly bool) error { ssh, err := exec.LookPath("ssh") if err != nil { return fmt.Errorf("path to ssh not found: %w", err) } - sshServer := "gomotessh.golang.org" cli := []string{"-o", fmt.Sprintf("CertificateFile=%s", certPath), "-i", priKey, "-p", "2222", name + "@" + sshServer} - fmt.Printf("$ %s %s\n", ssh, strings.Join(cli, " ")) + cli = append(cli, remoteCmd...) + if printOnly { + fmt.Println(shellJoin(append([]string{ssh}, cli...))) + return nil + } + fmt.Printf("$ %s\n", shellJoin(append([]string{ssh}, cli...))) cmd := exec.Command(ssh, cli...) cmd.Stdout = os.Stdout cmd.Stdin = os.Stdin @@ -136,6 +229,23 @@ return nil } +// shellQuote quotes s as needed for use in a shell command line. +func shellQuote(s string) string { + if s != "" && !strings.ContainsAny(s, " \t\n'\"\\$&|;<>()*?[]#~`!{}") { + return s + } + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// shellJoin joins args into a shell command line, quoting as needed. +func shellJoin(args []string) string { + quoted := make([]string, len(args)) + for i, arg := range args { + quoted[i] = shellQuote(arg) + } + return strings.Join(quoted, " ") +} + func fileExists(path string) bool { if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { return false
diff --git a/cmd/gomote/ssh_test.go b/cmd/gomote/ssh_test.go new file mode 100644 index 0000000..c77b2ca --- /dev/null +++ b/cmd/gomote/ssh_test.go
@@ -0,0 +1,63 @@ +// Copyright 2026 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package main + +import ( + "reflect" + "testing" +) + +func TestScpArgs(t *testing.T) { + tests := []struct { + args []string + wantInstances []string + wantRewritten []string + }{ + { + []string{"-r", "user-linux-amd64-0:foo", "."}, + []string{"user-linux-amd64-0"}, + []string{"-r", "user-linux-amd64-0@" + sshServer + ":foo", "."}, + }, + { + []string{"local", "user-linux-amd64-0:", "user-linux-amd64-0:x/y"}, + []string{"user-linux-amd64-0"}, + []string{"local", "user-linux-amd64-0@" + sshServer + ":", "user-linux-amd64-0@" + sshServer + ":x/y"}, + }, + { + []string{"a-1:x", "b-2:y"}, + []string{"a-1", "b-2"}, + []string{"a-1@" + sshServer + ":x", "b-2@" + sshServer + ":y"}, + }, + { + // Local paths, flags, and already-qualified names pass through. + []string{"-P", "2222", "./a:b", "/c:d", "u@h:x", ":x", "plain"}, + nil, + []string{"-P", "2222", "./a:b", "/c:d", "u@h:x", ":x", "plain"}, + }, + } + for _, tt := range tests { + instances, rewritten := scpArgs(tt.args) + if !reflect.DeepEqual(instances, tt.wantInstances) || !reflect.DeepEqual(rewritten, tt.wantRewritten) { + t.Errorf("scpArgs(%q) = %q, %q, want %q, %q", tt.args, instances, rewritten, tt.wantInstances, tt.wantRewritten) + } + } +} + +func TestShellJoin(t *testing.T) { + tests := []struct { + args []string + want string + }{ + {[]string{"ssh", "-p", "2222", "inst@host"}, "ssh -p 2222 inst@host"}, + {[]string{"-i", "/a/Application Support/key"}, "-i '/a/Application Support/key'"}, + {[]string{"echo", "don't"}, `echo 'don'\''t'`}, + {[]string{""}, "''"}, + } + for _, tt := range tests { + if got := shellJoin(tt.args); got != tt.want { + t.Errorf("shellJoin(%q) = %s, want %s", tt.args, got, tt.want) + } + } +}
diff --git a/go.mod b/go.mod index 0a84c5a..7778ad1 100644 --- a/go.mod +++ b/go.mod
@@ -45,6 +45,7 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/mailjet/mailjet-apiv3-go/v4 v4.0.7 github.com/mattn/go-sqlite3 v1.14.14 + github.com/pkg/sftp v1.13.11 github.com/robfig/cron/v3 v3.0.2-0.20210106135023-bc59245fe10e github.com/sendgrid/sendgrid-go v3.11.1+incompatible github.com/shurcooL/githubv4 v0.0.0-20231126234147-1cffa1f02456 @@ -146,6 +147,7 @@ github.com/kevinburke/ssh_config v1.6.0 // indirect github.com/klauspost/compress v1.16.7 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/kr/fs v0.1.0 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mostynb/zstdpool-syncpool v0.0.12 // indirect
diff --git a/go.sum b/go.sum index 9ad1f11..607363a 100644 --- a/go.sum +++ b/go.sum
@@ -620,6 +620,8 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -713,6 +715,8 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE= +github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0= github.com/pkg/xattr v0.4.9 h1:5883YPCtkSd8LFbs13nXplj9g9tlrwoJRjgpgMu1/fE= github.com/pkg/xattr v0.4.9/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
diff --git a/internal/coordinator/remote/ssh.go b/internal/coordinator/remote/ssh.go index f23b993..9ef6d62 100644 --- a/internal/coordinator/remote/ssh.go +++ b/internal/coordinator/remote/ssh.go
@@ -31,6 +31,7 @@ "github.com/creack/pty" gssh "github.com/gliderlabs/ssh" + "golang.org/x/build/buildlet" "golang.org/x/build/dashboard" "golang.org/x/build/internal/envutil" "golang.org/x/crypto/ssh" @@ -138,6 +139,9 @@ }, } s.server.Handler = s.HandleIncomingSSHPostAuth + s.server.SubsystemHandlers = map[string]gssh.SubsystemHandler{ + "sftp": func(sess gssh.Session) { s.handleDirect(sess) }, + } for _, opt := range opts { opt(s) } @@ -166,7 +170,7 @@ inst := s.User() ptyReq, winCh, isPty := s.Pty() if !isPty { - fmt.Fprintf(s, "scp etc not yet supported; https://golang.org/issue/21140\n") + ss.handleDirect(s) return } rs, err := ss.sessionPool.Session(inst) @@ -215,47 +219,13 @@ return } if useLocalSSHProxy { - sshConn, err := bc.ConnectSSH(sshUser, ss.gomotePublicKey) - log.Printf("buildlet(%q).ConnectSSH = %T, %v", inst, sshConn, err) + port, cleanup, err := ss.proxyBuildletSSH(bc, inst, sshUser) if err != nil { - fmt.Fprintf(s, "failed to connect to ssh on %s: %v\n", inst, err) + fmt.Fprintf(s, "%v\n", err) return } - defer sshConn.Close() - - // Now listen on some localhost port that we'll proxy to sshConn. - // The openssh ssh command line tool will connect to this IP. - ln, err := net.Listen("tcp", "localhost:0") - if err != nil { - fmt.Fprintf(s, "local listen error: %v\n", err) - return - } - localProxyPort = ln.Addr().(*net.TCPAddr).Port - log.Printf("ssh local proxy port for %s: %v", inst, localProxyPort) - var lnCloseOnce sync.Once - lnClose := func() { lnCloseOnce.Do(func() { ln.Close() }) } - defer lnClose() - - // Accept at most one connection from localProxyPort and proxy - // it to sshConn. - go func() { - c, err := ln.Accept() - lnClose() - if err != nil { - return - } - defer c.Close() - errc := make(chan error, 1) - go func() { - _, err := io.Copy(c, sshConn) - errc <- err - }() - go func() { - _, err := io.Copy(sshConn, c) - errc <- err - }() - err = <-errc - }() + defer cleanup() + localProxyPort = port } workDir, err := bc.WorkDir(ctx) if err != nil { @@ -275,12 +245,7 @@ var cmd *exec.Cmd switch bconf.GOOS() { default: - cmd = exec.Command("ssh", - "-p", strconv.Itoa(localProxyPort), - "-o", "UserKnownHostsFile=/dev/null", - "-o", "StrictHostKeyChecking=no", - "-i", ss.privateHostKeyFile, - sshUser+"@localhost") + cmd = exec.Command("ssh", buildletSSHArgs(localProxyPort, ss.privateHostKeyFile, sshUser, "", "")...) case "plan9": fmt.Fprintf(s, "# Plan9 user/pass: glenda/glenda123\n") if ipErr != nil { @@ -317,7 +282,7 @@ inst := s.User() ptyReq, winCh, isPty := s.Pty() if !isPty { - fmt.Fprintf(s, "scp etc not yet supported; https://go.dev/issue/21140\n") + ss.handleDirect(s) return } rs, err := ss.sessionPool.Session(inst) @@ -331,8 +296,11 @@ log.Printf("ssh: KeepAlive on session=%s failed: %s", inst, err) } - sshUser := "swarming" - isPlan9 := strings.Contains(rs.HostType, "plan9") + sshUser, isPlan9, err := ss.sessionSSHUser(rs) + if err != nil { + fmt.Fprintf(s, "%v\n", err) + return + } useLocalSSHProxy := !isPlan9 if sshUser == "" && useLocalSSHProxy { fmt.Fprintf(s, "instance %q host type %q does not have SSH configured\n", inst, rs.HostType) @@ -351,47 +319,13 @@ return } if useLocalSSHProxy { - sshConn, err := bc.ConnectSSH(sshUser, ss.gomotePublicKey) - log.Printf("buildlet(%q).ConnectSSH = %T, %v", inst, sshConn, err) + port, cleanup, err := ss.proxyBuildletSSH(bc, inst, sshUser) if err != nil { - fmt.Fprintf(s, "failed to connect to ssh on %s: %v\n", inst, err) + fmt.Fprintf(s, "%v\n", err) return } - defer sshConn.Close() - - // Now listen on some localhost port that we'll proxy to sshConn. - // The openssh ssh command line tool will connect to this IP. - ln, err := net.Listen("tcp", "localhost:0") - if err != nil { - fmt.Fprintf(s, "local listen error: %v\n", err) - return - } - localProxyPort = ln.Addr().(*net.TCPAddr).Port - log.Printf("ssh local proxy port for %s: %v", inst, localProxyPort) - var lnCloseOnce sync.Once - lnClose := func() { lnCloseOnce.Do(func() { ln.Close() }) } - defer lnClose() - - // Accept at most one connection from localProxyPort and proxy - // it to sshConn. - go func() { - c, err := ln.Accept() - lnClose() - if err != nil { - return - } - defer c.Close() - errc := make(chan error, 1) - go func() { - _, err := io.Copy(c, sshConn) - errc <- err - }() - go func() { - _, err := io.Copy(sshConn, c) - errc <- err - }() - err = <-errc - }() + defer cleanup() + localProxyPort = port } workDir, err := bc.WorkDir(ctx) if err != nil { @@ -407,12 +341,7 @@ fmt.Fprint(s, "# Happy debugging.\n") log.Printf("ssh to %s: starting ssh -p %d for %s@localhost", inst, localProxyPort, sshUser) - cmd := exec.Command("ssh", - "-p", strconv.Itoa(localProxyPort), - "-o", "UserKnownHostsFile=/dev/null", - "-o", "StrictHostKeyChecking=no", - "-i", ss.privateHostKeyFile, - sshUser+"@localhost") + cmd := exec.Command("ssh", buildletSSHArgs(localProxyPort, ss.privateHostKeyFile, sshUser, "", "")...) if isPlan9 { fmt.Fprintf(s, "# Plan9 user/pass: glenda/glenda123\n") if ipErr != nil { @@ -441,6 +370,172 @@ cmd.Wait() } +// handleDirect handles a session that did not request a pty: an exec +// request ("gomote ssh instance cmd..."), a shell session reading +// commands from piped standard input, or the sftp subsystem that scp +// and sftp use (go.dev/issue/21140). It connects the ssh client to +// the session's own streams instead of a pty, so data passes through +// byte for byte, and it propagates the remote exit status. +// +// Unlike the interactive handlers, it prints no banners: standard +// output belongs to whatever protocol the command is running, +// and diagnostics go to the session's standard error. +func (ss *SSHServer) handleDirect(s gssh.Session) { + fail := func(format string, args ...any) { + fmt.Fprintf(s.Stderr(), format, args...) + s.Exit(255) + } + inst := s.User() + rs, err := ss.sessionPool.Session(inst) + if err != nil { + fail("unknown instance %q\n", inst) + return + } + sshUser, isPlan9, err := ss.sessionSSHUser(rs) + if err != nil { + fail("%v\n", err) + return + } + if isPlan9 { + fail("non-interactive sessions are not supported on plan9\n") + return + } + if sshUser == "" { + fail("instance %q host type %q does not have SSH configured\n", inst, rs.HostType) + return + } + + ctx, cancel := context.WithCancel(s.Context()) + defer cancel() + if err := ss.sessionPool.KeepAlive(ctx, inst); err != nil { + log.Printf("ssh: KeepAlive on session=%s failed: %s", inst, err) + } + + bc, err := ss.sessionPool.BuildletClient(inst) + if err != nil { + fail("failed to connect to ssh on %s: %v\n", inst, err) + return + } + localProxyPort, cleanup, err := ss.proxyBuildletSSH(bc, inst, sshUser) + if err != nil { + fail("%v\n", err) + return + } + defer cleanup() + + log.Printf("ssh to %s: starting direct ssh -p %d for %s@localhost (subsystem %q, command %q)", + inst, localProxyPort, sshUser, s.Subsystem(), s.RawCommand()) + cmd := exec.CommandContext(ctx, "ssh", + buildletSSHArgs(localProxyPort, ss.privateHostKeyFile, sshUser, s.Subsystem(), s.RawCommand())...) + stdin, err := cmd.StdinPipe() + if err != nil { + fail("%v\n", err) + return + } + cmd.Stdout = s + cmd.Stderr = s.Stderr() + if err := cmd.Start(); err != nil { + fail("running ssh client to %s: %v\n", inst, err) + return + } + // Copy session input on the side: the copy blocks until the client + // sends EOF or disconnects, which must not keep Wait from returning + // once the command exits. + go func() { + io.Copy(stdin, s) + stdin.Close() + }() + err = cmd.Wait() + code := 0 + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + if code = exitErr.ExitCode(); code < 0 { + code = 255 + } + } else if err != nil { + fail("running ssh client to %s: %v\n", inst, err) + return + } + s.Exit(code) +} + +// sessionSSHUser returns the user for ssh connections to rs's buildlet +// and whether the buildlet is a plan9 system, which the ssh command +// cannot reach. +func (ss *SSHServer) sessionSSHUser(rs *Session) (sshUser string, isPlan9 bool, err error) { + return "swarming", strings.Contains(rs.HostType, "plan9"), nil +} + +// proxyBuildletSSH connects to the SSH server on inst's buildlet as +// sshUser and starts a localhost listener that proxies a single +// connection to it, for the ssh command line tool to connect to. +// It returns the listener's port and a cleanup function that closes +// the listener and the buildlet connection. +func (ss *SSHServer) proxyBuildletSSH(bc buildlet.Client, inst, sshUser string) (port int, cleanup func(), err error) { + sshConn, err := bc.ConnectSSH(sshUser, ss.gomotePublicKey) + log.Printf("buildlet(%q).ConnectSSH = %T, %v", inst, sshConn, err) + if err != nil { + return 0, nil, fmt.Errorf("failed to connect to ssh on %s: %v", inst, err) + } + ln, err := net.Listen("tcp", "localhost:0") + if err != nil { + sshConn.Close() + return 0, nil, fmt.Errorf("local listen error: %v", err) + } + port = ln.Addr().(*net.TCPAddr).Port + log.Printf("ssh local proxy port for %s: %v", inst, port) + var lnCloseOnce sync.Once + lnClose := func() { lnCloseOnce.Do(func() { ln.Close() }) } + + // Accept at most one connection and proxy it to sshConn. + go func() { + c, err := ln.Accept() + lnClose() + if err != nil { + return + } + defer c.Close() + errc := make(chan error, 1) + go func() { + _, err := io.Copy(c, sshConn) + errc <- err + }() + go func() { + _, err := io.Copy(sshConn, c) + errc <- err + }() + <-errc + }() + return port, func() { lnClose(); sshConn.Close() }, nil +} + +// buildletSSHArgs returns the ssh client arguments for a session to +// sshUser@localhost:port through the local buildlet proxy: an sftp +// subsystem request if subsystem is set, an exec of rawCmd if that is +// set, and otherwise a shell (interactive on the pty paths, reading +// standard input on the direct path). +func buildletSSHArgs(port int, keyFile, sshUser, subsystem, rawCmd string) []string { + args := []string{ + "-p", strconv.Itoa(port), + "-o", "UserKnownHostsFile=/dev/null", + "-o", "StrictHostKeyChecking=no", + // Suppress the "Permanently added ... known hosts" warning, + // which would otherwise arrive on the stderr of every session. + "-o", "LogLevel=ERROR", + "-i", keyFile, + } + if subsystem != "" { + return append(args, "-s", sshUser+"@localhost", subsystem) + } + args = append(args, sshUser+"@localhost") + if rawCmd != "" { + // One argument: ssh passes it to the remote shell verbatim, + // preserving the client's own quoting. + args = append(args, rawCmd) + } + return args +} + // setupRemoteSSHEnv sets up environment variables on the remote system. // This makes the new SSH session easier to use for Go testing. func (ss *SSHServer) setupRemoteSSHEnv(bconf *dashboard.BuildConfig, workDir string, f io.Writer) {
diff --git a/internal/coordinator/remote/ssh_test.go b/internal/coordinator/remote/ssh_test.go index 1d77f0a..19397ab 100644 --- a/internal/coordinator/remote/ssh_test.go +++ b/internal/coordinator/remote/ssh_test.go
@@ -7,8 +7,21 @@ package remote import ( + "bytes" "context" + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "errors" "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" "testing" "time" @@ -324,3 +337,355 @@ // devCertAlternateClientPublic is a public SSH to be used for development. devCertAlternateClientPublic = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM6PwraVsJK/4uiM1ytR/RcfW+qSe4RkGQBx6IEe424R test_discard@golang.org` ) + +func TestBuildletSSHArgs(t *testing.T) { + common := []string{"-p", "1234", "-o", "UserKnownHostsFile=/dev/null", "-o", "StrictHostKeyChecking=no", "-o", "LogLevel=ERROR", "-i", "/key"} + tests := []struct { + subsystem string + rawCmd string + want []string + }{ + {"", "", append(common, "swarming@localhost")}, + {"", "hostname -f", append(common, "swarming@localhost", "hostname -f")}, + {"sftp", "", append(common, "-s", "swarming@localhost", "sftp")}, + } + for _, tt := range tests { + got := buildletSSHArgs(1234, "/key", "swarming", tt.subsystem, tt.rawCmd) + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("buildletSSHArgs(subsystem=%q, cmd=%q) mismatch (-want +got):\n%s", tt.subsystem, tt.rawCmd, diff) + } + } +} + +// TestSSHHandleDirect exercises the direct (no pty) proxy path end to +// end: a real ssh or scp client connects to a real SSHServer, which +// authenticates the client's certificate and proxies the session to a +// fake buildlet sshd (an in-process x/crypto/ssh server standing in +// for the sshd on a swarming bot). +func TestSSHHandleDirect(t *testing.T) { + // The proxy runs on Linux, and this test needs an ssh client that + // can connect to a local server and run commands through it. The + // macOS builders cannot even exec /usr/bin/scp, so limit the test + // to the system the proxy is deployed on. + if runtime.GOOS != "linux" { + t.Skipf("skipping on %s: the ssh proxy runs on linux", runtime.GOOS) + } + requireCommand(t, "ssh", "-V") + + // The gomote key pair: the proxy's host key, its identity when + // connecting to the buildlet sshd, and the key that sshd authorizes. + gomotePriv, gomotePub, err := SSHKeyPair() + if err != nil { + t.Fatal(err) + } + authorizedGomoteKey, _, _, _, err := ssh.ParseAuthorizedKey(gomotePub) + if err != nil { + t.Fatal(err) + } + caPriv, _, err := SSHKeyPair() + if err != nil { + t.Fatal(err) + } + caSigner, err := ssh.ParsePrivateKey(caPriv) + if err != nil { + t.Fatal(err) + } + buildletHostPriv, _, err := SSHKeyPair() + if err != nil { + t.Fatal(err) + } + buildletSigner, err := ssh.ParsePrivateKey(buildletHostPriv) + if err != nil { + t.Fatal(err) + } + + // A session pool holding one fake buildlet. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + sp := NewSessionPool(ctx) + defer sp.Close() + fc := &directFakeClient{ + t: t, + hostSigner: buildletSigner, + authorized: authorizedGomoteKey, + } + const ownerID = "accounts.google.com:tester" + sess := sp.AddSession(ownerID, "user", "gotip-linux-amd64", "host-linux-amd64", "task123", fc) + + // The proxy under test. + ss, err := NewSSHServer("localhost:0", gomotePriv, gomotePub, caPriv, sp, EnableLUCIOption()) + if err != nil { + t.Fatal(err) + } + ln, err := net.Listen("tcp", "localhost:0") + if err != nil { + t.Fatal(err) + } + go ss.serve(ln) + defer ss.Close() + port := strconv.Itoa(ln.Addr().(*net.TCPAddr).Port) + + // A client key, certified by the CA for this session and owner, + // as the gomote command does via the SignSSHKey RPC. + dir := t.TempDir() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + block, err := ssh.MarshalPrivateKey(priv, "") + if err != nil { + t.Fatal(err) + } + keyFile := filepath.Join(dir, "id_ed25519") + if err := os.WriteFile(keyFile, pem.EncodeToMemory(block), 0o600); err != nil { + t.Fatal(err) + } + sshPub, err := ssh.NewPublicKey(pub) + if err != nil { + t.Fatal(err) + } + cert, err := SignPublicSSHKey(ctx, caSigner, ssh.MarshalAuthorizedKey(sshPub), sess, ownerID, time.Hour) + if err != nil { + t.Fatal(err) + } + certFile := filepath.Join(dir, "id_ed25519-cert.pub") + if err := os.WriteFile(certFile, cert, 0o600); err != nil { + t.Fatal(err) + } + + opts := []string{ + "-o", "CertificateFile=" + certFile, + "-i", keyFile, + "-o", "IdentitiesOnly=yes", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "StrictHostKeyChecking=no", + "-o", "BatchMode=yes", + "-o", "LogLevel=ERROR", + } + target := sess + "@localhost" + + // The property the mote protocol needs: every byte passes through + // in both directions unmodified, including the ones a cooked pty + // would eat (\x00, \x03, \x13, \x7f, \r). + const binary = "mote server hello \x00\x01\xfe\xff\n\x03\x04\x11\x13\x7f\r\nrest" + + tests := []struct { + name string + stdin string + cmd []string // remote command; none means ssh with no arguments, a shell reading stdin + stdout string + stderr string + code int + }{ + {name: "exec", cmd: []string{"echo hello direct"}, stdout: "hello direct\n"}, + {name: "noArgs", stdin: "echo from-shell\nexit 0\n", stdout: "from-shell\n"}, + {name: "exitStatus", cmd: []string{"exit 7"}, code: 7}, + {name: "stderr", cmd: []string{"echo out; echo err >&2"}, stdout: "out\n", stderr: "err\n"}, + {name: "binarySafe", stdin: binary, cmd: []string{"cat"}, stdout: binary}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args := append(append([]string{"-p", port}, opts...), target) + args = append(args, tt.cmd...) + cmd := exec.Command("ssh", args...) + var outb, errb bytes.Buffer + cmd.Stdin = strings.NewReader(tt.stdin) + cmd.Stdout = &outb + cmd.Stderr = &errb + err := cmd.Run() + code := 0 + if ee, ok := err.(*exec.ExitError); ok { + code = ee.ExitCode() + } else if err != nil { + t.Fatalf("ssh: %v", err) + } + if code != tt.code || outb.String() != tt.stdout || errb.String() != tt.stderr { + t.Errorf("code=%d stdout=%q stderr=%q, want %d, %q, %q", + code, outb.String(), errb.String(), tt.code, tt.stdout, tt.stderr) + } + }) + } + + t.Run("scp", func(t *testing.T) { + if fc.sftpServer() == "" { + t.Skip("no sftp-server binary found") + } + requireCommand(t, "scp") + src := filepath.Join(dir, "src.bin") + data := []byte("scp test \x00\x01\xfe\xff\r\n data") + if err := os.WriteFile(src, data, 0o644); err != nil { + t.Fatal(err) + } + remote := filepath.Join(dir, "remote.bin") // "remote" is the local fs: sftp-server runs in-process + back := filepath.Join(dir, "back.bin") + for _, files := range [][]string{ + {src, target + ":" + remote}, + {target + ":" + remote, back}, + } { + args := append(append([]string{"-P", port}, opts...), files...) + out, err := exec.Command("scp", args...).CombinedOutput() + if err != nil { + t.Fatalf("scp %v: %v\n%s", files, err, out) + } + } + got, err := os.ReadFile(back) + if err != nil || !bytes.Equal(got, data) { + t.Errorf("round trip = %q, %v; want %q", got, err, data) + } + }) +} + +// requireCommand skips the test unless the named command is installed +// and can be run. Running it is the part worth checking: some builders +// have an ssh and an scp in PATH that fail to exec. +func requireCommand(t *testing.T, name string, args ...string) { + t.Helper() + path, err := exec.LookPath(name) + if err != nil { + t.Skipf("no %s command: %v", name, err) + } + // The command may report usage and exit with a status, which is + // fine; only a failure to run it at all disqualifies the system. + var execErr *exec.Error + if err := exec.Command(path, args...).Run(); errors.As(err, &execErr) { + t.Skipf("cannot run %s: %v", path, err) + } +} + +// A directFakeClient is a buildlet client whose ConnectSSH returns a +// connection to an in-process fake buildlet sshd. +type directFakeClient struct { + buildlet.FakeClient + t *testing.T + hostSigner ssh.Signer + authorized ssh.PublicKey +} + +// sftpServer returns the path of the OpenSSH sftp-server binary, +// or "" if none is installed. +func (c *directFakeClient) sftpServer() string { + for _, p := range []string{ + "/usr/libexec/sftp-server", // macOS + "/usr/lib/openssh/sftp-server", // Debian + "/usr/libexec/openssh/sftp-server", // Fedora + "/usr/lib/ssh/sftp-server", // Arch + } { + if _, err := os.Stat(p); err == nil { + return p + } + } + return "" +} + +func (c *directFakeClient) ConnectSSH(user, authorizedPubKey string) (net.Conn, error) { + if user != "swarming" { + return nil, fmt.Errorf("ConnectSSH user = %q, want swarming", user) + } + c1, c2 := net.Pipe() + go c.serveSSH(c2) + return c1, nil +} + +// serveSSH runs the fake buildlet sshd on conn: public key +// authentication with the gomote key, and sessions that run exec and +// shell requests locally and hand the sftp subsystem to sftp-server. +// A session requesting a pty is an error: the direct proxy path must +// not allocate one. +func (c *directFakeClient) serveSSH(conn net.Conn) { + defer conn.Close() + config := &ssh.ServerConfig{ + PublicKeyCallback: func(meta ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + if !bytes.Equal(key.Marshal(), c.authorized.Marshal()) { + return nil, fmt.Errorf("unauthorized key") + } + return nil, nil + }, + } + config.AddHostKey(c.hostSigner) + sconn, chans, reqs, err := ssh.NewServerConn(conn, config) + if err != nil { + c.t.Logf("fake sshd: handshake: %v", err) + return + } + defer sconn.Close() + go ssh.DiscardRequests(reqs) + for newCh := range chans { + if newCh.ChannelType() != "session" { + newCh.Reject(ssh.UnknownChannelType, "unknown channel type") + continue + } + ch, chReqs, err := newCh.Accept() + if err != nil { + continue + } + go c.serveSession(ch, chReqs) + } +} + +func (c *directFakeClient) serveSession(ch ssh.Channel, reqs <-chan *ssh.Request) { + for req := range reqs { + var cmd *exec.Cmd + switch req.Type { + default: + req.Reply(false, nil) + continue + case "env": + req.Reply(true, nil) + continue + case "pty-req": + c.t.Errorf("fake sshd: unexpected pty-req on direct session") + req.Reply(false, nil) + continue + case "exec": + var p struct{ Command string } + ssh.Unmarshal(req.Payload, &p) + cmd = exec.Command("/bin/sh", "-c", p.Command) + case "shell": + cmd = exec.Command("/bin/sh") + case "subsystem": + var p struct{ Name string } + ssh.Unmarshal(req.Payload, &p) + server := c.sftpServer() + if p.Name != "sftp" || server == "" { + req.Reply(false, nil) + continue + } + cmd = exec.Command(server) + } + req.Reply(true, nil) + // Drain any remaining requests so the connection loop + // does not stall while the command runs. + go func() { + for req := range reqs { + req.Reply(false, nil) + } + }() + c.runCommand(ch, cmd) + return + } + ch.Close() +} + +func (c *directFakeClient) runCommand(ch ssh.Channel, cmd *exec.Cmd) { + defer ch.Close() + cmd.Stdout = ch + cmd.Stderr = ch.Stderr() + stdin, err := cmd.StdinPipe() + if err != nil { + c.t.Errorf("fake sshd: %v", err) + return + } + go func() { + io.Copy(stdin, ch) + stdin.Close() + }() + err = cmd.Run() + code := 0 + if ee, ok := err.(*exec.ExitError); ok { + code = ee.ExitCode() + } else if err != nil { + c.t.Errorf("fake sshd: running command: %v", err) + return + } + ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Code uint32 }{uint32(code)})) +}