diff --git a/tstest/integration/vms/ssh_test.go b/tstest/integration/vms/ssh_test.go new file mode 100644 index 000000000..df12c595c --- /dev/null +++ b/tstest/integration/vms/ssh_test.go @@ -0,0 +1,137 @@ +// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +build linux + +package vms + +import ( + "bytes" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/terminal" + "tailscale.com/types/logger" +) + +func mkSSHServer(t *testing.T, hostKey ssh.Signer, bindhost string) string { + t.Helper() + + config := &ssh.ServerConfig{ + NoClientAuth: true, + AuthLogCallback: func(conn ssh.ConnMetadata, method string, err error) { + t.Logf("ssh server connection auth: %s, %s: %v", conn.RemoteAddr(), method, err) + }, + } + + config.AddHostKey(hostKey) + + lis, err := net.Listen("tcp", net.JoinHostPort(bindhost, "0")) + if err != nil { + t.Fatalf("can't listen on anonymous port: %v", err) + } + + t.Cleanup(func() { + t.Logf("closing socket") + lis.Close() + }) + + go func() { + for { + conn, err := lis.Accept() + if err != nil { + t.Logf("unexpected SSH connection error: %v", err) + return + } + + go func() { + sc, chanchan, reqchan, err := ssh.NewServerConn(conn, config) + if err != nil { + t.Logf("client can't register with ssh: %s: %v", conn.RemoteAddr(), err) + return + } + defer sc.Close() + + go ssh.DiscardRequests(reqchan) + + newChannel := <-chanchan + channel, requests, err := newChannel.Accept() + if err != nil { + t.Logf("can't accept channel from %s: %v", conn.RemoteAddr(), err) + return + } + t.Logf("%s: %s", conn.RemoteAddr(), newChannel.ChannelType()) + + go func(in <-chan *ssh.Request) { + for req := range in { + req.Reply(req.Type == "shell", nil) + } + }(requests) + + term := terminal.NewTerminal(channel, "> ") + + time.Sleep(time.Second) + fmt.Fprintln(term, "connection established") + channel.Close() + }() + } + }() + + return lis.Addr().String() +} + +func TestMkSSHServer(t *testing.T) { + dir := t.TempDir() + + run(t, dir, "ssh-keygen", "-t", "ed25519", "-f", "machinekey", "-N", ``) + + privateKey, err := os.ReadFile(filepath.Join(dir, "machinekey")) + if err != nil { + t.Fatalf("can't read ssh private key: %v", err) + } + + signer, err := ssh.ParsePrivateKey(privateKey) + if err != nil { + t.Fatalf("can't parse private key: %v", err) + } + + addr := mkSSHServer(t, signer, "::1") + t.Logf("connecting to %s", addr) + host, port, _ := net.SplitHostPort(addr) + + // NOTE(Xe): I tried to use go's SSH library for this but it just wouldn't work. + // The way my SSH server works is that it just spews stuff and kills the session + // afterwards. This is apparently in violation of how SSH servers are supposed + // to normally work, but the goal here is to get the text spewed back so it + // doesn't really matter. + cmd := exec.Command("ssh", + "-o", "StrictHostKeyChecking=no", + "-o", "GlobalKnownHostsFile=/dev/null", + "-o", "UserKnownHostsFile=/dev/null", + host, "-p", port, + ) + buf := bytes.NewBuffer(nil) + cmd.Stdout = io.MultiWriter(buf, logger.FuncWriter(t.Logf)) + err = cmd.Run() + if err != nil { + if eerr, ok := err.(*exec.ExitError); ok { + if eerr.ExitCode() != 255 { + t.Fatalf("can't ssh into %s: %v", addr, err) + } + } else { + t.Fatalf("can't ssh into %s: %v", addr, err) + } + } + + if !bytes.Contains(buf.Bytes(), []byte("connection established")) { + t.Fatalf("wanted \"connection established\" from ssh server, got: %q", buf) + } +} diff --git a/tstest/integration/vms/vms_test.go b/tstest/integration/vms/vms_test.go index 4522a5f6e..7541a8c8a 100644 --- a/tstest/integration/vms/vms_test.go +++ b/tstest/integration/vms/vms_test.go @@ -716,6 +716,75 @@ func testDistro(t *testing.T, loginServer string, d Distro, signer ssh.Signer, i timeout := 30 * time.Second + t.Run("start-tailscale", func(t *testing.T) { + var batch = []expect.Batcher{ + &expect.BExp{R: `(\#)`}, + } + + switch d.initSystem { + case "openrc": + // NOTE(Xe): this is a sin, however openrc doesn't really have the concept + // of service readiness. If this sleep is removed then tailscale will not be + // ready once the `tailscale up` command is sent. This is not ideal, but I + // am not really sure there is a good way around this without a delay of + // some kind. + batch = append(batch, &expect.BSnd{S: "rc-service tailscaled start && sleep 2\n"}) + case "systemd": + batch = append(batch, &expect.BSnd{S: "systemctl start tailscaled.service\n"}) + } + + batch = append(batch, &expect.BExp{R: `(\#)`}) + + runTestCommands(t, timeout, cli, batch) + }) + + t.Run("login", func(t *testing.T) { + runTestCommands(t, timeout, cli, []expect.Batcher{ + &expect.BSnd{S: fmt.Sprintf("tailscale up --login-server=%s\n", loginServer)}, + &expect.BExp{R: `Success.`}, + }) + }) + + t.Run("tailscale status", func(t *testing.T) { + runTestCommands(t, timeout, cli, []expect.Batcher{ + &expect.BSnd{S: "sleep 5 && tailscale status\n"}, + &expect.BExp{R: `100.64.0.1`}, + &expect.BExp{R: `(\#)`}, + }) + }) + + t.Run("ping-ipv4", func(t *testing.T) { + runTestCommands(t, timeout, cli, []expect.Batcher{ + &expect.BSnd{S: "tailscale ping -c 1 100.64.0.1\n"}, + &expect.BExp{R: `pong from.*\(100.64.0.1\)`}, + &expect.BSnd{S: "ping -c 1 100.64.0.1\n"}, + &expect.BExp{R: `bytes`}, + }) + }) + + // This test spawns a test-local SSH server that requires no authentication and + // returns "connection established" to every request fired at it. This allows us + // to ensure that outgoing TCP works over Tailscale. + // + // NOTE(Xe): SSH was chosen for this test because SSH is universal across all of + // the images that we will run this against. Admittedly it is kind of an odd + // choice, however it more closely reflects real-world usage of Tailscale. + t.Run("outgoing-ssh-ipv4", func(t *testing.T) { + sshServer := mkSSHServer(t, signer, "::") + _, port, _ := net.SplitHostPort(sshServer) + runTestCommands(t, timeout, cli, []expect.Batcher{ + &expect.BSnd{ + S: fmt.Sprintf( + "ssh -o StrictHostKeyChecking=no -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=/dev/null %s -p %s\n", + "100.64.0.1", + port, + )}, + &expect.BExp{R: `connection established`}, + }) + }) +} + +func runTestCommands(t *testing.T, timeout time.Duration, cli *ssh.Client, batch []expect.Batcher) { e, _, err := expect.SpawnSSH(cli, timeout, expect.Verbose(true), expect.VerboseWriter(logger.FuncWriter(t.Logf)), @@ -725,42 +794,10 @@ func testDistro(t *testing.T, loginServer string, d Distro, signer ssh.Signer, i // expect.Tee(nopWriteCloser{logger.FuncWriter(t.Logf)}), ) if err != nil { - t.Fatalf("%d: can't register a shell session: %v", port, err) + t.Fatalf("%s: can't register a shell session: %v", cli.RemoteAddr(), err) } defer e.Close() - t.Log("opened session") - - var batch = []expect.Batcher{ - &expect.BSnd{S: "PS1='# '\n"}, - &expect.BExp{R: `(\#)`}, - } - - switch d.initSystem { - case "openrc": - // NOTE(Xe): this is a sin, however openrc doesn't really have the concept - // of service readiness. If this sleep is removed then tailscale will not be - // ready once the `tailscale up` command is sent. This is not ideal, but I - // am not really sure there is a good way around this without a delay of - // some kind. - batch = append(batch, &expect.BSnd{S: "rc-service tailscaled start && sleep 2\n"}) - case "systemd": - batch = append(batch, &expect.BSnd{S: "systemctl start tailscaled.service\n"}) - } - - batch = append(batch, - &expect.BExp{R: `(\#)`}, - &expect.BSnd{S: fmt.Sprintf("tailscale up --login-server=%s\n", loginServer)}, - &expect.BExp{R: `Success.`}, - &expect.BSnd{S: "sleep 5 && tailscale status\n"}, - &expect.BExp{R: `100.64.0.1`}, - &expect.BExp{R: `(\#)`}, - &expect.BSnd{S: "tailscale ping -c 1 100.64.0.1\n"}, - &expect.BExp{R: `pong from.*\(100.64.0.1\)`}, - &expect.BSnd{S: "ping -c 1 100.64.0.1\n"}, - &expect.BExp{R: `bytes`}, - ) - _, err = e.ExpectBatch(batch, timeout) if err != nil { sess, terr := cli.NewSession()