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:
Brad Fitzpatrick
2026-07-13 15:04:51 -07:00
committed by Brad Fitzpatrick
parent 2506ede862
commit c436dec43c
4 changed files with 190 additions and 0 deletions
+26
View File
@@ -428,6 +428,7 @@ type Node struct {
vnetNode *vnet.Node // primary vnet node (set during Start)
agent *vnet.NodeAgentClient
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)
advertiseRoutes string
snatSubnetRoutes *bool // nil means default (true)
@@ -458,6 +459,8 @@ func (e *Env) AddNode(name string, opts ...any) *Node {
case nodeOptNoTailscale:
n.joinTailnet = false
vnetOpts = append(vnetOpts, vnet.DontJoinTailnet)
case nodeOptTailscaleSSH:
n.runSSH = true
case nodeOptNoAgent:
n.noAgent = true
case nodeOptAdvertiseRoutes:
@@ -515,6 +518,7 @@ func (n *Node) DropControlTraffic() {
type nodeOptOS OSImage
type nodeOptNoTailscale struct{}
type nodeOptTailscaleSSH struct{}
type nodeOptNoAgent struct{}
type nodeOptAdvertiseRoutes string
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.
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
// 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.
@@ -709,6 +720,9 @@ func (e *Env) tailscaleUp(ctx context.Context, n *Node) error {
if n.advertiseRoutes != "" {
url += "&advertise-routes=" + n.advertiseRoutes
}
if n.runSSH {
url += "&ssh=true"
}
if n.snatSubnetRoutes != nil {
if *n.snatSubnetRoutes {
url += "&snat-subnet-routes=true"
@@ -1636,6 +1650,18 @@ func (e *Env) initVnet() {
}
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
}
}
})
}