Files
tailscale/ipn/ipnlocal/cert_test.go
T
Brad FitzpatrickandBrad Fitzpatrick 4bb6f35c1f ipn/ipnlocal: consolidate test-only LocalBackend methods behind ForTest
Move all the FooForTest methods on LocalBackend to instead be
methods on a new unexported forTest type which is then given out
to callers in other packages via an exported ForTest method
(panicking in non-test contexts) that returns that unexported type.

This is unusual style (exported returning unexported) but declutters
godoc and makes call sites both more explicit and easier to read
without the "ForTest" suffix polluting the symbols. Now FooForTest()
changes into ForTest().Foo().

This was motivated by a pending change moving a bunch of code out of
LocalBackend into other packages that required adding more ForTest
methods to LocalBackend to keep the tests (now in other packages)
working. Instead, do this refactor now so the future change is prettier.

Updates #12614
Updates #cleanup

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: Ib25e6d76d48dc8622ac3a955e0b1220d582e63a8
2026-06-27 16:11:42 -07:00

1015 lines
28 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ios && !android && !js
package ipnlocal
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"embed"
"encoding/pem"
"maps"
"math/big"
"os"
"path/filepath"
"slices"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"tailscale.com/envknob"
"tailscale.com/health"
"tailscale.com/ipn"
"tailscale.com/ipn/store/mem"
"tailscale.com/tailcfg"
"tailscale.com/tempfork/acme"
"tailscale.com/tsconst"
"tailscale.com/tstest"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
"tailscale.com/util/must"
"tailscale.com/util/set"
)
func TestCertRequest(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
tests := []struct {
name string
domain string
wantSANs []string
}{
{
name: "example-com",
domain: "example.com",
wantSANs: []string{"example.com"},
},
{
name: "wildcard-example-com",
domain: "*.example.com",
wantSANs: []string{"*.example.com", "example.com"},
},
{
name: "wildcard-foo-bar-com",
domain: "*.foo.bar.com",
wantSANs: []string{"*.foo.bar.com", "foo.bar.com"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
csrDER, err := certRequest(key, tt.domain, nil)
if err != nil {
t.Fatalf("certRequest: %v", err)
}
csr, err := x509.ParseCertificateRequest(csrDER)
if err != nil {
t.Fatalf("ParseCertificateRequest: %v", err)
}
if csr.Subject.CommonName != tt.domain {
t.Errorf("CommonName = %q, want %q", csr.Subject.CommonName, tt.domain)
}
if !slices.Equal(csr.DNSNames, tt.wantSANs) {
t.Errorf("DNSNames = %v, want %v", csr.DNSNames, tt.wantSANs)
}
})
}
}
func TestResolveCertDomain(t *testing.T) {
tests := []struct {
name string
domain string
certDomains []string
hasCap bool
skipNetmap bool
want string
wantErr string
}{
{
name: "exact_match",
domain: "node.ts.net",
certDomains: []string{"node.ts.net"},
want: "node.ts.net",
},
{
name: "exact_match_with_cap",
domain: "node.ts.net",
certDomains: []string{"node.ts.net"},
hasCap: true,
want: "node.ts.net",
},
{
name: "wildcard_with_cap",
domain: "*.node.ts.net",
certDomains: []string{"node.ts.net"},
hasCap: true,
want: "*.node.ts.net",
},
{
name: "wildcard_without_cap",
domain: "*.node.ts.net",
certDomains: []string{"node.ts.net"},
hasCap: false,
wantErr: "wildcard certificates are not enabled for this node",
},
{
name: "subdomain_with_cap_rejected",
domain: "app.node.ts.net",
certDomains: []string{"node.ts.net"},
hasCap: true,
wantErr: `invalid domain "app.node.ts.net"; must be one of ["node.ts.net"]`,
},
{
name: "subdomain_without_cap_rejected",
domain: "app.node.ts.net",
certDomains: []string{"node.ts.net"},
hasCap: false,
wantErr: `invalid domain "app.node.ts.net"; must be one of ["node.ts.net"]`,
},
{
name: "multi_level_subdomain_rejected",
domain: "a.b.node.ts.net",
certDomains: []string{"node.ts.net"},
hasCap: true,
wantErr: `invalid domain "a.b.node.ts.net"; must be one of ["node.ts.net"]`,
},
{
name: "wildcard_no_matching_parent",
domain: "*.unrelated.ts.net",
certDomains: []string{"node.ts.net"},
hasCap: true,
wantErr: `invalid domain "*.unrelated.ts.net"; wildcard certificates are not enabled for this domain`,
},
{
name: "subdomain_unrelated_rejected",
domain: "app.unrelated.ts.net",
certDomains: []string{"node.ts.net"},
hasCap: true,
wantErr: `invalid domain "app.unrelated.ts.net"; must be one of ["node.ts.net"]`,
},
{
name: "no_cert_domains",
domain: "node.ts.net",
certDomains: nil,
wantErr: "your Tailscale account does not support getting TLS certs",
},
{
name: "wildcard_no_cert_domains",
domain: "*.foo.ts.net",
certDomains: nil,
hasCap: true,
wantErr: "your Tailscale account does not support getting TLS certs",
},
{
name: "empty_domain",
domain: "",
certDomains: []string{"node.ts.net"},
wantErr: "missing domain name",
},
{
name: "nil_netmap",
domain: "node.ts.net",
skipNetmap: true,
wantErr: "no netmap available",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b := newTestLocalBackend(t)
if !tt.skipNetmap {
// Set up netmap with CertDomains and capability
var allCaps set.Set[tailcfg.NodeCapability]
if tt.hasCap {
allCaps = set.Of(tailcfg.NodeAttrDNSSubdomainResolve)
}
b.mu.Lock()
b.currentNode().SetNetMap(&netmap.NetworkMap{
SelfNode: (&tailcfg.Node{}).View(),
DNS: tailcfg.DNSConfig{
CertDomains: tt.certDomains,
},
AllCaps: allCaps,
})
b.mu.Unlock()
}
got, err := b.resolveCertDomain(tt.domain)
if tt.wantErr != "" {
if err == nil {
t.Errorf("resolveCertDomain(%q) = %q, want error %q", tt.domain, got, tt.wantErr)
} else if err.Error() != tt.wantErr {
t.Errorf("resolveCertDomain(%q) error = %q, want %q", tt.domain, err.Error(), tt.wantErr)
}
return
}
if err != nil {
t.Errorf("resolveCertDomain(%q) error = %v, want nil", tt.domain, err)
return
}
if got != tt.want {
t.Errorf("resolveCertDomain(%q) = %q, want %q", tt.domain, got, tt.want)
}
})
}
}
func TestValidLookingCertDomain(t *testing.T) {
tests := []struct {
in string
want bool
}{
{"foo.com", true},
{"foo..com", false},
{"foo/com.com", false},
{"NUL", false},
{"", false},
{"foo\\bar.com", false},
{"foo\x00bar.com", false},
// Wildcard tests
{"*.foo.com", true},
{"*.foo.bar.com", true},
{"*foo.com", false}, // must be *.
{"*.com", false}, // must have domain after *.
{"*.", false}, // must have domain after *.
{"*.*.foo.com", false}, // no nested wildcards
{"foo.*.bar.com", false}, // no wildcard mid-string
{"app.foo.com", true}, // regular subdomain
{"*", false}, // bare asterisk
}
for _, tt := range tests {
if got := validLookingCertDomain(tt.in); got != tt.want {
t.Errorf("validLookingCertDomain(%q) = %v, want %v", tt.in, got, tt.want)
}
}
}
func TestACMETLSALPNCertHook(t *testing.T) {
b := newTestLocalBackend(t)
cert := &tls.Certificate{}
cleanup := b.storeACMETLSALPNCert("example.com", cert)
defer cleanup()
if got, ok := b.getACMETLSALPNCert(&tls.ClientHelloInfo{
ServerName: "example.com",
SupportedProtos: []string{acme.ALPNProto},
}); !ok || got != cert {
t.Fatalf("getACMETLSALPNCert = %v, %v; want stored cert, true", got, ok)
}
if _, ok := b.getACMETLSALPNCert(&tls.ClientHelloInfo{
ServerName: "example.com",
SupportedProtos: []string{"http/1.1"},
}); ok {
t.Fatal("getACMETLSALPNCert without acme ALPN = ok, want false")
}
if _, ok := b.getACMETLSALPNCert(&tls.ClientHelloInfo{
ServerName: "other.example.com",
SupportedProtos: []string{acme.ALPNProto},
}); ok {
t.Fatal("getACMETLSALPNCert for other name = ok, want false")
}
otherBackend := newTestLocalBackend(t)
if _, ok := otherBackend.getACMETLSALPNCert(&tls.ClientHelloInfo{
ServerName: "example.com",
SupportedProtos: []string{acme.ALPNProto},
}); ok {
t.Fatal("getACMETLSALPNCert on different LocalBackend = ok, want false")
}
}
func TestServeTLSConfigNextProtos(t *testing.T) {
b := newTestLocalBackend(t)
getCert := func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
return nil, nil
}
httpsConfig := b.serveTLSConfig(getCert, serveTLSNextProtos())
if got, want := httpsConfig.NextProtos, []string{"h2", "http/1.1"}; !slices.Equal(got, want) {
t.Fatalf("HTTPS NextProtos = %q; want %q", got, want)
}
tcpForwardConfig := b.serveTLSConfig(getCert, nil)
if got := tcpForwardConfig.NextProtos; got != nil {
t.Fatalf("TLS-terminated TCP forward NextProtos = %q; want nil", got)
}
}
func TestShouldUseACMETLSALPN01(t *testing.T) {
const (
tsNetDomain = "node.ts.net"
byoDomain = "foo.com"
)
previous := &TLSCertKeyPair{}
setFunnel := func(b *LocalBackend, hosts ...string) {
funnel := map[ipn.HostPort]bool{}
for _, h := range hosts {
funnel[ipn.HostPort(h+":443")] = true
}
b.mu.Lock()
b.serveConfig = (&ipn.ServeConfig{AllowFunnel: funnel}).View()
b.mu.Unlock()
}
setNetmap := func(b *LocalBackend, certDomains ...string) {
b.mu.Lock()
b.currentNode().SetNetMap(&netmap.NetworkMap{
SelfNode: (&tailcfg.Node{}).View(),
DNS: tailcfg.DNSConfig{CertDomains: certDomains},
})
b.mu.Unlock()
}
tests := []struct {
name string
domain string
previous *TLSCertKeyPair
funnel []string
netmap []string // CertDomains; if nil, no netmap installed
want bool
}{
{
name: "tsnet_renewal",
domain: tsNetDomain,
previous: previous,
funnel: []string{tsNetDomain},
netmap: []string{tsNetDomain},
want: true,
},
{
name: "tsnet_first_issuance_prefers_dns01",
domain: tsNetDomain,
previous: nil,
funnel: []string{tsNetDomain},
netmap: []string{tsNetDomain},
want: false,
},
{
name: "tsnet_wildcard_rejected",
domain: "*." + tsNetDomain,
previous: previous,
funnel: []string{tsNetDomain},
netmap: []string{tsNetDomain},
want: false,
},
{
name: "tsnet_without_funnel_rejected",
domain: tsNetDomain,
previous: previous,
funnel: nil,
netmap: []string{tsNetDomain},
want: false,
},
{
name: "byo_first_issuance_uses_alpn",
domain: byoDomain,
previous: nil,
funnel: []string{byoDomain},
netmap: []string{tsNetDomain},
want: true,
},
{
name: "byo_renewal_uses_alpn",
domain: byoDomain,
previous: previous,
funnel: []string{byoDomain},
netmap: []string{tsNetDomain},
want: true,
},
{
name: "byo_without_funnel_rejected",
domain: byoDomain,
previous: previous,
funnel: nil,
netmap: []string{tsNetDomain},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b := newTestLocalBackend(t)
if tt.netmap != nil {
setNetmap(b, tt.netmap...)
}
setFunnel(b, tt.funnel...)
if got := b.shouldUseACMETLSALPN01(tt.domain, tt.previous, t.Logf); got != tt.want {
t.Errorf("shouldUseACMETLSALPN01(%q, previous=%v) = %v, want %v",
tt.domain, tt.previous != nil, got, tt.want)
}
})
}
}
func TestIsBYOFunnelDomain(t *testing.T) {
setFunnel := func(b *LocalBackend, hosts ...string) {
funnel := map[ipn.HostPort]bool{}
for _, h := range hosts {
funnel[ipn.HostPort(h+":443")] = true
}
b.mu.Lock()
b.serveConfig = (&ipn.ServeConfig{AllowFunnel: funnel}).View()
b.mu.Unlock()
}
setNetmap := func(b *LocalBackend, certDomains ...string) {
b.mu.Lock()
b.currentNode().SetNetMap(&netmap.NetworkMap{
SelfNode: (&tailcfg.Node{}).View(),
DNS: tailcfg.DNSConfig{CertDomains: certDomains},
})
b.mu.Unlock()
}
tests := []struct {
name string
domain string
certDomains []string
funnel []string
want bool
}{
{name: "byo_with_funnel", domain: "foo.com", certDomains: []string{"node.ts.net"}, funnel: []string{"foo.com"}, want: true},
{name: "byo_without_funnel", domain: "foo.com", certDomains: []string{"node.ts.net"}, want: false},
{name: "tsnet_exact_match_not_byo", domain: "node.ts.net", certDomains: []string{"node.ts.net"}, funnel: []string{"node.ts.net"}, want: false},
{name: "wildcard_never_byo", domain: "*.foo.com", certDomains: []string{"node.ts.net"}, funnel: []string{"foo.com"}, want: false},
{name: "empty_never_byo", domain: "", certDomains: []string{"node.ts.net"}, funnel: []string{"foo.com"}, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b := newTestLocalBackend(t)
setNetmap(b, tt.certDomains...)
setFunnel(b, tt.funnel...)
if got := b.isBYOFunnelDomain(tt.domain); got != tt.want {
t.Errorf("isBYOFunnelDomain(%q) = %v, want %v", tt.domain, got, tt.want)
}
})
}
}
func TestResolveCertDomainBYO(t *testing.T) {
const (
tsNetDomain = "node.ts.net"
byoDomain = "foo.com"
)
b := newTestLocalBackend(t)
b.mu.Lock()
b.currentNode().SetNetMap(&netmap.NetworkMap{
SelfNode: (&tailcfg.Node{}).View(),
DNS: tailcfg.DNSConfig{CertDomains: []string{tsNetDomain}},
})
b.mu.Unlock()
// Without a serve config, BYO is rejected.
if _, err := b.resolveCertDomain(byoDomain); err == nil {
t.Fatalf("resolveCertDomain(%q) without serve config: want error, got nil", byoDomain)
}
// Web entry alone (no AllowFunnel) is not enough; the gate is Funnel.
b.mu.Lock()
b.serveConfig = (&ipn.ServeConfig{
Web: map[ipn.HostPort]*ipn.WebServerConfig{
byoDomain + ":443": {Handlers: map[string]*ipn.HTTPHandler{"/": {Proxy: "http://127.0.0.1:8080"}}},
},
}).View()
b.mu.Unlock()
if _, err := b.resolveCertDomain(byoDomain); err == nil {
t.Fatalf("resolveCertDomain(%q) with Web but no Funnel: want error, got nil", byoDomain)
}
// With AllowFunnel, BYO is accepted.
b.mu.Lock()
b.serveConfig = (&ipn.ServeConfig{
Web: map[ipn.HostPort]*ipn.WebServerConfig{
byoDomain + ":443": {Handlers: map[string]*ipn.HTTPHandler{"/": {Proxy: "http://127.0.0.1:8080"}}},
},
AllowFunnel: map[ipn.HostPort]bool{byoDomain + ":443": true},
}).View()
b.mu.Unlock()
got, err := b.resolveCertDomain(byoDomain)
if err != nil {
t.Fatalf("resolveCertDomain(%q): %v", byoDomain, err)
}
if got != byoDomain {
t.Errorf("resolveCertDomain(%q) = %q, want %q", byoDomain, got, byoDomain)
}
// The ts.net path still works alongside BYO entries.
got, err = b.resolveCertDomain(tsNetDomain)
if err != nil {
t.Fatalf("resolveCertDomain(%q): %v", tsNetDomain, err)
}
if got != tsNetDomain {
t.Errorf("resolveCertDomain(%q) = %q, want %q", tsNetDomain, got, tsNetDomain)
}
}
//go:embed testdata/*
var certTestFS embed.FS
func TestCertStoreRoundTrip(t *testing.T) {
const testDomain = "example.com"
// Use fixed verification timestamps so validity doesn't change over time.
// If you update the test data below, these may also need to be updated.
testNow := time.Date(2023, time.February, 10, 0, 0, 0, 0, time.UTC)
testExpired := time.Date(2026, time.February, 10, 0, 0, 0, 0, time.UTC)
// To re-generate a root certificate and domain certificate for testing,
// use:
//
// go run filippo.io/mkcert@latest example.com
//
// The content is not important except to be structurally valid so we can be
// sure the round-trip succeeds.
testRoot, err := certTestFS.ReadFile("testdata/rootCA.pem")
if err != nil {
t.Fatal(err)
}
roots := x509.NewCertPool()
if !roots.AppendCertsFromPEM(testRoot) {
t.Fatal("Unable to add test CA to the cert pool")
}
testCert, err := certTestFS.ReadFile("testdata/example.com.pem")
if err != nil {
t.Fatal(err)
}
testKey, err := certTestFS.ReadFile("testdata/example.com-key.pem")
if err != nil {
t.Fatal(err)
}
tests := []struct {
name string
store certStore
debugACMEURL bool
}{
{"FileStore", certFileStore{dir: t.TempDir(), testRoots: roots}, false},
{"FileStore_UnknownCA", certFileStore{dir: t.TempDir()}, true},
{"StateStore", certStateStore{StateStore: new(mem.Store), testRoots: roots}, false},
{"StateStore_UnknownCA", certStateStore{StateStore: new(mem.Store)}, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if test.debugACMEURL {
t.Setenv("TS_DEBUG_ACME_DIRECTORY_URL", "https://acme-staging-v02.api.letsencrypt.org/directory")
}
if err := test.store.WriteTLSCertAndKey(testDomain, testCert, testKey); err != nil {
t.Fatalf("WriteTLSCertAndKey: unexpected error: %v", err)
}
kp, err := test.store.Read(testDomain, testNow)
if err != nil {
t.Fatalf("Read: unexpected error: %v", err)
}
if diff := cmp.Diff(kp.CertPEM, testCert); diff != "" {
t.Errorf("Certificate (-got, +want):\n%s", diff)
}
if diff := cmp.Diff(kp.KeyPEM, testKey); diff != "" {
t.Errorf("Key (-got, +want):\n%s", diff)
}
unexpected, err := test.store.Read(testDomain, testExpired)
if err != errCertExpired {
t.Fatalf("Read: expected expiry error: %v", string(unexpected.CertPEM))
}
})
}
}
func TestShouldStartDomainRenewal(t *testing.T) {
mustMakePair := func(template *x509.Certificate) *TLSCertKeyPair {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
panic(err)
}
b, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
if err != nil {
panic(err)
}
certPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: b,
})
return &TLSCertKeyPair{
Cached: false,
CertPEM: certPEM,
KeyPEM: []byte("unused"),
}
}
now := time.Unix(1685714838, 0)
subject := pkix.Name{
Organization: []string{"Tailscale, Inc."},
Country: []string{"CA"},
Province: []string{"ON"},
Locality: []string{"Toronto"},
StreetAddress: []string{"290 Bremner Blvd"},
PostalCode: []string{"M5V 3L9"},
}
testCases := []struct {
name string
notBefore time.Time
lifetime time.Duration
want bool
wantErr string
}{
{
name: "should-renew",
notBefore: now.AddDate(0, 0, -89),
lifetime: 90 * 24 * time.Hour,
want: true,
},
{
name: "short-lived-renewal",
notBefore: now.AddDate(0, 0, -7),
lifetime: 10 * 24 * time.Hour,
want: true,
},
{
name: "no-renew",
notBefore: now.AddDate(0, 0, -59), // 59 days ago == not 2/3rds of the way through 90 days yet
lifetime: 90 * 24 * time.Hour,
want: false,
},
}
b := new(LocalBackend)
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
ret, err := b.domainRenewalTimeByExpiry(mustMakePair(&x509.Certificate{
SerialNumber: big.NewInt(2019),
Subject: subject,
NotBefore: tt.notBefore,
NotAfter: tt.notBefore.Add(tt.lifetime),
}))
if tt.wantErr != "" {
if err == nil {
t.Errorf("wanted error, got nil")
} else if err.Error() != tt.wantErr {
t.Errorf("got err=%q, want %q", err.Error(), tt.wantErr)
}
} else {
renew := now.After(ret)
if renew != tt.want {
t.Errorf("got renew=%v (ret=%v), want renew %v", renew, ret, tt.want)
}
}
})
}
}
func TestDebugACMEDirectoryURL(t *testing.T) {
for _, tc := range []string{"", "https://acme-staging-v02.api.letsencrypt.org/directory"} {
const setting = "TS_DEBUG_ACME_DIRECTORY_URL"
t.Run(tc, func(t *testing.T) {
t.Setenv(setting, tc)
ac, err := acmeClient(certStateStore{StateStore: new(mem.Store)})
if err != nil {
t.Fatalf("acmeClient creation err: %v", err)
}
if ac.DirectoryURL != tc {
t.Fatalf("acmeClient.DirectoryURL = %q, want %q", ac.DirectoryURL, tc)
}
})
}
}
func TestGetCertPEMWithValidity(t *testing.T) {
const testDomain = "example.com"
b := newTestLocalBackend(t)
b.varRoot = t.TempDir()
// Set up netmap with CertDomains so resolveCertDomain works
b.mu.Lock()
b.currentNode().SetNetMap(&netmap.NetworkMap{
SelfNode: (&tailcfg.Node{}).View(),
DNS: tailcfg.DNSConfig{
CertDomains: []string{testDomain},
},
})
b.mu.Unlock()
certDir, err := b.certDir()
if err != nil {
t.Fatalf("certDir error: %v", err)
}
if _, err := b.getCertStore(); err != nil {
t.Fatalf("getCertStore error: %v", err)
}
testRoot, err := certTestFS.ReadFile("testdata/rootCA.pem")
if err != nil {
t.Fatal(err)
}
roots := x509.NewCertPool()
if !roots.AppendCertsFromPEM(testRoot) {
t.Fatal("Unable to add test CA to the cert pool")
}
testX509Roots = roots
defer func() { testX509Roots = nil }()
tests := []struct {
name string
now time.Time
// storeCerts is true if the test cert and key should be written to store.
storeCerts bool
readOnlyMode bool // TS_READ_ONLY_CERTS env var
wantAsyncRenewal bool // async issuance should be started
wantIssuance bool // sync issuance should be started
wantErr bool
}{
{
name: "valid_no_renewal",
now: time.Date(2023, time.February, 20, 0, 0, 0, 0, time.UTC),
storeCerts: true,
wantAsyncRenewal: false,
wantIssuance: false,
wantErr: false,
},
{
name: "issuance_needed",
now: time.Date(2023, time.February, 20, 0, 0, 0, 0, time.UTC),
storeCerts: false,
wantAsyncRenewal: false,
wantIssuance: true,
wantErr: false,
},
{
name: "renewal_needed",
now: time.Date(2025, time.May, 1, 0, 0, 0, 0, time.UTC),
storeCerts: true,
wantAsyncRenewal: true,
wantIssuance: false,
wantErr: false,
},
{
name: "renewal_needed_read_only_mode",
now: time.Date(2025, time.May, 1, 0, 0, 0, 0, time.UTC),
storeCerts: true,
readOnlyMode: true,
wantAsyncRenewal: false,
wantIssuance: false,
wantErr: false,
},
{
name: "no_certs_read_only_mode",
now: time.Date(2025, time.May, 1, 0, 0, 0, 0, time.UTC),
storeCerts: false,
readOnlyMode: true,
wantAsyncRenewal: false,
wantIssuance: false,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tstest.AssertNotParallel(t)
if tt.readOnlyMode {
envknob.Setenv("TS_CERT_SHARE_MODE", "ro")
} else {
envknob.Setenv("TS_CERT_SHARE_MODE", "")
}
os.RemoveAll(certDir)
if tt.storeCerts {
os.MkdirAll(certDir, 0755)
if err := os.WriteFile(filepath.Join(certDir, "example.com.crt"),
must.Get(os.ReadFile("testdata/example.com.pem")), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(certDir, "example.com.key"),
must.Get(os.ReadFile("testdata/example.com-key.pem")), 0644); err != nil {
t.Fatal(err)
}
}
b.clock = tstest.NewClock(tstest.ClockOpts{Start: tt.now})
allDone := make(chan bool, 1)
defer b.goTracker.AddDoneCallback(func() {
b.mu.Lock()
defer b.mu.Unlock()
if b.goTracker.RunningGoroutines() > 0 {
return
}
select {
case allDone <- true:
default:
}
})()
// Set to true if get getCertPEM is called. GetCertPEM can be called in a goroutine for async
// renewal or in the main goroutine if issuance is required to obtain valid TLS credentials.
getCertPemWasCalled := false
getCertPEM = func(ctx context.Context, b *LocalBackend, cs certStore, logf logger.Logf, traceACME func(any), domain string, now time.Time, minValidity time.Duration) (*TLSCertKeyPair, error) {
getCertPemWasCalled = true
return nil, nil
}
prevGoRoutines := b.goTracker.StartedGoroutines()
_, err = b.GetCertPEMWithValidity(context.Background(), testDomain, 0)
if (err != nil) != tt.wantErr {
t.Errorf("b.GetCertPemWithValidity got err %v, wants error: '%v'", err, tt.wantErr)
}
// GetCertPEMWithValidity calls getCertPEM in a goroutine if async renewal is needed. That's the
// only goroutine it starts, so this can be used to test if async renewal was started.
gotAsyncRenewal := b.goTracker.StartedGoroutines()-prevGoRoutines != 0
if gotAsyncRenewal {
select {
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for goroutines to finish")
case <-allDone:
}
}
// Verify that async renewal was triggered if expected.
if tt.wantAsyncRenewal != gotAsyncRenewal {
t.Fatalf("wants getCertPem to be called async: %v, got called %v", tt.wantAsyncRenewal, gotAsyncRenewal)
}
// Verify that (non-async) issuance was started if expected.
gotIssuance := getCertPemWasCalled && !gotAsyncRenewal
if tt.wantIssuance != gotIssuance {
t.Errorf("wants getCertPem to be called: %v, got called %v", tt.wantIssuance, gotIssuance)
}
})
}
}
func TestCertPendingWarnable(t *testing.T) {
b := newTestLocalBackend(t)
// currentWarning returns the pending warning's rendered text and
// domain-list arg, or "", "" if the warnable is currently healthy.
currentWarning := func() (text, domains string) {
ws, ok := b.health.CurrentState().Warnings[tsconst.HealthWarnableTLSCertPending]
if !ok {
return "", ""
}
return ws.Text, ws.Args[health.ArgDomains]
}
if b.health.IsUnhealthy(certPendingWarnable) {
t.Fatal("warnable unexpectedly unhealthy before any setCertPending")
}
b.setCertPending("a.example.com", true)
if !b.health.IsUnhealthy(certPendingWarnable) {
t.Fatal("warnable not unhealthy after first setCertPending")
}
if text, domains := currentWarning(); domains != "a.example.com" ||
text != "Fetching TLS certificate via ACME for: a.example.com" {
t.Errorf("after first setCertPending: text=%q domains=%q", text, domains)
}
b.setCertPending("b.example.com", true)
if !b.health.IsUnhealthy(certPendingWarnable) {
t.Fatal("warnable not unhealthy after second setCertPending")
}
if text, domains := currentWarning(); domains != "a.example.com, b.example.com" ||
text != "Fetching TLS certificate via ACME for: a.example.com, b.example.com" {
t.Errorf("after second setCertPending: text=%q domains=%q", text, domains)
}
b.setCertPending("a.example.com", false)
if !b.health.IsUnhealthy(certPendingWarnable) {
t.Fatal("warnable cleared too early; one domain still pending")
}
if text, domains := currentWarning(); domains != "b.example.com" ||
text != "Fetching TLS certificate via ACME for: b.example.com" {
t.Errorf("after clearing a.example.com: text=%q domains=%q", text, domains)
}
b.setCertPending("b.example.com", false)
if b.health.IsUnhealthy(certPendingWarnable) {
t.Fatal("warnable still unhealthy after clearing all domains")
}
if text, domains := currentWarning(); text != "" || domains != "" {
t.Errorf("after clearing all domains: text=%q domains=%q", text, domains)
}
}
func TestServeConfigUsesACMECerts(t *testing.T) {
tests := []struct {
name string
sc *ipn.ServeConfig
want bool
}{
{"nil", nil, false},
{"empty", &ipn.ServeConfig{}, false},
{
name: "background_web",
sc: &ipn.ServeConfig{
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"node.ts.net:443": {},
},
},
want: true,
},
{
name: "tcp_forward_no_tls",
sc: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {TCPForward: "127.0.0.1:443"}},
},
want: false,
},
{
name: "tls_terminated_tcp",
sc: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {TCPForward: "127.0.0.1:443", TerminateTLS: "node.ts.net"},
},
},
want: true,
},
{
name: "service_tls_terminated_tcp",
sc: &ipn.ServeConfig{
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
"svc:web": {
TCP: map[uint16]*ipn.TCPPortHandler{
443: {TCPForward: "127.0.0.1:443", TerminateTLS: "web.svc.ts.net"},
},
},
},
},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var v ipn.ServeConfigView
if tt.sc != nil {
v = tt.sc.View()
}
if got := serveConfigUsesACMECerts(v); got != tt.want {
t.Errorf("serveConfigUsesACMECerts = %v, want %v", got, tt.want)
}
})
}
}
func TestRefreshApplicableCerts(t *testing.T) {
const (
certDomain = "node1.example.com"
byoDomain = "byo.example.org"
)
b := newTestLocalBackend(t)
b.varRoot = t.TempDir()
b.mu.Lock()
b.currentNode().SetNetMap(&netmap.NetworkMap{
SelfNode: (&tailcfg.Node{}).View(),
DNS: tailcfg.DNSConfig{
CertDomains: []string{certDomain},
},
})
b.serveConfig = (&ipn.ServeConfig{
Web: map[ipn.HostPort]*ipn.WebServerConfig{
ipn.HostPort(certDomain + ":443"): {},
ipn.HostPort(byoDomain + ":443"): {},
// Not in CertDomains and no Funnel entry; must be filtered out.
ipn.HostPort("not-ours.other.tld:443"): {},
},
AllowFunnel: map[ipn.HostPort]bool{
ipn.HostPort(byoDomain + ":443"): true,
},
}).View()
b.mu.Unlock()
gotCh := make(chan string, 4)
b.ForTest().ConfigureCerts(func(host string) (*TLSCertKeyPair, error) {
gotCh <- host
return &TLSCertKeyPair{}, nil
})
b.refreshApplicableCerts(context.Background())
want := set.Of(certDomain, byoDomain)
got := set.Set[string]{}
for got.Len() < want.Len() {
select {
case h := <-gotCh:
got.Add(h)
case <-time.After(5 * time.Second):
t.Fatalf("timed out waiting for refresh workers; got %v, want %v", got, want)
}
}
if !maps.Equal(got, want) {
t.Errorf("got fetches %v, want %v", got, want)
}
select {
case h := <-gotCh:
t.Errorf("unexpected extra fetch for %q", h)
default:
}
}