tstest, cmd/tta: add Tailscale SSH end-to-end VM test
Add TestTailscaleSSH to tstest/natlab/vmtest, exercising the Tailscale SSH server (tailscale up --ssh, not a system sshd) end to end: an Ubuntu client node SSHes over the tailnet into an Ubuntu server node as both root and a non-root user, and into a gokrazy node. The gokrazy sessions exercise the gokrazy special cases in the SSH code: util/osuser hard-codes the login shell to serial-busybox ash and synthesizes a root user when lookup fails (so any username works and becomes root, unlike Ubuntu where nonexistent users are rejected), and the incubator's findSU refuses su on gokrazy, handling sessions in-process. To support this, testcontrol gains an SSHPolicy field that's sent in MapResponses along with the CapabilitySSH node capability, tta's /up handler accepts an ssh=true parameter, and vmtest gains a TailscaleSSH node option that wires the two together with a permissive any-principal policy. Updates tailscale/corp#44813 Updates #13038 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com> Change-Id: I3f6b9c41a72e05d8c94dd7f6ab1937cf24b81c92
This commit is contained in:
committed by
Brad Fitzpatrick
parent
2506ede862
commit
c436dec43c
@@ -198,6 +198,9 @@ func main() {
|
|||||||
if r.URL.Query().Get("accept-routes") == "true" {
|
if r.URL.Query().Get("accept-routes") == "true" {
|
||||||
args = append(args, "--accept-routes")
|
args = append(args, "--accept-routes")
|
||||||
}
|
}
|
||||||
|
if r.URL.Query().Get("ssh") == "true" {
|
||||||
|
args = append(args, "--ssh")
|
||||||
|
}
|
||||||
serveCmd(w, "tailscale", args...)
|
serveCmd(w, "tailscale", args...)
|
||||||
})
|
})
|
||||||
ttaMux.HandleFunc("/set", func(w http.ResponseWriter, r *http.Request) {
|
ttaMux.HandleFunc("/set", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ type Server struct {
|
|||||||
// grants rules.
|
// grants rules.
|
||||||
PeerRelayGrants bool
|
PeerRelayGrants bool
|
||||||
|
|
||||||
|
// SSHPolicy, if non-nil, is sent to every node in MapResponses.
|
||||||
|
// Each node also gets [tailcfg.CapabilitySSH] added to its capability
|
||||||
|
// map, permitting "tailscale up --ssh".
|
||||||
|
SSHPolicy *tailcfg.SSHPolicy
|
||||||
|
|
||||||
// AllNodesSameUser, if true, makes all created nodes
|
// AllNodesSameUser, if true, makes all created nodes
|
||||||
// belong to the same user.
|
// belong to the same user.
|
||||||
AllNodesSameUser bool
|
AllNodesSameUser bool
|
||||||
@@ -1612,10 +1617,14 @@ func (s *Server) MapResponse(req *tailcfg.MapRequest) (res *tailcfg.MapResponse,
|
|||||||
dns = s.DNSConfig.Clone()
|
dns = s.DNSConfig.Clone()
|
||||||
}
|
}
|
||||||
magicDNSDomain := s.MagicDNSDomain
|
magicDNSDomain := s.MagicDNSDomain
|
||||||
|
sshPolicy := s.SSHPolicy.Clone()
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
node.CapMap = nodeCapMap
|
node.CapMap = nodeCapMap
|
||||||
node.Capabilities = append(node.Capabilities, tailcfg.NodeAttrDisableUPnP)
|
node.Capabilities = append(node.Capabilities, tailcfg.NodeAttrDisableUPnP)
|
||||||
|
if sshPolicy != nil {
|
||||||
|
mak.Set(&node.CapMap, tailcfg.CapabilitySSH, nil)
|
||||||
|
}
|
||||||
|
|
||||||
t := time.Date(2020, 8, 3, 0, 0, 0, 1, time.UTC)
|
t := time.Date(2020, 8, 3, 0, 0, 0, 1, time.UTC)
|
||||||
if dns != nil && magicDNSDomain != "" {
|
if dns != nil && magicDNSDomain != "" {
|
||||||
@@ -1629,6 +1638,7 @@ func (s *Server) MapResponse(req *tailcfg.MapRequest) (res *tailcfg.MapResponse,
|
|||||||
CollectServices: cmp.Or(s.CollectServices, opt.True),
|
CollectServices: cmp.Or(s.CollectServices, opt.True),
|
||||||
PacketFilter: packetFilterWithIngress(s.PeerRelayGrants),
|
PacketFilter: packetFilterWithIngress(s.PeerRelayGrants),
|
||||||
DNSConfig: dns,
|
DNSConfig: dns,
|
||||||
|
SSHPolicy: sshPolicy,
|
||||||
ControlTime: &t,
|
ControlTime: &t,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package vmtest_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/creachadair/mds/shell"
|
||||||
|
"tailscale.com/tstest/natlab/vmtest"
|
||||||
|
"tailscale.com/tstest/natlab/vnet"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestTailscaleSSH exercises the Tailscale SSH server ("tailscale up --ssh",
|
||||||
|
// not a system sshd) on both Ubuntu and gokrazy nodes, with an Ubuntu node
|
||||||
|
// as the SSH client.
|
||||||
|
//
|
||||||
|
// On Ubuntu it tests logging in as both root and a non-root user, which
|
||||||
|
// exercises the incubator's su-based login path.
|
||||||
|
//
|
||||||
|
// On gokrazy it exercises the gokrazy special cases in the SSH code:
|
||||||
|
// util/osuser hard-codes the login shell to /tmp/serial-busybox/ash and
|
||||||
|
// falls back to a synthesized root user (uid 0, home dir "/") when user
|
||||||
|
// lookup fails, since gokrazy has no user database; and the incubator's
|
||||||
|
// findSU refuses to use su on gokrazy, so sessions are handled in-process.
|
||||||
|
func TestTailscaleSSH(t *testing.T) {
|
||||||
|
env := vmtest.New(t)
|
||||||
|
client := sshTestNode(env, "client", vmtest.Ubuntu2404)
|
||||||
|
ubuntu := sshTestNode(env, "ubuntu", vmtest.Ubuntu2404, vmtest.TailscaleSSH())
|
||||||
|
gokrazy := sshTestNode(env, "gokrazy", vmtest.Gokrazy, vmtest.TailscaleSSH())
|
||||||
|
env.Start()
|
||||||
|
|
||||||
|
if out, err := env.SSHExec(ubuntu, "useradd -m -s /bin/bash tsuser"); err != nil {
|
||||||
|
t.Fatalf("useradd: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
ubuntuIP := tailscaleIP4(t, env, ubuntu)
|
||||||
|
gokrazyIP := tailscaleIP4(t, env, gokrazy)
|
||||||
|
|
||||||
|
// ssh runs cmd as user on the node at ip over Tailscale SSH, from the
|
||||||
|
// client node, returning the combined output and the ssh client's exit
|
||||||
|
// code. The command run via Env.SSHExec always exits 0 so that its
|
||||||
|
// transport-error (exit 255) retry loop doesn't kick in when a
|
||||||
|
// Tailscale SSH connection is expected to fail.
|
||||||
|
const exitMarker = "tailscale-ssh-exit="
|
||||||
|
ssh := func(user string, ip netip.Addr, cmd string) (string, int) {
|
||||||
|
t.Helper()
|
||||||
|
inner := "ssh" +
|
||||||
|
" -o StrictHostKeyChecking=no" +
|
||||||
|
" -o UserKnownHostsFile=/dev/null" +
|
||||||
|
" -o BatchMode=yes" +
|
||||||
|
" -o ConnectTimeout=10" +
|
||||||
|
" -o LogLevel=ERROR" +
|
||||||
|
" " + user + "@" + ip.String() +
|
||||||
|
" " + shell.Quote(cmd)
|
||||||
|
out, err := env.SSHExec(client, inner+" 2>&1; echo "+exitMarker+"$?")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ssh %s@%s: %v\n%s", user, ip, err, out)
|
||||||
|
}
|
||||||
|
i := strings.LastIndex(out, exitMarker)
|
||||||
|
if i == -1 {
|
||||||
|
t.Fatalf("ssh %s@%s: no exit marker in output:\n%s", user, ip, out)
|
||||||
|
}
|
||||||
|
code, err := strconv.Atoi(strings.TrimSpace(out[i+len(exitMarker):]))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ssh %s@%s: bad exit marker in output:\n%s", user, ip, out)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(out[:i]), code
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitSSH waits for the node's Tailscale SSH server to accept a
|
||||||
|
// connection; the first one can race tailscaled's SSH server startup
|
||||||
|
// and WireGuard path setup.
|
||||||
|
waitSSH := func(name, user string, ip netip.Addr) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Minute)
|
||||||
|
for {
|
||||||
|
out, code := ssh(user, ip, "echo ok")
|
||||||
|
if code == 0 && strings.Contains(out, "ok") {
|
||||||
|
t.Logf("[%s] Tailscale SSH up as %s@%s", name, user, ip)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("[%s] Tailscale SSH as %s@%s never came up; last output (exit %d):\n%s", name, user, ip, code, out)
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
waitSSH("ubuntu", "root", ubuntuIP)
|
||||||
|
waitSSH("gokrazy", "root", gokrazyIP)
|
||||||
|
|
||||||
|
check := func(desc, user string, ip netip.Addr, cmd, want string) {
|
||||||
|
t.Helper()
|
||||||
|
out, code := ssh(user, ip, cmd)
|
||||||
|
if code != 0 {
|
||||||
|
t.Errorf("%s: ssh %s@%s %q exited %d:\n%s", desc, user, ip, cmd, code, out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if out != want {
|
||||||
|
t.Errorf("%s: ssh %s@%s %q = %q, want %q", desc, user, ip, cmd, out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
check("ubuntu root login", "root", ubuntuIP, "id -un && pwd", "root\n/root")
|
||||||
|
check("ubuntu non-root login", "tsuser", ubuntuIP, "id -un && pwd", "tsuser\n/home/tsuser")
|
||||||
|
|
||||||
|
// A nonexistent user is rejected on Ubuntu...
|
||||||
|
if out, code := ssh("nosuchuser", ubuntuIP, "true"); code == 0 {
|
||||||
|
t.Errorf("ubuntu nonexistent user: ssh succeeded, want failure:\n%s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... but on gokrazy any username works and becomes root, via the
|
||||||
|
// synthesized-user fallback in util/osuser (uid 0, home dir "/").
|
||||||
|
// $0 is the login shell the session ran the command with, which on
|
||||||
|
// gokrazy is hard-coded rather than looked up with getent. These
|
||||||
|
// sessions also implicitly exercise the incubator's su-less
|
||||||
|
// in-process path, since gokrazy has no su.
|
||||||
|
check("gokrazy root login shell", "root", gokrazyIP, "echo $0", "/tmp/serial-busybox/ash")
|
||||||
|
check("gokrazy nonexistent user maps to root", "nosuchuser", gokrazyIP, "pwd", "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// sshTestNode adds a node named name running img behind its own
|
||||||
|
// easy-NAT network.
|
||||||
|
func sshTestNode(env *vmtest.Env, name string, img vmtest.OSImage, opts ...any) *vmtest.Node {
|
||||||
|
n := env.NumNodes()
|
||||||
|
opts = append([]any{
|
||||||
|
env.AddNetwork(
|
||||||
|
fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP
|
||||||
|
fmt.Sprintf("192.168.%d.1/24", n), vnet.EasyNAT),
|
||||||
|
vmtest.OS(img),
|
||||||
|
}, opts...)
|
||||||
|
return env.AddNode(name, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tailscaleIP4 returns the node's IPv4 Tailscale address.
|
||||||
|
func tailscaleIP4(t *testing.T, env *vmtest.Env, n *vmtest.Node) netip.Addr {
|
||||||
|
t.Helper()
|
||||||
|
st := env.Status(n)
|
||||||
|
for _, ip := range st.Self.TailscaleIPs {
|
||||||
|
if ip.Is4() {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("no IPv4 Tailscale address for %s; have %v", n.Name(), st.Self.TailscaleIPs)
|
||||||
|
panic("unreachable")
|
||||||
|
}
|
||||||
@@ -428,6 +428,7 @@ type Node struct {
|
|||||||
vnetNode *vnet.Node // primary vnet node (set during Start)
|
vnetNode *vnet.Node // primary vnet node (set during Start)
|
||||||
agent *vnet.NodeAgentClient
|
agent *vnet.NodeAgentClient
|
||||||
joinTailnet bool
|
joinTailnet bool
|
||||||
|
runSSH bool // true to enable the node's Tailscale SSH server
|
||||||
noAgent bool // true to skip TTA agent setup (e.g. macOS VMs without TTA)
|
noAgent bool // true to skip TTA agent setup (e.g. macOS VMs without TTA)
|
||||||
advertiseRoutes string
|
advertiseRoutes string
|
||||||
snatSubnetRoutes *bool // nil means default (true)
|
snatSubnetRoutes *bool // nil means default (true)
|
||||||
@@ -458,6 +459,8 @@ func (e *Env) AddNode(name string, opts ...any) *Node {
|
|||||||
case nodeOptNoTailscale:
|
case nodeOptNoTailscale:
|
||||||
n.joinTailnet = false
|
n.joinTailnet = false
|
||||||
vnetOpts = append(vnetOpts, vnet.DontJoinTailnet)
|
vnetOpts = append(vnetOpts, vnet.DontJoinTailnet)
|
||||||
|
case nodeOptTailscaleSSH:
|
||||||
|
n.runSSH = true
|
||||||
case nodeOptNoAgent:
|
case nodeOptNoAgent:
|
||||||
n.noAgent = true
|
n.noAgent = true
|
||||||
case nodeOptAdvertiseRoutes:
|
case nodeOptAdvertiseRoutes:
|
||||||
@@ -515,6 +518,7 @@ func (n *Node) DropControlTraffic() {
|
|||||||
|
|
||||||
type nodeOptOS OSImage
|
type nodeOptOS OSImage
|
||||||
type nodeOptNoTailscale struct{}
|
type nodeOptNoTailscale struct{}
|
||||||
|
type nodeOptTailscaleSSH struct{}
|
||||||
type nodeOptNoAgent struct{}
|
type nodeOptNoAgent struct{}
|
||||||
type nodeOptAdvertiseRoutes string
|
type nodeOptAdvertiseRoutes string
|
||||||
type nodeOptSNATSubnetRoutes bool
|
type nodeOptSNATSubnetRoutes bool
|
||||||
@@ -526,6 +530,13 @@ func OS(img OSImage) nodeOptOS { return nodeOptOS(img) }
|
|||||||
// DontJoinTailnet returns a NodeOption that prevents the node from running tailscale up.
|
// DontJoinTailnet returns a NodeOption that prevents the node from running tailscale up.
|
||||||
func DontJoinTailnet() nodeOptNoTailscale { return nodeOptNoTailscale{} }
|
func DontJoinTailnet() nodeOptNoTailscale { return nodeOptNoTailscale{} }
|
||||||
|
|
||||||
|
// TailscaleSSH returns a NodeOption that enables the node's Tailscale SSH
|
||||||
|
// server by passing --ssh to tailscale up. If any node has this option, the
|
||||||
|
// test control server is configured with a permissive SSH policy that lets
|
||||||
|
// any tailnet node connect as any SSH user, mapped to the same-named local
|
||||||
|
// user.
|
||||||
|
func TailscaleSSH() nodeOptTailscaleSSH { return nodeOptTailscaleSSH{} }
|
||||||
|
|
||||||
// NoAgent returns a NodeOption that skips TTA agent setup. The node will not
|
// NoAgent returns a NodeOption that skips TTA agent setup. The node will not
|
||||||
// have a test agent, so agent-dependent operations (Status, ExecOnNode, etc.)
|
// have a test agent, so agent-dependent operations (Status, ExecOnNode, etc.)
|
||||||
// won't work. Useful for VMs that just need to boot and respond to ICMP.
|
// won't work. Useful for VMs that just need to boot and respond to ICMP.
|
||||||
@@ -709,6 +720,9 @@ func (e *Env) tailscaleUp(ctx context.Context, n *Node) error {
|
|||||||
if n.advertiseRoutes != "" {
|
if n.advertiseRoutes != "" {
|
||||||
url += "&advertise-routes=" + n.advertiseRoutes
|
url += "&advertise-routes=" + n.advertiseRoutes
|
||||||
}
|
}
|
||||||
|
if n.runSSH {
|
||||||
|
url += "&ssh=true"
|
||||||
|
}
|
||||||
if n.snatSubnetRoutes != nil {
|
if n.snatSubnetRoutes != nil {
|
||||||
if *n.snatSubnetRoutes {
|
if *n.snatSubnetRoutes {
|
||||||
url += "&snat-subnet-routes=true"
|
url += "&snat-subnet-routes=true"
|
||||||
@@ -1636,6 +1650,18 @@ func (e *Env) initVnet() {
|
|||||||
}
|
}
|
||||||
cs.DNSConfig.Proxied = true
|
cs.DNSConfig.Proxied = true
|
||||||
}
|
}
|
||||||
|
for _, n := range e.nodes {
|
||||||
|
if n.runSSH {
|
||||||
|
e.server.ControlServer().SSHPolicy = &tailcfg.SSHPolicy{
|
||||||
|
Rules: []*tailcfg.SSHRule{{
|
||||||
|
Principals: []*tailcfg.SSHPrincipal{{Any: true}},
|
||||||
|
SSHUsers: map[string]string{"*": "="},
|
||||||
|
Action: &tailcfg.SSHAction{Accept: true},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user