// Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause //go:build integrationtest // +build integrationtest package tailssh import ( "crypto/ecdsa" "crypto/ed25519" "crypto/elliptic" "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/pem" "fmt" "io" "log" "net" "net/http" "net/netip" "os" "runtime" "strings" "testing" "time" gossh "github.com/tailscale/golang-x-crypto/ssh" "golang.org/x/crypto/ssh" "tailscale.com/net/tsdial" "tailscale.com/tailcfg" "tailscale.com/types/key" "tailscale.com/types/netmap" ) // This file contains integration tests of the SSH functionality. These tests // exercise everything except for the authentication logic. // // The tests make the following assumptions about the environment: // // - OS is one of MacOS, Linux, FreeBSD or OpenBSD // - Test is being run as root (e.g. go test -tags integrationtest -c . && sudo ./tailssh.test -test.run TestIntegration) // - TAILSCALED_PATH environment variable points at tailscaled binary // - User "testuser" exists // - "testuser" is in groups "groupone" and "grouptwo" func TestIntegration(t *testing.T) { debugIncubator.Store(true) t.Cleanup(func() { debugIncubator.Store(false) }) homeDir := "/home/testuser" if runtime.GOOS == "darwin" { homeDir = "/Users/testuser" } tests := []struct { cmd string want []string }{ { cmd: "id", want: []string{"testuser", "groupone", "grouptwo"}, }, { cmd: "pwd", want: []string{homeDir}, }, { cmd: "pwd", want: []string{homeDir}, }, } for _, test := range tests { // run every test both without and with a shell for _, shell := range []bool{false, true} { shellQualifier := "no_shell" if shell { shellQualifier = "shell" } t.Run(fmt.Sprintf("%s_%s", test.cmd, shellQualifier), func(t *testing.T) { s := testSession(t) defer s.Close() if shell { err := s.RequestPty("xterm", 40, 80, ssh.TerminalModes{}) if err != nil { t.Fatalf("unable to request shell: %s", err) } } err := s.Start(test.cmd) if err != nil { t.Fatalf("unable to start command: %s", err) } ch := make(chan []byte) go func() { for { b := make([]byte, 1) n, err := s.stdout.Read(b) if n > 0 { ch <- b } if err == io.EOF { return } } }() // Read first byte in blocking fashion. _got := <-ch // Read subsequent bytes in non-blocking fashion. readLoop: for { select { case b := <-ch: _got = append(_got, b...) case <-time.After(25 * time.Millisecond): break readLoop } } got := string(_got) for _, want := range test.want { if !strings.Contains(got, want) { t.Errorf("%q does not contain %q", got, want) } } }) } } } type session struct { *ssh.Session stdin io.WriteCloser stdout io.ReadCloser stderr io.ReadCloser } func testSession(t *testing.T) *session { t.Helper() username := "testuser" srv := &server{ lb: &testBackend{localUser: username}, logf: log.Printf, tailscaledPath: os.Getenv("TAILSCALED_PATH"), timeNow: time.Now, } l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } t.Cleanup(func() { l.Close() }) go func() { conn, err := l.Accept() if err == nil { go srv.HandleSSHConn(&addressFakingConn{conn}) } }() cl, err := ssh.Dial("tcp", l.Addr().String(), &ssh.ClientConfig{ HostKeyCallback: ssh.InsecureIgnoreHostKey(), }) if err != nil { log.Fatal(err) } t.Cleanup(func() { cl.Close() }) s, err := cl.NewSession() if err != nil { log.Fatal(err) } stdinReader, stdinWriter := io.Pipe() stdoutReader, stdoutWriter := io.Pipe() stderrReader, stderrWriter := io.Pipe() s.Stdin = stdinReader s.Stdout = io.MultiWriter(stdoutWriter, os.Stdout) s.Stderr = io.MultiWriter(stderrWriter, os.Stderr) return &session{ Session: s, stdin: stdinWriter, stdout: stdoutReader, stderr: stderrReader, } } // testBackend implements ipnLocalBackend type testBackend struct { localUser string } func (tb *testBackend) GetSSH_HostKeys() ([]gossh.Signer, error) { var result []gossh.Signer for _, typ := range []string{"ed25519", "ecdsa", "rsa"} { var priv any var err error switch typ { case "ed25519": _, priv, err = ed25519.GenerateKey(rand.Reader) case "ecdsa": curve := elliptic.P256() priv, err = ecdsa.GenerateKey(curve, rand.Reader) case "rsa": const keySize = 2048 priv, err = rsa.GenerateKey(rand.Reader, keySize) } if err != nil { return nil, err } mk, err := x509.MarshalPKCS8PrivateKey(priv) if err != nil { return nil, err } hostKey := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: mk}) signer, err := gossh.ParsePrivateKey(hostKey) if err != nil { return nil, err } result = append(result, signer) } return result, nil } func (tb *testBackend) ShouldRunSSH() bool { return true } func (tb *testBackend) NetMap() *netmap.NetworkMap { return &netmap.NetworkMap{ SSHPolicy: &tailcfg.SSHPolicy{ Rules: []*tailcfg.SSHRule{ &tailcfg.SSHRule{ Principals: []*tailcfg.SSHPrincipal{{Any: true}}, Action: &tailcfg.SSHAction{Accept: true}, SSHUsers: map[string]string{"*": tb.localUser}, }, }, }, } } func (tb *testBackend) WhoIs(ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) { return (&tailcfg.Node{}).View(), tailcfg.UserProfile{ LoginName: tb.localUser + "@example.com", }, true } func (tb *testBackend) DoNoiseRequest(req *http.Request) (*http.Response, error) { return nil, nil } func (tb *testBackend) Dialer() *tsdial.Dialer { return nil } func (tb *testBackend) TailscaleVarRoot() string { return "" } func (tb *testBackend) NodeKey() key.NodePublic { return key.NodePublic{} } type addressFakingConn struct { net.Conn } func (conn *addressFakingConn) LocalAddr() net.Addr { return &net.TCPAddr{ IP: net.ParseIP("100.100.100.101"), Port: 22, } } func (conn *addressFakingConn) RemoteAddr() net.Addr { return &net.TCPAddr{ IP: net.ParseIP("100.100.100.102"), Port: 10002, } }