| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680 |
- // Copyright (c) Tailscale Inc & AUTHORS
- // SPDX-License-Identifier: BSD-3-Clause
- //go:build integrationtest
- // +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"
- gossh "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)
- }
- }
- 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 ipnLocalBackend
- type testBackend struct {
- localUser string
- forceV1Behavior bool
- allowSendEnv bool
- }
- func (tb *testBackend) GetSSH_HostKeys() ([]gossh.Signer, error) {
- var result []gossh.Signer
- var priv any
- var err error
- 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 {
- 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,
- }
- }
|