tstest/integration/vms: outgoing SSH test

This does two things:

1. Rewrites the tests so that we get a log of what individual tests
   failed at the end of a test run.
2. Adds a test that runs an SSH server via the tester tailscale node and
   then has the VMs connect to that over Tailscale.

This tests outgoing TCP connections.

Signed-off-by: Christine Dodrill <xe@tailscale.com>
This commit is contained in:
Christine Dodrill 2021-07-07 12:16:27 -04:00
parent ddb8726c98
commit 5f0799f935
2 changed files with 207 additions and 33 deletions

View File

@ -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)
}
}

View File

@ -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()