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:
committed by
Brad Fitzpatrick
parent
84ffcd2759
commit
26864f1302
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user