tstest/natlab: add ACME cert vmtest

This adds a fake vnet ACME service, TXT-backed SetDNS support, and a
VM test that fetches a certificate with tailscale cert, serves it with
tailscale serve, and verifies HTTPS from a second node.

This adds coverage motivated by #19915.

Updates #13038

Change-Id: Ie1e53409509337d81c8fbceb63f59f3dfbd48207
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick
2026-06-05 08:57:17 -07:00
committed by Brad Fitzpatrick
parent 84ffcd2759
commit 26864f1302
7 changed files with 818 additions and 6 deletions
+86
View File
@@ -0,0 +1,86 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package vmtest_test
import (
"encoding/base64"
"fmt"
"strings"
"testing"
"github.com/creachadair/mds/shell"
"tailscale.com/tstest/natlab/vmtest"
"tailscale.com/tstest/natlab/vnet"
)
func TestACMECertServeHTTPS(t *testing.T) {
env := vmtest.New(t, vmtest.FakeACME())
issuer := ubuntuHard(env, "issuer")
client := ubuntuHard(env, "client")
env.Start()
st := env.Status(issuer)
if len(st.CertDomains) == 0 {
t.Fatalf("issuer has no CertDomains in status")
}
domain := st.CertDomains[0]
out, err := env.SSHExec(issuer, certAndWatchHealthCommand(domain))
if err != nil {
t.Fatalf("tailscale cert: %v\n%s", err, out)
}
out, err = env.SSHExec(issuer, "tailscale serve --bg --https=443 text:natlab-acme-ok")
if err != nil {
t.Fatalf("tailscale serve: %v\n%s", err, out)
}
rootB64 := base64.StdEncoding.EncodeToString(env.FakeACMERootPEM())
out, err = env.SSHExec(client, "printf %s "+shell.Quote(rootB64)+" | base64 -d >/tmp/fake-acme-root.pem")
if err != nil {
t.Fatalf("install fake ACME root: %v\n%s", err, out)
}
out, err = env.SSHExec(client, "curl --fail --silent --show-error --cacert /tmp/fake-acme-root.pem https://"+shell.Quote(domain)+"/")
if err != nil {
t.Fatalf("curl served HTTPS page: %v\n%s", err, out)
}
if strings.TrimSpace(out) != "natlab-acme-ok" {
t.Fatalf("curl body = %q, want %q", out, "natlab-acme-ok")
}
}
func ubuntuHard(env *vmtest.Env, name string) *vmtest.Node {
n := env.NumNodes()
return env.AddNode(name,
env.AddNetwork(
fmt.Sprintf("2.%d.%d.%d", n, n, n),
fmt.Sprintf("10.0.%d.1/24", n), vnet.HardNAT),
vmtest.OS(vmtest.Ubuntu2404))
}
func certAndWatchHealthCommand(domain string) string {
qdomain := shell.Quote(domain)
return fmt.Sprintf(`
set -eu
cd /tmp
rm -f cert.out cert.status cert.done cert-health.out
(set +e; tailscale cert %[1]s >cert.out 2>&1; echo $? >cert.status; touch cert.done) &
certpid=$!
for i in $(seq 1 60); do
if tailscale status --json | grep -F "Fetching TLS certificate" >cert-health.out; then
break
fi
if [ -e cert.done ]; then
break
fi
sleep 0.1
done
wait "$certpid" || true
cat cert.out
if [ "$(cat cert.status)" != "0" ]; then
exit "$(cat cert.status)"
fi
test -s cert-health.out
`, qdomain)
}
+55 -2
View File
@@ -4,12 +4,18 @@
package vmtest
import (
"crypto/ed25519"
"crypto/rand"
"encoding/pem"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/creachadair/mds/shell"
"github.com/kdomanski/iso9660"
"golang.org/x/crypto/ssh"
)
// createCloudInitISO creates a cidata seed ISO for the given cloud VM node.
@@ -18,6 +24,9 @@ import (
// doesn't use netplan-style network-config; DHCP is enabled in rc.conf).
func (e *Env) createCloudInitISO(n *Node) (string, error) {
metaData := fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", n.name, n.name)
if err := ensureDebugSSHKey(); err != nil {
return "", err
}
userData := e.generateUserData(n)
files := map[string]string{
@@ -124,7 +133,7 @@ func (e *Env) generateLinuxUserData(n *Node) string {
// features like Taildrop (which needs a place to stash incoming files)
// have a directory to work with.
ud.WriteString(" - [\"mkdir\", \"-p\", \"/var/lib/tailscale\"]\n")
ud.WriteString(" - [\"/bin/sh\", \"-c\", \"/usr/local/bin/tailscaled --state=mem: --statedir=/var/lib/tailscale &\"]\n")
fmt.Fprintf(&ud, " - [\"/bin/sh\", \"-c\", \"%s/usr/local/bin/tailscaled --state=mem: --statedir=/var/lib/tailscale &\"]\n", tailscaledEnvPrefix(n))
ud.WriteString(" - [\"sleep\", \"2\"]\n")
// Start tta (Tailscale Test Agent).
@@ -183,7 +192,7 @@ func (e *Env) generateFreeBSDUserData(n *Node) string {
// path). --statedir provides a VarRoot so features like Taildrop have a
// directory.
ud.WriteString(" - \"mkdir -p /var/lib/tailscale\"\n")
ud.WriteString(" - \"export PATH=/usr/local/bin:$PATH && /usr/local/bin/tailscaled --state=mem: --statedir=/var/lib/tailscale </dev/null >/var/log/tailscaled.log 2>&1 &\"\n")
fmt.Fprintf(&ud, " - \"export PATH=/usr/local/bin:$PATH && %s/usr/local/bin/tailscaled --state=mem: --statedir=/var/lib/tailscale </dev/null >/var/log/tailscaled.log 2>&1 &\"\n", tailscaledEnvPrefix(n))
ud.WriteString(" - \"sleep 2\"\n")
// Start tta (Tailscale Test Agent), with the same stdio redirection.
@@ -191,3 +200,47 @@ func (e *Env) generateFreeBSDUserData(n *Node) string {
return ud.String()
}
func tailscaledEnvPrefix(n *Node) string {
env := n.vnetNode.Env()
if len(env) == 0 {
return ""
}
parts := make([]string, 0, len(env))
for _, e := range env {
parts = append(parts, e.Key+"="+shell.Quote(e.Value))
}
sort.Strings(parts)
return strings.Join(parts, " ") + " "
}
func ensureDebugSSHKey() error {
const keyPath = "/tmp/vmtest_key"
if privPEM, err := os.ReadFile(keyPath); err == nil {
if _, err := os.Stat(keyPath + ".pub"); err == nil {
return nil
}
signer, err := ssh.ParsePrivateKey(privPEM)
if err != nil {
return fmt.Errorf("parse %s: %w", keyPath, err)
}
return os.WriteFile(keyPath+".pub", ssh.MarshalAuthorizedKey(signer.PublicKey()), 0644)
}
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return err
}
block, err := ssh.MarshalPrivateKey(priv, "vmtest")
if err != nil {
return err
}
if err := os.WriteFile(keyPath, pem.EncodeToMemory(block), 0600); err != nil {
return err
}
sshPub, err := ssh.NewPublicKey(pub)
if err != nil {
return err
}
return os.WriteFile(keyPath+".pub", ssh.MarshalAuthorizedKey(sshPub), 0644)
}
+28
View File
@@ -93,6 +93,7 @@ type Env struct {
allOnline bool // mark every peer as Online=true in MapResponses
peerRelayGrants bool // grant peer-relay capabilities on the wildcard packet filter
selfSignedDERPCertPinning bool // serve test DERP map with sha256-raw cert pins
fakeACME bool // point tailscaled at vnet's fake ACME server
// Shared resource initialization (sync.Once for things multiple nodes share).
vnetOnce sync.Once
@@ -394,6 +395,12 @@ func SelfSignedDERPCertPinning() EnvOption {
return envOptFunc(func(e *Env) { e.selfSignedDERPCertPinning = true })
}
// FakeACME returns an [EnvOption] that points nodes at vnet's in-process ACME
// CA and configures the test control server to advertise MagicDNS cert domains.
func FakeACME() EnvOption {
return envOptFunc(func(e *Env) { e.fakeACME = true })
}
// AddNetwork creates a new virtual network. Arguments follow the same pattern as
// vnet.Config.AddNetwork (string IPs, NAT types, NetworkService values).
func (e *Env) AddNetwork(opts ...any) *vnet.Network {
@@ -463,6 +470,12 @@ func (e *Env) AddNode(name string, opts ...any) *Node {
vnetOpts = append(vnetOpts, o)
}
}
if e.fakeACME {
vnetOpts = append(vnetOpts, vnet.TailscaledEnv{
Key: "TS_DEBUG_ACME_DIRECTORY_URL",
Value: "http://acme.example/directory",
})
}
// macOS VMs require a macOS arm64 host (Apple Virtualization.framework via
// tailmac). Skip the test now rather than letting it proceed through the
@@ -810,6 +823,12 @@ func (e *Env) ControlServer() *testcontrol.Server {
return e.server.ControlServer()
}
// FakeACMERootPEM returns the root certificate for vnet's fake ACME CA.
func (e *Env) FakeACMERootPEM() []byte {
e.initVnet()
return e.server.FakeACMERootPEM()
}
// BringUpMullvadWGServer brings up a userspace WireGuard server on n,
// configured as a single-peer "Mullvad-style" exit-node target. The
// server runs inside n's TTA process on a Linux TUN named "wg0".
@@ -1229,6 +1248,7 @@ func (e *Env) SSHExec(n *Node, cmd string) (string, error) {
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "ConnectTimeout=5",
"-o", "LogLevel=ERROR",
"-i", "/tmp/vmtest_key",
"-p", fmt.Sprintf("%d", n.sshPort),
"root@127.0.0.1",
@@ -1445,6 +1465,14 @@ func (e *Env) initVnet() {
if e.selfSignedDERPCertPinning {
e.server.ControlServer().DERPMap = e.buildSelfSignedDERPMap()
}
if e.fakeACME {
cs := e.server.ControlServer()
cs.MagicDNSDomain = "tailnet.test"
if cs.DNSConfig == nil {
cs.DNSConfig = new(tailcfg.DNSConfig)
}
cs.DNSConfig.Proxied = true
}
})
}