ipn/ipnlocal: set certificate retrieval function directly in tests

Signed-off-by: Harry Harpham <harry@tailscale.com>
This commit is contained in:
Harry Harpham 2025-12-23 09:52:07 -07:00
parent 1584825c9a
commit a44e9d9c08
No known key found for this signature in database
4 changed files with 46 additions and 64 deletions

View File

@ -45,7 +45,6 @@ import (
"tailscale.com/tailcfg"
"tailscale.com/tempfork/acme"
"tailscale.com/types/logger"
"tailscale.com/util/set"
"tailscale.com/util/testenv"
"tailscale.com/version"
"tailscale.com/version/distro"
@ -309,38 +308,13 @@ func (b *LocalBackend) getCertStore() (certStore, error) {
return certFileStore{dir: dir, testRoots: testX509Roots}, nil
}
// SetCertsForTest configures the TLS certificates used by this local backend.
// This should only be used in testing and can be used to skip the usual ACME
// certificate registration.
//
// Certificates will be served based on the subject name or subject alternative
// names (SANs) in the certificates. If this backend should serve certificates
// for hostnames like foo.tail-scale.ts.net or test-service.tail-scale.ts.net,
// then those names need to appear as a subject name or SAN.
func (b *LocalBackend) SetCertsForTest(certs ...TLSCertKeyPair) {
// ConfigureCertsForTest sets a certificate retrieval function to be used by
// this local backend, skipping the usual ACME certificate registration. Should
// only be used in tests.
func (b *LocalBackend) ConfigureCertsForTest(getCert func(hostname string) (*TLSCertKeyPair, error)) {
testenv.AssertInTest()
m := map[string]TLSCertKeyPair{}
for _, c := range certs {
cert, err := tls.X509KeyPair(c.CertPEM, c.KeyPEM)
if err != nil {
panic(fmt.Sprintf("parse error: %v", err))
}
names := set.Of(append(cert.Leaf.DNSNames, cert.Leaf.Subject.CommonName)...)
for _, name := range names.Slice() {
if _, ok := m[name]; ok {
panic(fmt.Sprintf("duplicate subject name %v", name))
}
m[name] = c
}
}
b.mu.Lock()
b.getCertForTest = func(domain string) (*TLSCertKeyPair, error) {
c, ok := m[domain]
if !ok {
return nil, errors.New("cert not found")
}
return &c, nil
}
b.getCertForTest = getCert
b.mu.Unlock()
}

View File

@ -400,9 +400,9 @@ type LocalBackend struct {
// bind the node identity to this device.
hardwareAttested atomic.Bool
// getCertForTest is used to configure TLS certificates for testing
// purposes. See [LocalBackend.SetCertsForTest].
getCertForTest func(domain string) (*TLSCertKeyPair, error)
// getCertForTest is used to retrieve TLS certificates in tests.
// See [LocalBackend.ConfigureCertsForTest].
getCertForTest func(hostname string) (*TLSCertKeyPair, error)
}
// SetHardwareAttested enables hardware attestation key signatures in map

View File

@ -1274,10 +1274,11 @@ func (s *Server) ListenService(name string, port uint16, opts ...ServiceOption)
}
// TODO:
// - try above with simple TCP listener first
// - handle Services with multiple ports defined
// - support web handlers
// - make sure extras like PROXY mode are supported
// - create example for a Service with multiple ports
// - support web handlers?
// - at least app capabilities need to work
// - everything else could serve directly or use http.ReverseProxy, right?
// - maybe worth an http.ReverseProxy example?
// - support TUN mode
// Process options.

View File

@ -30,6 +30,7 @@ import (
"reflect"
"runtime"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@ -138,6 +139,9 @@ func startControl(t *testing.T) (controlURL string, control *testcontrol.Server)
}
type testCertIssuer struct {
mu sync.Mutex
certs map[string]ipnlocal.TLSCertKeyPair // keyed by hostname
root *x509.Certificate
rootKey *ecdsa.PrivateKey
}
@ -170,33 +174,48 @@ func newCertIssuer() *testCertIssuer {
return &testCertIssuer{
root: rootCA,
rootKey: rootKey,
certs: map[string]ipnlocal.TLSCertKeyPair{},
}
}
func (tci *testCertIssuer) makeCert(domain string) (certPEM, keyPEM []byte, err error) {
func (tci *testCertIssuer) getCert(hostname string) (*ipnlocal.TLSCertKeyPair, error) {
tci.mu.Lock()
defer tci.mu.Unlock()
cert, ok := tci.certs[hostname]
if ok {
return &cert, nil
}
certPrivKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, err
return nil, err
}
certTmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
DNSNames: []string{domain},
DNSNames: []string{hostname},
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour),
}
certDER, err := x509.CreateCertificate(rand.Reader, certTmpl, tci.root, &certPrivKey.PublicKey, tci.rootKey)
if err != nil {
return nil, nil, err
return nil, err
}
certPEM = pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certDER,
})
keyPEM = pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: must.Get(x509.MarshalPKCS8PrivateKey(certPrivKey)),
})
return certPEM, keyPEM, nil
keyDER, err := x509.MarshalPKCS8PrivateKey(certPrivKey)
if err != nil {
return nil, err
}
cert = ipnlocal.TLSCertKeyPair{
CertPEM: pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certDER,
}),
KeyPEM: pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: keyDER,
}),
}
tci.certs[hostname] = cert
return &cert, nil
}
func (tci *testCertIssuer) Pool() *x509.CertPool {
@ -228,10 +247,7 @@ func startServer(t *testing.T, ctx context.Context, controlURL, hostname string)
if err != nil {
t.Fatal(err)
}
nodeFQDN := hostname + "." + status.CurrentTailnet.MagicDNSSuffix
certPEM, keyPEM, err := testCertRoot.makeCert(nodeFQDN)
s.lb.SetCertsForTest(ipnlocal.TLSCertKeyPair{CertPEM: certPEM, KeyPEM: keyPEM})
s.lb.ConfigureCertsForTest(testCertRoot.getCert)
return s, status.TailscaleIPs[0], status.Self.PublicKey
}
@ -809,15 +825,6 @@ func TestListenService(t *testing.T) {
serviceHostNode.Tags = append(serviceHostNode.Tags, "some-tag")
control.UpdateNode(serviceHostNode)
// Configure a certificate for the Service domain (in production,
// the local backend would use an ACME client to obtain a certPEM).
// This is only used when serving over TLS.
certPEM, keyPEM := must.Get2(testCertRoot.makeCert(serviceFQDN))
serviceHost.lb.SetCertsForTest(ipnlocal.TLSCertKeyPair{
CertPEM: certPEM,
KeyPEM: keyPEM,
})
// The service client must accept routes advertised by other nodes
// (RouteAll is equivalent to --accept-routes).
must.Get(serviceClient.localClient.EditPrefs(ctx, &ipn.MaskedPrefs{