@ -37,7 +37,6 @@ import (
"tailscale.com/feature/buildfeatures"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/ipn/store"
"tailscale.com/ipn/store/mem"
"tailscale.com/net/bakedroots"
@ -106,6 +105,13 @@ func (b *LocalBackend) GetCertPEM(ctx context.Context, domain string) (*TLSCertK
//
// If a cert is expired, or expires sooner than minValidity, it will be renewed
// synchronously. Otherwise it will be renewed asynchronously.
//
// The domain must be one of:
//
// - An exact CertDomain (e.g., "node.ts.net")
// - A wildcard domain (e.g., "*.node.ts.net")
//
// The wildcard format requires the NodeAttrDNSSubdomainResolve capability.
func ( b * LocalBackend ) GetCertPEMWithValidity ( ctx context . Context , domain string , minValidity time . Duration ) ( * TLSCertKeyPair , error ) {
b . mu . Lock ( )
getCertForTest := b . getCertForTest
@ -119,6 +125,13 @@ func (b *LocalBackend) GetCertPEMWithValidity(ctx context.Context, domain string
if ! validLookingCertDomain ( domain ) {
return nil , errors . New ( "invalid domain" )
}
certDomain , err := b . resolveCertDomain ( domain )
if err != nil {
return nil , err
}
storageKey := strings . TrimPrefix ( certDomain , "*." )
logf := logger . WithPrefix ( b . logf , fmt . Sprintf ( "cert(%q): " , domain ) )
now := b . clock . Now ( )
traceACME := func ( v any ) {
@ -134,13 +147,13 @@ func (b *LocalBackend) GetCertPEMWithValidity(ctx context.Context, domain string
return nil , err
}
if pair , err := getCertPEMCached ( cs , domain , now ) ; err == nil {
if pair , err := getCertPEMCached ( cs , storageKey , now ) ; err == nil {
if envknob . IsCertShareReadOnlyMode ( ) {
return pair , nil
}
// If we got here, we have a valid unexpired cert.
// Check whether we should start an async renewal.
shouldRenew , err := b . shouldStartDomainRenewal ( cs , domain , now , pair , minValidity )
shouldRenew , err := b . shouldStartDomainRenewal ( cs , storageKey , now , pair , minValidity )
if err != nil {
logf ( "error checking for certificate renewal: %v" , err )
// Renewal check failed, but the current cert is valid and not
@ -154,7 +167,7 @@ func (b *LocalBackend) GetCertPEMWithValidity(ctx context.Context, domain string
logf ( "starting async renewal" )
// Start renewal in the background, return current valid cert.
b . goTracker . Go ( func ( ) {
if _ , err := getCertPEM ( context . Background ( ) , b , cs , logf , traceACME , d omain, now , minValidity ) ; err != nil {
if _ , err := getCertPEM ( context . Background ( ) , b , cs , logf , traceACME , certD omain, now , minValidity ) ; err != nil {
logf ( "async renewal failed: getCertPem: %v" , err )
}
} )
@ -169,7 +182,7 @@ func (b *LocalBackend) GetCertPEMWithValidity(ctx context.Context, domain string
return nil , fmt . Errorf ( "retrieving cached TLS certificate failed and cert store is configured in read-only mode, not attempting to issue a new certificate: %w" , err )
}
pair , err := getCertPEM ( ctx , b , cs , logf , traceACME , d omain, now , minValidity )
pair , err := getCertPEM ( ctx , b , cs , logf , traceACME , certD omain, now , minValidity )
if err != nil {
logf ( "getCertPEM: %v" , err )
return nil , err
@ -506,19 +519,24 @@ func getCertPEMCached(cs certStore, domain string, now time.Time) (p *TLSCertKey
}
// getCertPem checks if a cert needs to be renewed and if so, renews it.
// domain is the resolved cert domain (e.g., "*.node.ts.net" for wildcards).
// It can be overridden in tests.
var 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 ) {
acmeMu . Lock ( )
defer acmeMu . Unlock ( )
// storageKey is used for file storage and renewal tracking.
// For wildcards, "*.node.ts.net" -> "node.ts.net"
storageKey , isWildcard := strings . CutPrefix ( domain , "*." )
// In case this method was triggered multiple times in parallel (when
// serving incoming requests), check whether one of the other goroutines
// already renewed the cert before us.
previous , err := getCertPEMCached ( cs , domain , now )
previous , err := getCertPEMCached ( cs , storageKey , now )
if err == nil {
// shouldStartDomainRenewal caches its result so it's OK to call this
// frequently.
shouldRenew , err := b . shouldStartDomainRenewal ( cs , domain , now , previous , minValidity )
shouldRenew , err := b . shouldStartDomainRenewal ( cs , storageKey , now , previous , minValidity )
if err != nil {
logf ( "error checking for certificate renewal: %v" , err )
} else if ! shouldRenew {
@ -561,12 +579,6 @@ var getCertPEM = func(ctx context.Context, b *LocalBackend, cs certStore, logf l
return nil , fmt . Errorf ( "unexpected ACME account status %q" , a . Status )
}
// Before hitting LetsEncrypt, see if this is a domain that Tailscale will do DNS challenges for.
st := b . StatusWithoutPeers ( )
if err := checkCertDomain ( st , domain ) ; err != nil {
return nil , err
}
// If we have a previous cert, include it in the order. Assuming we're
// within the ARI renewal window this should exclude us from LE rate
// limits.
@ -580,7 +592,18 @@ var getCertPEM = func(ctx context.Context, b *LocalBackend, cs certStore, logf l
opts = append ( opts , acme . WithOrderReplacesCert ( prevCrt ) )
}
}
order , err := ac . AuthorizeOrder ( ctx , [ ] acme . AuthzID { { Type : "dns" , Value : domain } } , opts ... )
// For wildcards, we need to authorize both the wildcard and base domain.
var authzIDs [ ] acme . AuthzID
if isWildcard {
authzIDs = [ ] acme . AuthzID {
{ Type : "dns" , Value : domain } ,
{ Type : "dns" , Value : storageKey } ,
}
} else {
authzIDs = [ ] acme . AuthzID { { Type : "dns" , Value : domain } }
}
order , err := ac . AuthorizeOrder ( ctx , authzIDs , opts ... )
if err != nil {
return nil , err
}
@ -598,7 +621,9 @@ var getCertPEM = func(ctx context.Context, b *LocalBackend, cs certStore, logf l
if err != nil {
return nil , err
}
key := "_acme-challenge." + domain
// For wildcards, the challenge is on the base domain.
// e.g., "*.node.ts.net" -> "_acme-challenge.node.ts.net"
key := "_acme-challenge." + strings . TrimPrefix ( az . Identifier . Value , "*." )
// Do a best-effort lookup to see if we've already created this DNS name
// in a previous attempt. Don't burn too much time on it, though. Worst
@ -608,14 +633,14 @@ var getCertPEM = func(ctx context.Context, b *LocalBackend, cs certStore, logf l
txts , _ := resolver . LookupTXT ( lookupCtx , key )
lookupCancel ( )
if slices . Contains ( txts , rec ) {
logf ( "TXT record already existed" )
logf ( "TXT record already existed for %s " , key )
} else {
logf ( "starting SetDNS call..." )
logf ( "starting SetDNS call for %s ..." , key )
err = b . SetDNS ( ctx , key , rec )
if err != nil {
return nil , fmt . Errorf ( "SetDNS %q => %q: %w" , key , rec , err )
}
logf ( "did SetDNS" )
logf ( "did SetDNS for %s " , key )
}
chal , err := ac . Accept ( ctx , ch )
@ -672,19 +697,27 @@ var getCertPEM = func(ctx context.Context, b *LocalBackend, cs certStore, logf l
return nil , err
}
}
if err := cs . WriteTLSCertAndKey ( domain , certPEM . Bytes ( ) , privPEM . Bytes ( ) ) ; err != nil {
if err := cs . WriteTLSCertAndKey ( storageKey , certPEM . Bytes ( ) , privPEM . Bytes ( ) ) ; err != nil {
return nil , err
}
b . domainRenewed ( domain )
b . domainRenewed ( storageKey )
return & TLSCertKeyPair { CertPEM : certPEM . Bytes ( ) , KeyPEM : privPEM . Bytes ( ) } , nil
}
// certRequest generates a CSR for the given common name cn and optional SANs.
func certRequest ( key crypto . Signer , name string , ext [ ] pkix . Extension ) ( [ ] byte , error ) {
// certRequest generates a CSR for the given domain and optional SANs.
func certRequest ( key crypto . Signer , domain string , ext [ ] pkix . Extension ) ( [ ] byte , error ) {
dnsNames := [ ] string { domain }
if base , ok := strings . CutPrefix ( domain , "*." ) ; ok {
// Wildcard cert must also include the base domain as a SAN.
// This is load-bearing: getCertPEMCached validates certs using
// the storage key (base domain), which only passes x509 verification
// if the base domain is in DNSNames.
dnsNames = append ( dnsNames , base )
}
req := & x509 . CertificateRequest {
Subject : pkix . Name { CommonName : name } ,
DNSNames : [ ] string { name } ,
Subject : pkix . Name { CommonName : domain } ,
DNSNames : d nsN ames ,
ExtraExtensions : ext ,
}
return x509 . CreateCertificateRequest ( rand . Reader , req , key )
@ -844,7 +877,7 @@ func isDefaultDirectoryURL(u string) bool {
// we might be able to get a cert for.
//
// It's a light check primarily for double checking before it's used
// as part of a filesystem path. The actual validation happens in check CertDomain.
// as part of a filesystem path. The actual validation happens in resolve CertDomain.
func validLookingCertDomain ( name string ) bool {
if name == "" ||
strings . Contains ( name , ".." ) ||
@ -852,22 +885,56 @@ func validLookingCertDomain(name string) bool {
! strings . Contains ( name , "." ) {
return false
}
// Only allow * as a wildcard prefix "*.domain.tld"
if rest , ok := strings . CutPrefix ( name , "*." ) ; ok {
if strings . Contains ( rest , "*" ) || ! strings . Contains ( rest , "." ) {
return false
}
} else if strings . Contains ( name , "*" ) {
return false
}
return true
}
func checkCertDomain ( st * ipnstate . Status , domain string ) error {
// resolveCertDomain validates a domain and returns the cert domain to use.
//
// - "node.ts.net" -> "node.ts.net" (exact CertDomain match)
// - "*.node.ts.net" -> "*.node.ts.net" (explicit wildcard, requires NodeAttrDNSSubdomainResolve)
//
// Subdomain requests like "app.node.ts.net" are rejected; callers should
// request "*.node.ts.net" explicitly for subdomain coverage.
func ( b * LocalBackend ) resolveCertDomain ( domain string ) ( string , error ) {
if domain == "" {
return errors . New ( "missing domain name" )
return "" , errors . New ( "missing domain name" )
}
for _ , d := range st . CertDomains {
if d == domain {
return nil
// Read the netmap once to get both CertDomains and capabilities atomically.
nm := b . NetMap ( )
if nm == nil {
return "" , errors . New ( "no netmap available" )
}
certDomains := nm . DNS . CertDomains
if len ( certDomains ) == 0 {
return "" , errors . New ( "your Tailscale account does not support getting TLS certs" )
}
// Wildcard request like "*.node.ts.net".
if base , ok := strings . CutPrefix ( domain , "*." ) ; ok {
if ! nm . AllCaps . Contains ( tailcfg . NodeAttrDNSSubdomainResolve ) {
return "" , fmt . Errorf ( "wildcard certificates are not enabled for this node" )
}
if ! slices . Contains ( certDomains , base ) {
return "" , fmt . Errorf ( "invalid domain %q; parent domain must be one of %q" , domain , certDomains )
}
return domain , nil
}
if len ( st . CertDomains ) == 0 {
return errors . New ( "your Tailscale account does not support getting TLS certs" )
// Exact CertDomain match.
if slices . Contains ( certDomains , domain ) {
return domain , nil
}
return fmt . Errorf ( "invalid domain %q; must be one of %q" , domain , st . CertDomains )
return "" , fmt . Errorf ( "invalid domain %q; must be one of %q" , domain , certDomains )
}
// handleC2NTLSCertStatus returns info about the last TLS certificate issued for the
@ -884,7 +951,7 @@ func handleC2NTLSCertStatus(b *LocalBackend, w http.ResponseWriter, r *http.Requ
return
}
domain := r . FormValue ( "domain" )
domain := strings . TrimPrefix ( r . FormValue ( "domain" ) , "*. " )
if domain == "" {
http . Error ( w , "no 'domain'" , http . StatusBadRequest )
return