From a44e9d9c08039dbeb563b6cc0ad129e3f98db45e Mon Sep 17 00:00:00 2001 From: Harry Harpham Date: Tue, 23 Dec 2025 09:52:07 -0700 Subject: [PATCH] ipn/ipnlocal: set certificate retrieval function directly in tests Signed-off-by: Harry Harpham --- ipn/ipnlocal/cert.go | 36 ++++---------------------- ipn/ipnlocal/local.go | 6 ++--- tsnet/tsnet.go | 9 ++++--- tsnet/tsnet_test.go | 59 ++++++++++++++++++++++++------------------- 4 files changed, 46 insertions(+), 64 deletions(-) diff --git a/ipn/ipnlocal/cert.go b/ipn/ipnlocal/cert.go index 51b00cb18..71389abf1 100644 --- a/ipn/ipnlocal/cert.go +++ b/ipn/ipnlocal/cert.go @@ -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() } diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 3c4010746..713f7c479 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -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 diff --git a/tsnet/tsnet.go b/tsnet/tsnet.go index 3303a9cb0..24553c220 100644 --- a/tsnet/tsnet.go +++ b/tsnet/tsnet.go @@ -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. diff --git a/tsnet/tsnet_test.go b/tsnet/tsnet_test.go index cf179b52d..15b05612c 100644 --- a/tsnet/tsnet_test.go +++ b/tsnet/tsnet_test.go @@ -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{