// Copyright 2025 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 test import ( "bytes" "errors" "fmt" "io" "net" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "sync" "testing" "time" "golang.org/x/crypto/internal/testenv" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/testdata" ) // serverPort contains the port that OpenSSH will listen on. OpenSSH can't take // "0" as an argument here so we have to pick a number and hope that it's not in // use on the machine. Since this only occurs when -update is given and thus // when there's a human watching the test, this isn't too bad. const serverPort = 24222 var ( storeUsernameOnce sync.Once ) type clientTest struct { // name is a freeform string identifying the test and the file in which // the expected results will be stored. name string // config contains the client configuration to use for this test. config *ssh.ClientConfig // expectError defines the error string to check if the connection is // expected to fail. expectError string // successCallback defines a callback to execute after the client connection // is established. successCallback func(t *testing.T, client *ssh.Client) } // connFromCommand starts the reference server process, connects to it and // returns a recordingConn for the connection. It must be closed before Waiting // for child. func (test *clientTest) connFromCommand(t *testing.T, config string) *recordingConn { sshd, err := exec.LookPath("sshd") if err != nil { t.Skipf("sshd not found, skipping test: %v", err) } dir, err := os.MkdirTemp("", "sshtest") if err != nil { t.Fatal(err) } f, err := os.Create(filepath.Join(dir, "sshd_config")) if err != nil { t.Fatal(err) } if _, ok := configTmpl[config]; ok == false { t.Fatal(fmt.Errorf("Invalid server config '%s'", config)) } configVars := map[string]string{ "Dir": dir, } err = configTmpl[config].Execute(f, configVars) if err != nil { t.Fatal(err) } f.Close() writeFile(filepath.Join(dir, "banner"), []byte("Server Banner")) for k, v := range testdata.PEMBytes { filename := "id_" + k writeFile(filepath.Join(dir, filename), v) writeFile(filepath.Join(dir, filename+".pub"), ssh.MarshalAuthorizedKey(testPublicKeys[k])) } var authkeys bytes.Buffer for k := range testdata.PEMBytes { authkeys.Write(ssh.MarshalAuthorizedKey(testPublicKeys[k])) } writeFile(filepath.Join(dir, "authorized_keys"), authkeys.Bytes()) cmd := testenv.Command(t, sshd, "-D", "-e", "-f", f.Name(), "-p", strconv.Itoa(serverPort)) cmd.Stdin = nil var output bytes.Buffer cmd.Stdout = &output cmd.Stderr = &output if err := cmd.Start(); err != nil { t.Fatal(err) } t.Cleanup(func() { if err := os.RemoveAll(dir); err != nil { t.Error(err) } // Don't check for errors; if it fails it's most // likely "os: process already finished", and we don't // care about that. Use os.Interrupt, so child // processes are killed too. cmd.Process.Signal(os.Interrupt) cmd.Wait() if t.Failed() { t.Logf("OpenSSH output:\n\n%s", cmd.Stdout) } }) var tcpConn net.Conn for i := uint(0); i < 5; i++ { tcpConn, err = net.DialTCP("tcp", nil, &net.TCPAddr{ IP: net.IPv4(127, 0, 0, 1), Port: serverPort, }) if err == nil { break } time.Sleep((1 << i) * 5 * time.Millisecond) } if err != nil { t.Fatalf("error connecting to the OpenSSH server: %v (%v)\n\n%s", err, cmd.Wait(), output.Bytes()) } record := &recordingConn{ Conn: tcpConn, clientToServer: true, } return record } func (test *clientTest) dataPath() string { return filepath.Join("..", "testdata", "Client-"+test.name) } func (test *clientTest) usernameDataPath() string { return filepath.Join("..", "testdata", "Client-username") } func (test *clientTest) loadData() (flows [][]byte, err error) { in, err := os.Open(test.dataPath()) if err != nil { return nil, err } defer in.Close() return parseTestData(in) } func (test *clientTest) storeUsername() (err error) { storeUsernameOnce.Do(func() { err = os.WriteFile(test.usernameDataPath(), []byte(username()), 0666) }) return err } func (test *clientTest) loadUsername() (string, error) { data, err := os.ReadFile(test.usernameDataPath()) return string(data), err } func (test *clientTest) run(t *testing.T, write bool) { var clientConn net.Conn var recordingConn *recordingConn setDeterministicRandomSource(&test.config.Config) if write { // We store the username used when we record the connection so we can // reuse the same username when running tests. if err := test.storeUsername(); err != nil { t.Fatalf("failed to store username to %q: %v", test.usernameDataPath(), err) } recordingConn = test.connFromCommand(t, "default") clientConn = recordingConn } else { username, err := test.loadUsername() if err != nil { t.Fatalf("failed to load username from %q: %v", test.usernameDataPath(), err) } test.config.User = username timer := time.AfterFunc(10*time.Second, func() { fmt.Println("This test may be stuck, try running using -timeout 10s") }) t.Cleanup(func() { timer.Stop() }) flows, err := test.loadData() if err != nil { t.Fatalf("failed to load data from %s: %v", test.dataPath(), err) } clientConn = newReplayingConn(t, flows) } c, chans, reqs, err := ssh.NewClientConn(clientConn, "", test.config) if err != nil { if test.expectError == "" { t.Fatal(err) } else { if !strings.Contains(err.Error(), test.expectError) { t.Fatalf("%q not found in %v", test.expectError, err) } } } else { if test.expectError != "" { t.Error("dial should have failed.") } client := ssh.NewClient(c, chans, reqs) if test.successCallback != nil { test.successCallback(t, client) } if err := client.Close(); err != nil { t.Fatal(err) } } if write { path := test.dataPath() out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) if err != nil { t.Fatalf("Failed to create output file: %v", err) } defer out.Close() recordingConn.Close() recordingConn.WriteTo(out) t.Logf("Wrote %s\n", path) } } func recordingsClientConfig() *ssh.ClientConfig { config := clientConfig() config.SetDefaults() // Remove ML-KEM since it only works with Go 1.24. if config.KeyExchanges[0] == ssh.KeyExchangeMLKEM768X25519 { config.KeyExchanges = config.KeyExchanges[1:] } config.Auth = []ssh.AuthMethod{ ssh.PublicKeys(testSigners["rsa"]), } return config } func TestClientKeyExchanges(t *testing.T) { config := ssh.ClientConfig{} config.SetDefaults() var keyExchanges []string for _, kex := range config.KeyExchanges { // Exclude ecdh for now, to make them deterministic we should use see a // stream of fixed bytes as the random source. if !strings.HasPrefix(kex, "ecdh-") { keyExchanges = append(keyExchanges, kex) } } // Add diffie-hellman-group-exchange-sha256 and // diffie-hellman-group16-sha512 as they are not enabled by default. keyExchanges = append(keyExchanges, "diffie-hellman-group-exchange-sha256", "diffie-hellman-group16-sha512") for _, kex := range keyExchanges { c := recordingsClientConfig() c.KeyExchanges = []string{kex} test := clientTest{ name: "KEX-" + kex, config: c, } runTestAndUpdateIfNeeded(t, test.name, test.run) } } func TestClientCiphers(t *testing.T) { config := ssh.ClientConfig{} config.SetDefaults() for _, ciph := range config.Ciphers { c := recordingsClientConfig() c.Ciphers = []string{ciph} test := clientTest{ name: "Cipher-" + ciph, config: c, } runTestAndUpdateIfNeeded(t, test.name, test.run) } } func TestClientMACs(t *testing.T) { config := ssh.ClientConfig{} config.SetDefaults() for _, mac := range config.MACs { c := recordingsClientConfig() c.MACs = []string{mac} test := clientTest{ name: "MAC-" + mac, config: c, } runTestAndUpdateIfNeeded(t, test.name, test.run) } } func TestBannerCallback(t *testing.T) { var receivedBanner string config := recordingsClientConfig() config.BannerCallback = func(message string) error { receivedBanner = message return nil } test := clientTest{ name: "BannerCallback", config: config, successCallback: func(t *testing.T, client *ssh.Client) { expected := "Server Banner" if receivedBanner != expected { t.Fatalf("got %v; want %v", receivedBanner, expected) } }, } runTestAndUpdateIfNeeded(t, test.name, test.run) } func TestRunCommandSuccess(t *testing.T) { if runtime.GOARCH == "wasm" { t.Skip("skipping test, executing a command, session.Run(), is not supported on wasm") } test := clientTest{ name: "RunCommandSuccess", config: recordingsClientConfig(), successCallback: func(t *testing.T, client *ssh.Client) { session, err := client.NewSession() if err != nil { t.Fatalf("session failed: %v", err) } defer session.Close() err = session.Run("true") if err != nil { t.Fatalf("session failed: %v", err) } }, } runTestAndUpdateIfNeeded(t, test.name, test.run) } func TestHostKeyCheck(t *testing.T) { config := recordingsClientConfig() hostDB := hostKeyDB() config.HostKeyCallback = hostDB.Check // change the keys. hostDB.keys[ssh.KeyAlgoRSA][25]++ hostDB.keys[ssh.InsecureKeyAlgoDSA][25]++ hostDB.keys[ssh.KeyAlgoECDSA256][25]++ test := clientTest{ name: "HostKeyCheck", config: config, expectError: "host key mismatch", } runTestAndUpdateIfNeeded(t, test.name, test.run) } func TestRunCommandStdin(t *testing.T) { if runtime.GOARCH == "wasm" { t.Skip("skipping test, executing a command, session.Run(), is not supported on wasm") } test := clientTest{ name: "RunCommandStdin", config: recordingsClientConfig(), successCallback: func(t *testing.T, client *ssh.Client) { session, err := client.NewSession() if err != nil { t.Fatalf("session failed: %v", err) } defer session.Close() r, w := io.Pipe() defer r.Close() defer w.Close() session.Stdin = r err = session.Run("true") if err != nil { t.Fatalf("session failed: %v", err) } }, } runTestAndUpdateIfNeeded(t, test.name, test.run) } func TestRunCommandStdinError(t *testing.T) { if runtime.GOARCH == "wasm" { t.Skip("skipping test, executing a command, session.Run(), is not supported on wasm") } test := clientTest{ name: "RunCommandStdinError", config: recordingsClientConfig(), successCallback: func(t *testing.T, client *ssh.Client) { session, err := client.NewSession() if err != nil { t.Fatalf("session failed: %v", err) } defer session.Close() r, w := io.Pipe() defer r.Close() session.Stdin = r pipeErr := errors.New("closing write end of pipe") w.CloseWithError(pipeErr) err = session.Run("true") if err != pipeErr { t.Fatalf("expected %v, found %v", pipeErr, err) } }, } runTestAndUpdateIfNeeded(t, test.name, test.run) } func TestRunCommandFailed(t *testing.T) { if runtime.GOARCH == "wasm" { t.Skip("skipping test, executing a command, session.Run(), is not supported on wasm") } test := clientTest{ name: "RunCommandFailed", config: recordingsClientConfig(), successCallback: func(t *testing.T, client *ssh.Client) { session, err := client.NewSession() if err != nil { t.Fatalf("session failed: %v", err) } defer session.Close() // Trigger a failure by attempting to execute a non-existent // command. err = session.Run(`non-existent command`) if err == nil { t.Fatalf("session succeeded: %v", err) } }, } runTestAndUpdateIfNeeded(t, test.name, test.run) } func TestWindowChange(t *testing.T) { if runtime.GOARCH == "wasm" { t.Skip("skipping test, stdin/out are not supported on wasm") } test := clientTest{ name: "WindowChange", config: recordingsClientConfig(), successCallback: func(t *testing.T, client *ssh.Client) { session, err := client.NewSession() if err != nil { t.Fatalf("session failed: %v", err) } defer session.Close() stdout, err := session.StdoutPipe() if err != nil { t.Fatalf("unable to acquire stdout pipe: %s", err) } stdin, err := session.StdinPipe() if err != nil { t.Fatalf("unable to acquire stdin pipe: %s", err) } tm := ssh.TerminalModes{ssh.ECHO: 0} if err = session.RequestPty("xterm", 80, 40, tm); err != nil { t.Fatalf("req-pty failed: %s", err) } if err := session.WindowChange(100, 100); err != nil { t.Fatalf("window-change failed: %s", err) } err = session.Shell() if err != nil { t.Fatalf("session failed: %s", err) } stdin.Write([]byte("stty size && exit\n")) var buf bytes.Buffer if _, err := io.Copy(&buf, stdout); err != nil { t.Fatalf("reading failed: %s", err) } if sttyOutput := buf.String(); !strings.Contains(sttyOutput, "100 100") { t.Fatalf("terminal WindowChange failure: expected \"100 100\" stty output, got %s", sttyOutput) } }, } runTestAndUpdateIfNeeded(t, test.name, test.run) }