| // Copyright 2022 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 linux || darwin | |
| 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" | |
| "github.com/google/go-cmp/cmp" | |
| "golang.org/x/build/buildlet" | |
| "golang.org/x/crypto/ssh" | |
| "golang.org/x/net/nettest" | |
| ) | |
| func TestSignPublicSSHKey(t *testing.T) { | |
| signer, err := ssh.ParsePrivateKey([]byte(devCertCAPrivate)) | |
| if err != nil { | |
| t.Fatalf("ssh.ParsePrivateKey() = %s", err) | |
| } | |
| ownerID := "accounts.google.com:userIDvalue" | |
| sessionID := "user-maria-linux-amd64-12" | |
| gotPubKey, err := SignPublicSSHKey(context.Background(), signer, []byte(devCertClientPublic), sessionID, ownerID, time.Minute) | |
| if err != nil { | |
| t.Fatalf("SignPublicSSHKey(...) = _, %s; want no error", err) | |
| } | |
| pubKey, _, _, _, err := ssh.ParseAuthorizedKey(gotPubKey) | |
| if err != nil { | |
| t.Fatalf("ssh.ParseAuthorizedKey(...) = %s; want no error", err) | |
| } | |
| certChecker := &ssh.CertChecker{} | |
| wantPrinciple := fmt.Sprintf("%s@farmer.golang.org", sessionID) | |
| pubKeyCert := pubKey.(*ssh.Certificate) | |
| if err := certChecker.CheckCert(wantPrinciple, pubKeyCert); err != nil { | |
| t.Fatalf("certChecker.CheckCert(%s, %+v) = %s", wantPrinciple, pubKeyCert, err) | |
| } | |
| if diff := cmp.Diff(pubKeyCert.SignatureKey.Marshal(), signer.PublicKey().Marshal()); diff != "" { | |
| t.Fatalf("Public Keys mismatch (-want +got):\n%s", diff) | |
| } | |
| } | |
| func TestHandleCertificateAuthFunc(t *testing.T) { | |
| ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| defer cancel() | |
| addr, sp, s := setupSSHServer(t, ctx) | |
| defer s.Close() | |
| ownerID := "accounts.google.com:userIDvalue" | |
| sessionID := sp.AddSession(ownerID, "maria", "linux-amd64", "xyz", "", &buildlet.FakeClient{}) | |
| certSigner := parsePrivateKey(t, []byte(devCertCAPrivate)) | |
| clientPubKey, err := SignPublicSSHKey(ctx, certSigner, []byte(devCertClientPublic), sessionID, ownerID, time.Minute) | |
| if err != nil { | |
| t.Fatalf("SignPublicSSHKey(...) = _, %s; want no error", err) | |
| } | |
| pubKey, _, _, _, err := ssh.ParseAuthorizedKey(clientPubKey) | |
| if err != nil { | |
| t.Fatalf("ParsePublicKey(...) = _, %s; want no error", err) | |
| } | |
| cert := pubKey.(*ssh.Certificate) | |
| clientCertSigner := parsePrivateKey(t, []byte(devCertClientPrivate)) | |
| clientSigner, err := ssh.NewCertSigner(cert, clientCertSigner) | |
| if err != nil { | |
| t.Fatalf("NewCertSigner(...) = _, %s; want no error", err) | |
| } | |
| clientConfig := &ssh.ClientConfig{ | |
| User: sessionID, | |
| Auth: []ssh.AuthMethod{ | |
| ssh.PublicKeys(clientSigner), | |
| }, | |
| HostKeyCallback: ssh.InsecureIgnoreHostKey(), | |
| Timeout: 5 * time.Second, | |
| } | |
| client, err := ssh.Dial("tcp", addr, clientConfig) | |
| if err != nil { | |
| t.Fatalf("Dial(...) = _, %s; want no error", err) | |
| } | |
| client.Close() | |
| } | |
| func TestHandleCertificateAuthFuncErrors(t *testing.T) { | |
| t.Run("no certificate", func(t *testing.T) { | |
| ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| defer cancel() | |
| addr, sp, s := setupSSHServer(t, ctx) | |
| defer s.Close() | |
| ownerID := "accounts.google.com:userIDvalue" | |
| sessionID := sp.AddSession(ownerID, "maria", "linux-amd64", "xyz", "", &buildlet.FakeClient{}) | |
| clientSigner := parsePrivateKey(t, []byte(devCertClientPrivate)) | |
| clientConfig := &ssh.ClientConfig{ | |
| User: sessionID, | |
| Auth: []ssh.AuthMethod{ | |
| ssh.PublicKeys(clientSigner), | |
| }, | |
| HostKeyCallback: ssh.InsecureIgnoreHostKey(), | |
| Timeout: 5 * time.Second, | |
| } | |
| _, err := ssh.Dial("tcp", addr, clientConfig) | |
| if err == nil { | |
| t.Fatal("Dial(...) = client, nil; want error") | |
| } | |
| }) | |
| t.Run("wrong certificate signer", func(t *testing.T) { | |
| ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| defer cancel() | |
| addr, sp, s := setupSSHServer(t, ctx) | |
| defer s.Close() | |
| ownerID := "accounts.google.com:userIDvalue" | |
| sessionID := sp.AddSession(ownerID, "maria", "linux-amd64", "xyz", "", &buildlet.FakeClient{}) | |
| certSigner := parsePrivateKey(t, []byte(devCertAlternateClientPrivate)) | |
| clientPubKey, err := SignPublicSSHKey(ctx, certSigner, []byte(devCertClientPublic), sessionID, ownerID, time.Minute) | |
| if err != nil { | |
| t.Fatalf("SignPublicSSHKey(...) = _, %s; want no error", err) | |
| } | |
| pubKey, _, _, _, err := ssh.ParseAuthorizedKey(clientPubKey) | |
| if err != nil { | |
| t.Fatalf("ParsePublicKey(...) = _, %s; want no error", err) | |
| } | |
| cert := pubKey.(*ssh.Certificate) | |
| clientCertSigner := parsePrivateKey(t, []byte(devCertClientPrivate)) | |
| clientSigner, err := ssh.NewCertSigner(cert, clientCertSigner) | |
| if err != nil { | |
| t.Fatalf("NewCertSigner(...) = _, %s; want no error", err) | |
| } | |
| clientConfig := &ssh.ClientConfig{ | |
| User: sessionID, | |
| Auth: []ssh.AuthMethod{ | |
| ssh.PublicKeys(clientSigner), | |
| }, | |
| HostKeyCallback: ssh.InsecureIgnoreHostKey(), | |
| Timeout: 5 * time.Second, | |
| } | |
| _, err = ssh.Dial("tcp", addr, clientConfig) | |
| if err == nil { | |
| t.Fatalf("Dial(...) = _, %s; want no error", err) | |
| } | |
| }) | |
| t.Run("wrong user", func(t *testing.T) { | |
| ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| defer cancel() | |
| addr, sp, s := setupSSHServer(t, ctx) | |
| defer s.Close() | |
| ownerID := "accounts.google.com:userIDvalue" | |
| sessionID := sp.AddSession(ownerID, "maria", "linux-amd64", "xyz", "", &buildlet.FakeClient{}) | |
| certSigner := parsePrivateKey(t, []byte(devCertCAPrivate)) | |
| clientPubKey, err := SignPublicSSHKey(ctx, certSigner, []byte(devCertClientPublic), sessionID, ownerID, time.Minute) | |
| if err != nil { | |
| t.Fatalf("SignPublicSSHKey(...) = _, %s; want no error", err) | |
| } | |
| pubKey, _, _, _, err := ssh.ParseAuthorizedKey(clientPubKey) | |
| if err != nil { | |
| t.Fatalf("ParsePublicKey(...) = _, %s; want no error", err) | |
| } | |
| cert := pubKey.(*ssh.Certificate) | |
| clientCertSigner := parsePrivateKey(t, []byte(devCertClientPrivate)) | |
| clientSigner, err := ssh.NewCertSigner(cert, clientCertSigner) | |
| if err != nil { | |
| t.Fatalf("NewCertSigner(...) = _, %s; want no error", err) | |
| } | |
| clientConfig := &ssh.ClientConfig{ | |
| User: sessionID + "_i_do_not_exist", | |
| Auth: []ssh.AuthMethod{ | |
| ssh.PublicKeys(clientSigner), | |
| }, | |
| HostKeyCallback: ssh.InsecureIgnoreHostKey(), | |
| Timeout: 5 * time.Second, | |
| } | |
| _, err = ssh.Dial("tcp", addr, clientConfig) | |
| if err == nil { | |
| t.Fatal("Dial(...) = _, nil; want error") | |
| } | |
| }) | |
| t.Run("wrong principle", func(t *testing.T) { | |
| ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| defer cancel() | |
| addr, sp, s := setupSSHServer(t, ctx) | |
| defer s.Close() | |
| ownerID := "accounts.google.com:userIDvalue" | |
| sessionID := sp.AddSession(ownerID, "maria", "linux-amd64", "xyz", "", &buildlet.FakeClient{}) | |
| certSigner := parsePrivateKey(t, []byte(devCertCAPrivate)) | |
| clientPubKey, err := SignPublicSSHKey(ctx, certSigner, []byte(devCertClientPublic), sessionID+"WRONG", ownerID, time.Minute) | |
| if err != nil { | |
| t.Fatalf("SignPublicSSHKey(...) = _, %s; want no error", err) | |
| } | |
| pubKey, _, _, _, err := ssh.ParseAuthorizedKey(clientPubKey) | |
| if err != nil { | |
| t.Fatalf("ParsePublicKey(...) = _, %s; want no error", err) | |
| } | |
| cert := pubKey.(*ssh.Certificate) | |
| clientCertSigner := parsePrivateKey(t, []byte(devCertClientPrivate)) | |
| clientSigner, err := ssh.NewCertSigner(cert, clientCertSigner) | |
| if err != nil { | |
| t.Fatalf("NewCertSigner(...) = _, %s; want no error", err) | |
| } | |
| clientConfig := &ssh.ClientConfig{ | |
| User: sessionID, | |
| Auth: []ssh.AuthMethod{ | |
| ssh.PublicKeys(clientSigner), | |
| }, | |
| HostKeyCallback: ssh.InsecureIgnoreHostKey(), | |
| Timeout: 5 * time.Second, | |
| } | |
| _, err = ssh.Dial("tcp", addr, clientConfig) | |
| if err == nil { | |
| t.Fatal("Dial(...) = _, nil; want error") | |
| } | |
| }) | |
| t.Run("wrong owner", func(t *testing.T) { | |
| ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| defer cancel() | |
| addr, sp, s := setupSSHServer(t, ctx) | |
| defer s.Close() | |
| ownerID := "accounts.google.com:userIDvalue" | |
| sessionID := sp.AddSession(ownerID, "maria", "linux-amd64", "xyz", "", &buildlet.FakeClient{}) | |
| certSigner := parsePrivateKey(t, []byte(devCertCAPrivate)) | |
| clientPubKey, err := SignPublicSSHKey(ctx, certSigner, []byte(devCertClientPublic), sessionID, ownerID+"WRONG", time.Minute) | |
| if err != nil { | |
| t.Fatalf("SignPublicSSHKey(...) = _, %s; want no error", err) | |
| } | |
| pubKey, _, _, _, err := ssh.ParseAuthorizedKey(clientPubKey) | |
| if err != nil { | |
| t.Fatalf("ParsePublicKey(...) = _, %s; want no error", err) | |
| } | |
| cert := pubKey.(*ssh.Certificate) | |
| clientCertSigner := parsePrivateKey(t, []byte(devCertClientPrivate)) | |
| clientSigner, err := ssh.NewCertSigner(cert, clientCertSigner) | |
| if err != nil { | |
| t.Fatalf("NewCertSigner(...) = _, %s; want no error", err) | |
| } | |
| clientConfig := &ssh.ClientConfig{ | |
| User: sessionID, | |
| Auth: []ssh.AuthMethod{ | |
| ssh.PublicKeys(clientSigner), | |
| }, | |
| HostKeyCallback: ssh.InsecureIgnoreHostKey(), | |
| Timeout: 5 * time.Second, | |
| } | |
| _, err = ssh.Dial("tcp", addr, clientConfig) | |
| if err == nil { | |
| t.Fatal("Dial(...) = _, nil; want error") | |
| } | |
| }) | |
| } | |
| func setupSSHServer(t *testing.T, ctx context.Context) (addr string, sp *SessionPool, s *SSHServer) { | |
| sp = NewSessionPool(ctx) | |
| l, err := nettest.NewLocalListener("tcp") | |
| if err != nil { | |
| t.Fatalf("nettest.NewLocalListener(tcp) = _, %s; want no error", err) | |
| } | |
| addr = l.Addr().String() | |
| s, err = NewSSHServer(addr, []byte(devCertAlternateClientPrivate), []byte(devCertCAPublic), []byte(devCertCAPrivate), sp) | |
| if err != nil { | |
| t.Fatalf("NewSSHServer(...) = %s; want no error", err) | |
| } | |
| go s.serve(l) | |
| if err != nil { | |
| t.Fatalf("server.serve(l) = %s; want no error", err) | |
| } | |
| return | |
| } | |
| func parsePrivateKey(t *testing.T, pemEncoded []byte) ssh.Signer { | |
| cert, err := ssh.ParsePrivateKey(pemEncoded) | |
| if err != nil { | |
| t.Fatalf("ssh.ParsePrivateKey() = _, %s; want no error", err) | |
| } | |
| return cert | |
| } | |
| const ( | |
| // devCertCAPrivate is a private SSH CA certificate to be used for development. | |
| devCertCAPrivate = `-----BEGIN OPENSSH PRIVATE KEY----- | |
| b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW | |
| QyNTUxOQAAACCVd2FJ3Db/oV53iRDt1RLscTn41hYXbunuCWIlXze2WAAAAJhjy3ePY8t3 | |
| jwAAAAtzc2gtZWQyNTUxOQAAACCVd2FJ3Db/oV53iRDt1RLscTn41hYXbunuCWIlXze2WA | |
| AAAEALuUJMb/rEaFNa+vn5RejeoBiiViyda7djgEvMnQ8fRJV3YUncNv+hXneJEO3VEuxx | |
| OfjWFhdu6e4JYiVfN7ZYAAAAE3Rlc3R1c2VyQGdvbGFuZy5vcmcBAg== | |
| -----END OPENSSH PRIVATE KEY-----` | |
| // devCertCAPublic is a public SSH CA certificate to be used for development. | |
| devCertCAPublic = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJV3YUncNv+hXneJEO3VEuxxOfjWFhdu6e4JYiVfN7ZY testuser@golang.org` | |
| // devCertClientPrivate is a private SSH certificate to be used for development. | |
| devCertClientPrivate = `-----BEGIN OPENSSH PRIVATE KEY----- | |
| b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW | |
| QyNTUxOQAAACBxCM6ADdHnjTIHG/IpMa3z32CLwtu3BDUR3k2NNbI3owAAAKDFZ7xtxWe8 | |
| bQAAAAtzc2gtZWQyNTUxOQAAACBxCM6ADdHnjTIHG/IpMa3z32CLwtu3BDUR3k2NNbI3ow | |
| AAAECidrOyYbTlYxyBSPP7W/UHk3Si2dgWSfkT+eEIETcvqHEIzoAN0eeNMgcb8ikxrfPf | |
| YIvC27cENRHeTY01sjejAAAAFnRlc3RfY2xpZW50QGdvbGFuZy5vcmcBAgMEBQYH | |
| -----END OPENSSH PRIVATE KEY-----` | |
| // devCertClientPublic is a public SSH certificate to be used for development. | |
| devCertClientPublic = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHEIzoAN0eeNMgcb8ikxrfPfYIvC27cENRHeTY01sjej test_client@golang.org` | |
| // devCertAlternateClientPrivate is a private SSH certificate to be used for development. | |
| devCertAlternateClientPrivate = `-----BEGIN OPENSSH PRIVATE KEY----- | |
| b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW | |
| QyNTUxOQAAACDOj8K2lbCSv+LojNcrUf0XH1vqknuEZBkAceiBHuNuEQAAAKDYNRtZ2DUb | |
| WQAAAAtzc2gtZWQyNTUxOQAAACDOj8K2lbCSv+LojNcrUf0XH1vqknuEZBkAceiBHuNuEQ | |
| AAAEDS4G3tQt5S4v7CD+DVyT/mwOKgIScIgFOpFt/EsCXL9M6PwraVsJK/4uiM1ytR/Rcf | |
| W+qSe4RkGQBx6IEe424RAAAAF3Rlc3RfZGlzY2FyZEBnb2xhbmcub3JnAQIDBAUG | |
| -----END OPENSSH PRIVATE KEY-----` | |
| // 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)})) | |
| } |