ipn/ipnlocal, feature/acme: move most remaining cert code into feature/acme

f5eac39ea ("feature/acme, ipn/ipnlocal: start moving ACME/cert state
into an extension") started to move the cert code into feature/acme
but was meant as a baby step.

This goes further, moving almost everything, leaving only some hooks
in ipnlocal.

When we later move "serve" support out to feature/serve, this will
look a bit different in that the hooks currently in ipnlocal will move
to feature/serve (cert support already depends on serve).

As part of this, cert-related tests move to feaure/acme too, which
means some test infra from ipnlocal now moves to shared ipnlocaltest.
(it's not big at the moment, but I imagine it growing)

Updates #12614

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I9ea89aa9754f12d54b81751b6bd830f2664241ff
This commit is contained in:
Brad Fitzpatrick
2026-06-29 12:57:22 -07:00
committed by Brad Fitzpatrick
parent 825b7c479f
commit 1c77079fd7
27 changed files with 2212 additions and 1964 deletions
-125
View File
@@ -5,144 +5,19 @@ package ipnlocal
import (
"bytes"
"cmp"
"crypto/x509"
"encoding/json"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"reflect"
"testing"
"time"
"tailscale.com/health"
"tailscale.com/ipn/store/mem"
"tailscale.com/tailcfg"
"tailscale.com/tstest"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
"tailscale.com/types/views"
"tailscale.com/util/eventbus/eventbustest"
"tailscale.com/util/must"
gcmp "github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
)
func TestHandleC2NTLSCertStatus(t *testing.T) {
b := &LocalBackend{
store: &mem.Store{},
varRoot: t.TempDir(),
health: health.NewTracker(eventbustest.NewBus(t)),
}
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
domain string
copyFile bool // copy testdata/example.com.pem to the certDir
wantStatus int // 0 means 200
wantError string // wanted non-JSON non-200 error
now time.Time
want *tailcfg.C2NTLSCertInfo
}{
{
name: "no-domain",
wantStatus: 400,
wantError: "no 'domain'\n",
},
{
name: "missing",
domain: "example.com",
want: &tailcfg.C2NTLSCertInfo{
Error: "no certificate",
Missing: true,
},
},
{
name: "valid",
domain: "example.com",
now: time.Date(2023, time.February, 20, 0, 0, 0, 0, time.UTC),
copyFile: true,
want: &tailcfg.C2NTLSCertInfo{
Valid: true,
NotBefore: "2023-02-07T20:34:18Z",
NotAfter: "2025-05-07T19:34:18Z",
},
},
{
name: "expired",
domain: "example.com",
now: time.Date(2030, time.February, 20, 0, 0, 0, 0, time.UTC),
copyFile: true,
want: &tailcfg.C2NTLSCertInfo{
Error: "cert expired",
Expired: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
os.RemoveAll(certDir) // reset per test
if tt.copyFile {
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,
})
rec := httptest.NewRecorder()
handleC2NTLSCertStatus(b, rec, httptest.NewRequest("GET", "/tls-cert-status?domain="+url.QueryEscape(tt.domain), nil))
res := rec.Result()
wantStatus := cmp.Or(tt.wantStatus, 200)
if res.StatusCode != wantStatus {
t.Fatalf("status code = %v; want %v. Body: %s", res.Status, wantStatus, rec.Body.Bytes())
}
if wantStatus == 200 {
var got tailcfg.C2NTLSCertInfo
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("bad JSON: %v", err)
}
if !reflect.DeepEqual(&got, tt.want) {
t.Errorf("got %v; want %v", logger.AsJSON(got), logger.AsJSON(tt.want))
}
} else if tt.wantError != "" {
if got := rec.Body.String(); got != tt.wantError {
t.Errorf("body = %q; want %q", got, tt.wantError)
}
}
})
}
}
func TestHandleC2NDebugNetmap(t *testing.T) {
nm := &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
+102 -1319
View File
File diff suppressed because it is too large Load Diff
-58
View File
@@ -1,58 +0,0 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build js || ts_omit_acme
package ipnlocal
import (
"context"
"crypto/tls"
"errors"
"io"
"net/http"
"time"
)
func init() {
RegisterC2N("GET /tls-cert-status", handleC2NTLSCertStatusDisabled)
}
var errNoCerts = errors.New("cert support not compiled in this build")
type TLSCertKeyPair struct {
CertPEM, KeyPEM []byte
}
func (b *LocalBackend) GetCertPEM(ctx context.Context, domain string) (*TLSCertKeyPair, error) {
return nil, errNoCerts
}
func serveTLSNextProtos() []string {
return []string{"h2", "http/1.1"}
}
func (b *LocalBackend) getACMETLSALPNCert(hi *tls.ClientHelloInfo) (*tls.Certificate, bool) {
return nil, false
}
func (b *LocalBackend) getACMETLSALPNProto(hi *tls.ClientHelloInfo) (string, bool) {
return "", false
}
var errCertExpired = errors.New("cert expired")
type certStore interface{}
func getCertPEMCached(cs certStore, domain string, now time.Time) (p *TLSCertKeyPair, err error) {
return nil, errNoCerts
}
func (b *LocalBackend) getCertStore() (certStore, error) {
return nil, errNoCerts
}
func handleC2NTLSCertStatusDisabled(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"Missing":true}`) // a minimal tailcfg.C2NTLSCertInfo
}
-84
View File
@@ -1,84 +0,0 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"context"
"crypto/tls"
"sync"
"time"
"tailscale.com/feature"
"tailscale.com/syncs"
"tailscale.com/util/set"
)
// CertState holds the per-[LocalBackend] state owned by the
// feature/acme extension. The struct lives in this package so that
// cert.go can access its fields directly without method indirection,
// while the extension in feature/acme remains the canonical owner.
//
// In builds without ACME support (js or ts_omit_acme), no extension
// constructs a CertState, and [LocalBackend.certState] returns nil.
//
// CertState is safe for concurrent use; the individual fields document
// their own synchronization.
//
// TODO(bradfitz): continue moving all this cert code into feature/acme's package.
// This type being here was a compromise to keep the PR small during the move.
type CertState struct {
// acmeMu serializes ACME operations so concurrent requests for
// certs don't slam ACME. The first goroutine through populates the
// on-disk cache and the rest reuse it.
acmeMu syncs.Mutex
// renewMu guards renewCertAt.
// Lock order: acmeMu before renewMu.
renewMu syncs.Mutex
renewCertAt map[string]time.Time // lazily initialized under renewMu
// pendingACMETLSALPNCerts maps SNI names to short-lived ACME
// tls-alpn-01 challenge certificates while an ACME order is
// waiting for validation. Entries are deleted by the cleanup
// function returned from storeACMETLSALPNCert after the challenge
// validation path finishes, whether it succeeds or fails.
pendingACMETLSALPNCerts syncs.Map[string, *tls.Certificate] // "foo.bar.com" => challenge cert
// pendingCertDomains tracks the set of domains for which an ACME
// issuance is currently in flight with no usable cached cert. It
// backs the tls-cert-pending health Warnable.
// Guarded by pendingCertDomainsMu.
pendingCertDomainsMu sync.Mutex
pendingCertDomains set.Set[string]
// getCertForTest is used to retrieve TLS certificates in tests.
// See [forTest.ConfigureCerts]. Guarded by the containing
// [LocalBackend]'s mutex (b.mu).
getCertForTest func(hostname string) (*TLSCertKeyPair, error)
// certRefreshCancel cancels the background TLS cert refresh loop
// that periodically pokes [LocalBackend.GetCertPEM] so renewals
// happen on idle nodes. Guarded by the containing [LocalBackend]'s
// mutex (b.mu). Non-nil while the loop is running.
certRefreshCancel context.CancelFunc
}
// hookCertState is set by the feature/acme extension at init time
// to a function that returns the [CertState] for backend b, or nil
// if the cert extension is not registered (e.g. in builds with
// ts_omit_acme or js).
var hookCertState feature.Hook[func(*LocalBackend) *CertState]
// HookCertState exposes [hookCertState] to the feature/acme package
// for installation. It must be set exactly once at init time.
var HookCertState = &hookCertState
// certState returns the cert state for b, or nil if the cert
// extension is not registered.
func (b *LocalBackend) certState() *CertState {
if f, ok := hookCertState.GetOk(); ok {
return f(b)
}
return nil
}
-36
View File
@@ -1,36 +0,0 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !js && !ts_omit_acme
package ipnlocal
import "sync"
// In tests we can't import feature/acme (it would import this package
// and form a cycle), so the real cert extension is never registered.
// Install a default [hookCertState] provider here that lazily creates
// a [CertState] per [LocalBackend].
//
// Tests that want different behavior can use
// [feature.Hook.SetForTest] to override this hook for the duration
// of the test.
func init() {
if hookCertState.IsSet() {
return
}
var (
mu sync.Mutex
states = map[*LocalBackend]*CertState{}
)
hookCertState.Set(func(b *LocalBackend) *CertState {
mu.Lock()
defer mu.Unlock()
if s, ok := states[b]; ok {
return s
}
s := new(CertState)
states[b] = s
return s
})
}
File diff suppressed because it is too large Load Diff
+37 -6
View File
@@ -4,12 +4,14 @@
package ipnlocal
import (
"crypto/tls"
"net/http"
"tailscale.com/control/controlclient"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnauth"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
"tailscale.com/types/key"
"tailscale.com/types/netmap"
"tailscale.com/util/testenv"
@@ -99,16 +101,45 @@ func (f forTest) CurrentUser() (ipn.WindowsUserID, ipnauth.Actor) {
// ConfigureCerts sets a certificate retrieval function to be used by this
// local backend, skipping the usual ACME certificate registration.
func (f forTest) ConfigureCerts(getCert func(hostname string) (*TLSCertKeyPair, error)) {
b := f.b
cs := b.certState()
if cs == nil {
hook, ok := HookConfigureCertsForTest.GetOk()
if !ok {
panic("forTest.ConfigureCerts called without cert extension registered")
}
b.mu.Lock()
cs.getCertForTest = getCert
b.mu.Unlock()
hook(f.b, getCert)
}
// GetACMETLSALPNCert returns the short-lived ACME tls-alpn-01 challenge
// certificate for hi, if any.
func (f forTest) GetACMETLSALPNCert(hi *tls.ClientHelloInfo) (*tls.Certificate, bool) {
return f.b.getACMETLSALPNCert(hi)
}
// SetServeConfig installs sc as the backend's current
// [ipn.ServeConfig] without going through the validation in
// [LocalBackend.SetServeConfig]. It is intended for tests that need a
// specific serve config without first standing up the prerequisites
// (netmap, prefs, etc.).
func (f forTest) SetServeConfig(sc ipn.ServeConfigView) {
b := f.b
b.mu.Lock()
defer b.mu.Unlock()
b.serveConfig = sc
}
// SetNetMap installs nm as the backend's current netmap without going
// through control-plane plumbing. It is intended for tests that need a
// specific netmap (e.g. CertDomains, capabilities).
func (f forTest) SetNetMap(nm *netmap.NetworkMap) {
b := f.b
b.mu.Lock()
defer b.mu.Unlock()
b.currentNode().SetNetMap(nm)
}
// SetClock replaces b's clock with c, for tests that need
// time-dependent behavior to be deterministic.
func (f forTest) SetClock(c tstime.Clock) { f.b.clock = c }
// SetPrefs replaces the current prefs with newp.
func (f forTest) SetPrefs(newp *ipn.Prefs) {
if newp == nil {
+64
View File
@@ -0,0 +1,64 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package ipnlocaltest provides test helpers for constructing a
// [*ipnlocal.LocalBackend] from external test packages that cannot
// access ipnlocal's internal test helpers.
package ipnlocaltest
import (
"testing"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/store/mem"
"tailscale.com/net/netmon"
"tailscale.com/net/tsdial"
"tailscale.com/tsd"
"tailscale.com/types/logger"
"tailscale.com/types/logid"
"tailscale.com/util/eventbus/eventbustest"
"tailscale.com/util/testenv"
"tailscale.com/wgengine"
)
// NewBackend creates a new [*ipnlocal.LocalBackend] suitable for tests,
// using an in-memory state store, a fake userspace engine, and a static
// network monitor. Shutdown is registered as a t.Cleanup.
func NewBackend(t testing.TB) *ipnlocal.LocalBackend {
testenv.AssertInTest()
bus := eventbustest.NewBus(t)
return NewBackendWithSys(t, tsd.NewSystemWithBus(bus))
}
// NewBackendWithSys creates a new [*ipnlocal.LocalBackend] with the
// given [*tsd.System]. Missing components in sys (state store, engine,
// dialer) are filled in with test fakes.
func NewBackendWithSys(t testing.TB, sys *tsd.System) *ipnlocal.LocalBackend {
testenv.AssertInTest()
var logf logger.Logf = logger.Discard
if _, ok := sys.StateStore.GetOK(); !ok {
sys.Set(new(mem.Store))
t.Log("Added memory store for testing")
}
if _, ok := sys.Engine.GetOK(); !ok {
eng, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker.Get(), sys.UserMetricsRegistry(), sys.Bus.Get())
if err != nil {
t.Fatalf("NewFakeUserspaceEngine: %v", err)
}
t.Cleanup(eng.Close)
sys.Set(eng)
t.Log("Added fake userspace engine for testing")
}
if _, ok := sys.Dialer.GetOK(); !ok {
dialer := tsdial.NewDialer(netmon.NewStatic())
dialer.SetBus(sys.Bus.Get())
sys.Set(dialer)
t.Log("Added static dialer for testing")
}
lb, err := ipnlocal.NewLocalBackend(logf, logid.PublicID{}, sys, 0)
if err != nil {
t.Fatalf("NewLocalBackend: %v", err)
}
t.Cleanup(lb.Shutdown)
return lb
}
+1 -74
View File
@@ -1221,11 +1221,6 @@ var (
hookCheckCaptivePortalLoop feature.Hook[func(*LocalBackend, context.Context)]
)
// hookCertRefreshLoop is set by the ACME-enabled cert code to a function
// that periodically refreshes TLS certs for Serve/Funnel-configured
// domains so renewals proceed even on otherwise-idle nodes.
var hookCertRefreshLoop feature.Hook[func(*LocalBackend, context.Context)]
func (b *LocalBackend) onHealthChange(change health.Change) {
if !buildfeatures.HasHealth {
return
@@ -1327,12 +1322,7 @@ func (b *LocalBackend) Shutdown() {
b.captiveCancel()
}
if buildfeatures.HasACME {
if state := b.certState(); state != nil && state.certRefreshCancel != nil {
state.certRefreshCancel()
state.certRefreshCancel = nil
}
}
b.shutdownCertRefreshLoopLocked()
b.stopReconnectTimerLocked()
@@ -7478,69 +7468,6 @@ func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked(prefs ipn.
// The LocalBackend's mutex is held while calling.
var hookMaybeMutateHostinfoLocked feature.Hooks[func(*LocalBackend, *tailcfg.Hostinfo, ipn.PrefsView) bool]
// updateCertRefreshLoopLocked starts or stops the background TLS cert
// refresh loop based on whether we currently have any HTTPS-serving
// hostname whose cert we should keep fresh. The loop runs only while:
//
// - ACME support is compiled in,
// - the node is in [ipn.Running], and
// - the current [ipn.ServeConfig] has at least one HTTPS Web entry.
//
// We deliberately don't keep an idle timer around on hosts that have no
// certs to maintain (e.g. mobile devices that never run Serve), so this
// must be called whenever any of those inputs change: state transitions
// and ServeConfig reloads.
//
// b.mu must be held.
func (b *LocalBackend) updateCertRefreshLoopLocked() {
if !buildfeatures.HasACME {
return
}
state := b.certState()
if state == nil {
return
}
shouldRun := hookCertRefreshLoop.IsSet() &&
b.state == ipn.Running &&
serveConfigUsesACMECerts(b.serveConfig)
switch {
case shouldRun && state.certRefreshCancel == nil:
ctx, cancel := context.WithCancel(b.ctx)
state.certRefreshCancel = cancel
b.goTracker.Go(func() { hookCertRefreshLoop.Get()(b, ctx) })
case !shouldRun && state.certRefreshCancel != nil:
state.certRefreshCancel()
state.certRefreshCancel = nil
}
}
// serveConfigUsesACMECerts reports whether sc has any entry that
// causes tailscaled to obtain ACME-managed TLS certs: an HTTPS Web
// entry (background, foreground, or service) or a TCP handler with
// TerminateTLS set (`tailscale serve --tls-terminated-tcp`).
func serveConfigUsesACMECerts(sc ipn.ServeConfigView) bool {
if !sc.Valid() {
return false
}
for range sc.Webs() {
return true
}
for _, tcp := range sc.TCPs() {
if tcp.TerminateTLS() != "" {
return true
}
}
for _, svc := range sc.Services().All() {
for _, tcp := range svc.TCP().All() {
if tcp.TerminateTLS() != "" {
return true
}
}
}
return false
}
// maybeSentHostinfoIfChangedLocked updates the hostinfo.ServicesHash, hostinfo.WireIngress and
// hostinfo.IngressEnabled fields and kicks off a Hostinfo update if the values have changed.
//
+3 -1
View File
@@ -1367,7 +1367,9 @@ func (b *LocalBackend) serveTLSConfig(getCert func(*tls.ClientHelloInfo) (*tls.C
return base
}
func (b *LocalBackend) hasFunnelForHostPort(host string, port uint16) bool {
// HasFunnelForHostPort reports whether the LocalBackend's serve config
// has Funnel enabled for host:port.
func (b *LocalBackend) HasFunnelForHostPort(host string, port uint16) bool {
b.mu.Lock()
defer b.mu.Unlock()
if !b.serveConfig.Valid() {
+1 -1
View File
@@ -28,7 +28,7 @@ type funnelFlow = struct{}
func (*LocalBackend) hasIngressEnabledLocked() bool { return false }
func (*LocalBackend) shouldWireInactiveIngressLocked() bool { return false }
func (*LocalBackend) hasFunnelForHostPort(host string, port uint16) bool {
func (*LocalBackend) HasFunnelForHostPort(host string, port uint16) bool {
return false
}