Something went wrong. Try again.
forked from tailscale/tailscale
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699// Copyright (c) Tailscale Inc & contributors// SPDX-License-Identifier: BSD-3-Clause
//go:build integrationtest
package tailssh
import ( "bufio" "bytes" "context" "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/pem" "errors" "fmt" "io" "log" "net" "net/http" "net/netip" "os" "os/exec" "path/filepath" "runtime" "strings" "testing" "time"
"github.com/bramvdbogaerde/go-scp" "github.com/google/go-cmp/cmp" "github.com/pkg/sftp" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/agent" "tailscale.com/net/tsdial" "tailscale.com/tailcfg" glider "tailscale.com/tempfork/gliderlabs/ssh" "tailscale.com/types/key" "tailscale.com/types/netmap" "tailscale.com/util/set")
// 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 or Linux// - 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 TestMain(m *testing.M) { // Create our log file. file, err := os.OpenFile("/tmp/tailscalessh.log", os.O_CREATE|os.O_WRONLY, 0666) if err != nil { log.Fatal(err) } file.Close()
// Tail our log file. cmd := exec.Command("tail", "-F", "/tmp/tailscalessh.log")
r, err := cmd.StdoutPipe() if err != nil { return }
scanner := bufio.NewScanner(r) go func() { for scanner.Scan() { line := scanner.Text() log.Println(line) } }()
err = cmd.Start() if err != nil { return } defer func() { // tail -f has a default sleep interval of 1 second, so it takes a // moment for it to finish reading our log file after we've terminated. // So, wait a bit to let it catch up. time.Sleep(2 * time.Second) }()
m.Run()}
func TestIntegrationSSH(t *testing.T) { debugTest.Store(true) t.Cleanup(func() { debugTest.Store(false) })
homeDir := "/home/testuser" if runtime.GOOS == "darwin" { homeDir = "/Users/testuser" }
tests := []struct { cmd string want []string forceV1Behavior bool skip bool allowSendEnv bool }{ { cmd: "id", want: []string{"testuser", "groupone", "grouptwo"}, forceV1Behavior: false, }, { cmd: "id", want: []string{"testuser", "groupone", "grouptwo"}, forceV1Behavior: true, }, { cmd: "pwd", want: []string{homeDir}, skip: os.Getenv("SKIP_FILE_OPS") == "1" || !fallbackToSUAvailable(), forceV1Behavior: false, }, { cmd: "echo 'hello'", want: []string{"hello"}, skip: os.Getenv("SKIP_FILE_OPS") == "1" || !fallbackToSUAvailable(), forceV1Behavior: false, }, { cmd: `echo "${GIT_ENV_VAR:-unset1} ${EXACT_MATCH:-unset2} ${TESTING:-unset3} ${NOT_ALLOWED:-unset4}"`, want: []string{"working1 working2 working3 unset4"}, forceV1Behavior: false, allowSendEnv: true, }, { cmd: `echo "${GIT_ENV_VAR:-unset1} ${EXACT_MATCH:-unset2} ${TESTING:-unset3} ${NOT_ALLOWED:-unset4}"`, want: []string{"unset1 unset2 unset3 unset4"}, forceV1Behavior: false, allowSendEnv: false, }, }
for _, test := range tests { if test.skip { continue }
// run every test both without and with a shell for _, shell := range []bool{false, true} { shellQualifier := "no_shell" if shell { shellQualifier = "shell" }
versionQualifier := "v2" if test.forceV1Behavior { versionQualifier = "v1" }
t.Run(fmt.Sprintf("%s_%s_%s", test.cmd, shellQualifier, versionQualifier), func(t *testing.T) { sendEnv := map[string]string{ "GIT_ENV_VAR": "working1", "EXACT_MATCH": "working2", "TESTING": "working3", "NOT_ALLOWED": "working4", } s := testSession(t, test.forceV1Behavior, test.allowSendEnv, sendEnv)
if shell { err := s.RequestPty("xterm", 40, 80, ssh.TerminalModes{ ssh.ECHO: 1, ssh.TTY_OP_ISPEED: 14400, ssh.TTY_OP_OSPEED: 14400, }) if err != nil { t.Fatalf("unable to request PTY: %s", err) }
err = s.Shell() if err != nil { t.Fatalf("unable to request shell: %s", err) }
// Read the shell prompt s.read() }
got := s.run(t, test.cmd, shell) for _, want := range test.want { if !strings.Contains(got, want) { t.Errorf("%q does not contain %q", got, want) } } }) } }}
func TestIntegrationSFTP(t *testing.T) { debugTest.Store(true) t.Cleanup(func() { debugTest.Store(false) })
for _, forceV1Behavior := range []bool{false, true} { name := "v2" if forceV1Behavior { name = "v1" } t.Run(name, func(t *testing.T) { filePath := "/home/testuser/sftptest.dat" if forceV1Behavior || !fallbackToSUAvailable() { filePath = "/tmp/sftptest.dat" } wantText := "hello world"
cl := testClient(t, forceV1Behavior, false) scl, err := sftp.NewClient(cl) if err != nil { t.Fatalf("can't get sftp client: %s", err) }
file, err := scl.Create(filePath) if err != nil { t.Fatalf("can't create file: %s", err) } _, err = file.Write([]byte(wantText)) if err != nil { t.Fatalf("can't write to file: %s", err) } err = file.Close() if err != nil { t.Fatalf("can't close file: %s", err) }
file, err = scl.OpenFile(filePath, os.O_RDONLY) if err != nil { t.Fatalf("can't open file: %s", err) } defer file.Close() gotText, err := io.ReadAll(file) if err != nil { t.Fatalf("can't read file: %s", err) } if diff := cmp.Diff(string(gotText), wantText); diff != "" { t.Fatalf("unexpected file contents (-got +want):\n%s", diff) }
s := testSessionFor(t, cl, nil) got := s.run(t, "ls -l "+filePath, false) if !strings.Contains(got, "testuser") { t.Fatalf("unexpected file owner user: %s", got) } else if !strings.Contains(got, "testuser") { t.Fatalf("unexpected file owner group: %s", got) } }) }}
func TestIntegrationSCP(t *testing.T) { debugTest.Store(true) t.Cleanup(func() { debugTest.Store(false) })
for _, forceV1Behavior := range []bool{false, true} { name := "v2" if forceV1Behavior { name = "v1" } t.Run(name, func(t *testing.T) { filePath := "/home/testuser/scptest.dat" if !fallbackToSUAvailable() { filePath = "/tmp/scptest.dat" } wantText := "hello world"
cl := testClient(t, forceV1Behavior, false) scl, err := scp.NewClientBySSH(cl) if err != nil { t.Fatalf("can't get sftp client: %s", err) }
err = scl.Copy(context.Background(), strings.NewReader(wantText), filePath, "0644", int64(len(wantText))) if err != nil { t.Fatalf("can't create file: %s", err) }
outfile, err := os.CreateTemp("", "") if err != nil { t.Fatalf("can't create temp file: %s", err) } err = scl.CopyFromRemote(context.Background(), outfile, filePath) if err != nil { t.Fatalf("can't copy file from remote: %s", err) } outfile.Close()
gotText, err := os.ReadFile(outfile.Name()) if err != nil { t.Fatalf("can't read file: %s", err) } if diff := cmp.Diff(string(gotText), wantText); diff != "" { t.Fatalf("unexpected file contents (-got +want):\n%s", diff) }
s := testSessionFor(t, cl, nil) got := s.run(t, "ls -l "+filePath, false) if !strings.Contains(got, "testuser") { t.Fatalf("unexpected file owner user: %s", got) } else if !strings.Contains(got, "testuser") { t.Fatalf("unexpected file owner group: %s", got) } }) }}
func TestSSHAgentForwarding(t *testing.T) { debugTest.Store(true) t.Cleanup(func() { debugTest.Store(false) })
// Create a client SSH key tmpDir, err := os.MkdirTemp("", "") if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = os.RemoveAll(tmpDir) }) pkFile := filepath.Join(tmpDir, "pk") clientKey, clientKeyRSA := generateClientKey(t, pkFile)
// Start upstream SSH server l, err := net.Listen("tcp", "127.0.0.1:") if err != nil { t.Fatalf("unable to listen for SSH: %s", err) } t.Cleanup(func() { _ = l.Close() })
// Run an SSH server that accepts connections from that client SSH key. gs := glider.Server{ Handler: func(s glider.Session) { io.WriteString(s, "Hello world\n") }, PublicKeyHandler: func(ctx glider.Context, key glider.PublicKey) error { // Note - this is not meant to be cryptographically secure, it's // just checking that SSH agent forwarding is forwarding the right // key. a := key.Marshal() b := clientKey.PublicKey().Marshal() if !bytes.Equal(a, b) { return errors.New("key mismatch") } return nil }, } go gs.Serve(l)
// Run tailscale SSH server and connect to it username := "testuser" tailscaleAddr := testServer(t, username, false, false) tcl, err := ssh.Dial("tcp", tailscaleAddr, &ssh.ClientConfig{ HostKeyCallback: ssh.InsecureIgnoreHostKey(), }) if err != nil { t.Fatal(err) } t.Cleanup(func() { tcl.Close() })
s, err := tcl.NewSession() if err != nil { t.Fatal(err) }
// Set up SSH agent forwarding on the client err = agent.RequestAgentForwarding(s) if err != nil { t.Fatal(err) }
keyring := agent.NewKeyring() keyring.Add(agent.AddedKey{ PrivateKey: clientKeyRSA, }) err = agent.ForwardToAgent(tcl, keyring) if err != nil { t.Fatal(err) }
// Attempt to SSH to the upstream test server using the forwarded SSH key // and run the "true" command. upstreamHost, upstreamPort, err := net.SplitHostPort(l.Addr().String()) if err != nil { t.Fatal(err) }
o, err := s.CombinedOutput(fmt.Sprintf(`ssh -T -o StrictHostKeyChecking=no -p %s upstreamuser@%s "true"`, upstreamPort, upstreamHost)) if err != nil { t.Fatalf("unable to call true command: %s\n%s\n-------------------------", err, o) }}
// TestIntegrationParamiko attempts to connect to Tailscale SSH using the// paramiko Python library. This library does not request 'none' auth. This// test ensures that Tailscale SSH can correctly handle clients that don't// request 'none' auth and instead immediately authenticate with a public key// or password.func TestIntegrationParamiko(t *testing.T) { debugTest.Store(true) t.Cleanup(func() { debugTest.Store(false) })
addr := testServer(t, "testuser", true, false) host, port, err := net.SplitHostPort(addr) if err != nil { t.Fatalf("Failed to split addr %q: %s", addr, err) }
out, err := exec.Command("python3", "-c", fmt.Sprintf(`import paramiko.client as pmfrom paramiko.ecdsakey import ECDSAKeyclient = pm.SSHClient()client.set_missing_host_key_policy(pm.AutoAddPolicy)client.connect('%s', port=%s, username='testuser', pkey=ECDSAKey.generate(), allow_agent=False, look_for_keys=False)client.exec_command('pwd')`, host, port)).CombinedOutput() if err != nil { t.Fatalf("failed to connect with Paramiko using public key auth: %s\n%q", err, string(out)) }
out, err = exec.Command("python3", "-c", fmt.Sprintf(`import paramiko.client as pmfrom paramiko.ecdsakey import ECDSAKeyclient = pm.SSHClient()client.set_missing_host_key_policy(pm.AutoAddPolicy)client.connect('%s', port=%s, username='testuser', password='doesntmatter', allow_agent=False, look_for_keys=False)client.exec_command('pwd')`, host, port)).CombinedOutput() if err != nil { t.Fatalf("failed to connect with Paramiko using password auth: %s\n%q", err, string(out)) }}
func fallbackToSUAvailable() bool { if runtime.GOOS != "linux" { return false }
_, err := exec.LookPath("su") if err != nil { return false }
// Some operating systems like Fedora seem to require login to be present // in order for su to work. _, err = exec.LookPath("login") return err == nil}
type session struct { *ssh.Session
stdin io.WriteCloser stdout io.ReadCloser stderr io.ReadCloser}
func (s *session) run(t *testing.T, cmdString string, shell bool) string { t.Helper()
if shell { _, err := s.stdin.Write([]byte(fmt.Sprintf("%s\n", cmdString))) if err != nil { t.Fatalf("unable to send command to shell: %s", err) } } else { err := s.Start(cmdString) if err != nil { t.Fatalf("unable to start command: %s", err) } }
return s.read()}
func (s *session) read() string { 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(1 * time.Second): break readLoop } }
return string(_got)}
func testClient(t *testing.T, forceV1Behavior bool, allowSendEnv bool, authMethods ...ssh.AuthMethod) *ssh.Client { t.Helper()
username := "testuser" addr := testServer(t, username, forceV1Behavior, allowSendEnv)
cl, err := ssh.Dial("tcp", addr, &ssh.ClientConfig{ HostKeyCallback: ssh.InsecureIgnoreHostKey(), Auth: authMethods, }) if err != nil { t.Fatal(err) } t.Cleanup(func() { cl.Close() })
return cl}
func testServer(t *testing.T, username string, forceV1Behavior bool, allowSendEnv bool) string { srv := &server{ lb: &testBackend{localUser: username, forceV1Behavior: forceV1Behavior, allowSendEnv: allowSendEnv}, 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() { for { conn, err := l.Accept() if err == nil { go srv.HandleSSHConn(&addressFakingConn{conn}) } } }()
return l.Addr().String()}
func testSession(t *testing.T, forceV1Behavior bool, allowSendEnv bool, sendEnv map[string]string) *session { cl := testClient(t, forceV1Behavior, allowSendEnv) return testSessionFor(t, cl, sendEnv)}
func testSessionFor(t *testing.T, cl *ssh.Client, sendEnv map[string]string) *session { s, err := cl.NewSession() if err != nil { t.Fatal(err) } for k, v := range sendEnv { s.Setenv(k, v) }
t.Cleanup(func() { s.Close() })
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, }}
func generateClientKey(t *testing.T, privateKeyFile string) (ssh.Signer, *rsa.PrivateKey) { t.Helper() priv, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } mk, err := x509.MarshalPKCS8PrivateKey(priv) if err != nil { t.Fatal(err) } privateKey := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: mk}) if privateKey == nil { t.Fatal("failed to encoded private key") } err = os.WriteFile(privateKeyFile, privateKey, 0600) if err != nil { t.Fatal(err) } signer, err := ssh.ParsePrivateKey(privateKey) if err != nil { t.Fatal(err) } return signer, priv}
// testBackend implements ipnLocalBackendtype testBackend struct { localUser string forceV1Behavior bool allowSendEnv bool}
func (tb *testBackend) ShouldRunSSH() bool { return true}
func (tb *testBackend) NetMap() *netmap.NetworkMap { capMap := make(set.Set[tailcfg.NodeCapability]) if tb.forceV1Behavior { capMap[tailcfg.NodeAttrSSHBehaviorV1] = struct{}{} } if tb.allowSendEnv { capMap[tailcfg.NodeAttrSSHEnvironmentVariables] = struct{}{} } return &netmap.NetworkMap{ SSHPolicy: &tailcfg.SSHPolicy{ Rules: []*tailcfg.SSHRule{ { Principals: []*tailcfg.SSHPrincipal{{Any: true}}, Action: &tailcfg.SSHAction{Accept: true, AllowAgentForwarding: true}, SSHUsers: map[string]string{"*": tb.localUser}, AcceptEnv: []string{"GIT_*", "EXACT_MATCH", "TEST?NG"}, }, }, }, AllCaps: capMap, }}
func (tb *testBackend) WhoIs(_ string, 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, }}