ipnlocal: try ACME TLS-ALPN for Funnel renewals
Use TLS-ALPN-01 for Funnel certificate renewals only when the node already has a cached certificate, and fall back to DNS-01 with a fresh order if the ALPN path is unavailable or fails. Dynamically advertise acme-tls/1 only while an ACME challenge certificate is pending, and add client metrics for DNS-01 and TLS-ALPN-01 start/success/failure paths. Updates tailscale/corp#41736 Fixes tailscale/corp#42320 Change-Id: I5adc6ea129237f9ef592f84fc1a8953c80bc9d5c Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
committed by
Brad Fitzpatrick
parent
4aef023765
commit
b553969b03
+229
-52
@@ -44,6 +44,7 @@ import (
|
|||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
"tailscale.com/tempfork/acme"
|
"tailscale.com/tempfork/acme"
|
||||||
"tailscale.com/types/logger"
|
"tailscale.com/types/logger"
|
||||||
|
"tailscale.com/util/clientmetric"
|
||||||
"tailscale.com/util/testenv"
|
"tailscale.com/util/testenv"
|
||||||
"tailscale.com/version"
|
"tailscale.com/version"
|
||||||
"tailscale.com/version/distro"
|
"tailscale.com/version/distro"
|
||||||
@@ -65,6 +66,63 @@ var (
|
|||||||
renewCertAt = map[string]time.Time{}
|
renewCertAt = map[string]time.Time{}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
metricACMEDNS01Start = clientmetric.NewCounter("cert_acme_dns01_start")
|
||||||
|
metricACMEDNS01Success = clientmetric.NewCounter("cert_acme_dns01_success")
|
||||||
|
metricACMEDNS01Failure = clientmetric.NewCounter("cert_acme_dns01_failure")
|
||||||
|
metricACMETLSALPN01Start = clientmetric.NewCounter("cert_acme_tls_alpn01_start")
|
||||||
|
metricACMETLSALPN01Success = clientmetric.NewCounter("cert_acme_tls_alpn01_success")
|
||||||
|
metricACMETLSALPN01Failure = clientmetric.NewCounter("cert_acme_tls_alpn01_failure")
|
||||||
|
)
|
||||||
|
|
||||||
|
type acmeChallengeType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
acmeChallengeDNS01 acmeChallengeType = "dns-01"
|
||||||
|
acmeChallengeTLSALPN01 acmeChallengeType = "tls-alpn-01"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serveTLSNextProtos returns the baseline ALPN protocols for ordinary Serve
|
||||||
|
// TLS traffic. ACME tls-alpn-01 is intentionally not advertised here; it is
|
||||||
|
// added dynamically by serveTLSConfig only while a matching challenge
|
||||||
|
// certificate is pending.
|
||||||
|
func serveTLSNextProtos() []string {
|
||||||
|
return []string{"h2", "http/1.1"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getACMETLSALPNCert returns the short-lived ACME challenge certificate for
|
||||||
|
// hi.ServerName. The ok result reports whether hi offered acme-tls/1 and an
|
||||||
|
// ACME order is actively waiting on that challenge for hi.ServerName.
|
||||||
|
func (b *LocalBackend) getACMETLSALPNCert(hi *tls.ClientHelloInfo) (cert *tls.Certificate, ok bool) {
|
||||||
|
if hi == nil || hi.ServerName == "" || !slices.Contains(hi.SupportedProtos, acme.ALPNProto) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
cert, ok = b.pendingACMETLSALPNCerts.Load(hi.ServerName)
|
||||||
|
return cert, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// getACMETLSALPNProto reports whether serveTLSConfig should advertise an ACME
|
||||||
|
// ALPN protocol for this ClientHello. The proto result is the protocol to
|
||||||
|
// advertise, and ok reports whether hi offered acme-tls/1 and an ACME order is
|
||||||
|
// actively waiting on that challenge for hi.ServerName. It is separate from
|
||||||
|
// getACMETLSALPNCert because Go selects ALPN before calling GetCertificate;
|
||||||
|
// both hooks must agree for the challenge handshake to complete.
|
||||||
|
func (b *LocalBackend) getACMETLSALPNProto(hi *tls.ClientHelloInfo) (proto string, ok bool) {
|
||||||
|
if _, ok := b.getACMETLSALPNCert(hi); !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return acme.ALPNProto, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeACMETLSALPNCert publishes cert to Serve TLS handshakes for domain until
|
||||||
|
// the returned cleanup function is called.
|
||||||
|
func (b *LocalBackend) storeACMETLSALPNCert(domain string, cert *tls.Certificate) (cleanup func()) {
|
||||||
|
b.pendingACMETLSALPNCerts.Store(domain, cert)
|
||||||
|
return func() {
|
||||||
|
b.pendingACMETLSALPNCerts.Delete(domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// certDir returns (creating if needed) the directory in which cached
|
// certDir returns (creating if needed) the directory in which cached
|
||||||
// cert keypairs are stored.
|
// cert keypairs are stored.
|
||||||
func (b *LocalBackend) certDir() (string, error) {
|
func (b *LocalBackend) certDir() (string, error) {
|
||||||
@@ -244,6 +302,36 @@ func (b *LocalBackend) domainRenewalTimeByExpiry(pair *TLSCertKeyPair) (time.Tim
|
|||||||
return renewAt, nil
|
return renewAt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *LocalBackend) shouldUseACMETLSALPN01(domain string, previous *TLSCertKeyPair, logf logger.Logf) bool {
|
||||||
|
if isWildcardDomain(domain) {
|
||||||
|
logf("acme: using dns-01: tls-alpn-01 does not support wildcard certificates")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !b.hasFunnelForHostPort(domain, 443) {
|
||||||
|
logf("acme: using dns-01: Funnel is not enabled for %s:443", domain)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if previous == nil {
|
||||||
|
logf("acme: using dns-01: no cached certificate for Funnel renewal")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
logf("acme: using tls-alpn-01")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func challengeByType(challenges []*acme.Challenge, typ string) *acme.Challenge {
|
||||||
|
for _, ch := range challenges {
|
||||||
|
if ch.Type == typ {
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWildcardDomain(domain string) bool {
|
||||||
|
return strings.HasPrefix(domain, "*.")
|
||||||
|
}
|
||||||
|
|
||||||
func (b *LocalBackend) domainRenewalTimeByARI(cs certStore, pair *TLSCertKeyPair) (time.Time, error) {
|
func (b *LocalBackend) domainRenewalTimeByARI(cs certStore, pair *TLSCertKeyPair) (time.Time, error) {
|
||||||
var blocks []*pem.Block
|
var blocks []*pem.Block
|
||||||
rest := pair.CertPEM
|
rest := pair.CertPEM
|
||||||
@@ -527,8 +615,6 @@ var getCertPEM = func(ctx context.Context, b *LocalBackend, cs certStore, logf l
|
|||||||
acmeMu.Lock()
|
acmeMu.Lock()
|
||||||
defer acmeMu.Unlock()
|
defer acmeMu.Unlock()
|
||||||
|
|
||||||
baseDomain, isWildcard := strings.CutPrefix(domain, "*.")
|
|
||||||
|
|
||||||
// In case this method was triggered multiple times in parallel (when
|
// In case this method was triggered multiple times in parallel (when
|
||||||
// serving incoming requests), check whether one of the other goroutines
|
// serving incoming requests), check whether one of the other goroutines
|
||||||
// already renewed the cert before us.
|
// already renewed the cert before us.
|
||||||
@@ -593,63 +679,113 @@ var getCertPEM = func(ctx context.Context, b *LocalBackend, cs certStore, logf l
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
issueArgs := acmeCertIssueArgs{
|
||||||
|
cs: cs,
|
||||||
|
logf: logf,
|
||||||
|
traceACME: traceACME,
|
||||||
|
domain: domain,
|
||||||
|
opts: opts,
|
||||||
|
}
|
||||||
|
if b.shouldUseACMETLSALPN01(domain, previous, logf) {
|
||||||
|
issueArgs.challengeType = acmeChallengeTLSALPN01
|
||||||
|
pair, err := b.issueACMECert(ctx, ac, issueArgs)
|
||||||
|
if err == nil {
|
||||||
|
return pair, nil
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
logf("acme: tls-alpn-01 failed; falling back to dns-01: %v", err)
|
||||||
|
}
|
||||||
|
issueArgs.challengeType = acmeChallengeDNS01
|
||||||
|
return b.issueACMECert(ctx, ac, issueArgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
type acmeCertIssueArgs struct {
|
||||||
|
cs certStore // certificate and ACME account storage
|
||||||
|
logf logger.Logf // logs ACME progress and failures
|
||||||
|
traceACME func(any) // optional hook for logging ACME messages
|
||||||
|
domain string // certificate domain being issued
|
||||||
|
opts []acme.OrderOption // ACME order options
|
||||||
|
challengeType acmeChallengeType // challenge type to fulfill
|
||||||
|
}
|
||||||
|
|
||||||
|
func (args acmeCertIssueArgs) baseDomain() string { return strings.TrimPrefix(args.domain, "*.") }
|
||||||
|
func (args acmeCertIssueArgs) isWildcard() bool { return isWildcardDomain(args.domain) }
|
||||||
|
|
||||||
|
func (b *LocalBackend) issueACMECert(ctx context.Context, ac *acme.Client, args acmeCertIssueArgs) (ret *TLSCertKeyPair, err error) {
|
||||||
|
if args.traceACME == nil {
|
||||||
|
args.traceACME = func(any) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch args.challengeType {
|
||||||
|
case acmeChallengeTLSALPN01:
|
||||||
|
metricACMETLSALPN01Start.Add(1)
|
||||||
|
defer func() {
|
||||||
|
if err == nil {
|
||||||
|
metricACMETLSALPN01Success.Add(1)
|
||||||
|
} else {
|
||||||
|
metricACMETLSALPN01Failure.Add(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
case acmeChallengeDNS01:
|
||||||
|
metricACMEDNS01Start.Add(1)
|
||||||
|
defer func() {
|
||||||
|
if err == nil {
|
||||||
|
metricACMEDNS01Success.Add(1)
|
||||||
|
} else {
|
||||||
|
metricACMEDNS01Failure.Add(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown ACME challenge type %q", args.challengeType)
|
||||||
|
}
|
||||||
|
|
||||||
// For wildcards, we need to authorize both the wildcard and base domain.
|
// For wildcards, we need to authorize both the wildcard and base domain.
|
||||||
var authzIDs []acme.AuthzID
|
var authzIDs []acme.AuthzID
|
||||||
if isWildcard {
|
if args.isWildcard() {
|
||||||
authzIDs = []acme.AuthzID{
|
authzIDs = []acme.AuthzID{
|
||||||
{Type: "dns", Value: domain},
|
{Type: "dns", Value: args.domain},
|
||||||
{Type: "dns", Value: baseDomain},
|
{Type: "dns", Value: args.baseDomain()},
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
authzIDs = []acme.AuthzID{{Type: "dns", Value: domain}}
|
authzIDs = []acme.AuthzID{{Type: "dns", Value: args.domain}}
|
||||||
}
|
}
|
||||||
order, err := ac.AuthorizeOrder(ctx, authzIDs, opts...)
|
order, err := ac.AuthorizeOrder(ctx, authzIDs, args.opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
traceACME(order)
|
args.traceACME(order)
|
||||||
|
|
||||||
for _, aurl := range order.AuthzURLs {
|
for _, aurl := range order.AuthzURLs {
|
||||||
az, err := ac.GetAuthorization(ctx, aurl)
|
az, err := ac.GetAuthorization(ctx, aurl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
traceACME(az)
|
args.traceACME(az)
|
||||||
for _, ch := range az.Challenges {
|
switch args.challengeType {
|
||||||
if ch.Type == "dns-01" {
|
case acmeChallengeTLSALPN01:
|
||||||
rec, err := ac.DNS01ChallengeRecord(ch.Token)
|
ch := challengeByType(az.Challenges, string(acmeChallengeTLSALPN01))
|
||||||
if err != nil {
|
if ch == nil {
|
||||||
return nil, err
|
return nil, errors.New("tls-alpn-01 challenge not offered")
|
||||||
}
|
|
||||||
// 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
|
|
||||||
// case we ask the server to create something that already exists.
|
|
||||||
var resolver net.Resolver
|
|
||||||
lookupCtx, lookupCancel := context.WithTimeout(ctx, 500*time.Millisecond)
|
|
||||||
txts, _ := resolver.LookupTXT(lookupCtx, key)
|
|
||||||
lookupCancel()
|
|
||||||
if slices.Contains(txts, rec) {
|
|
||||||
logf("TXT record already existed for %s", key)
|
|
||||||
} else {
|
|
||||||
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 for %s", key)
|
|
||||||
}
|
|
||||||
|
|
||||||
chal, err := ac.Accept(ctx, ch)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("Accept: %v", err)
|
|
||||||
}
|
|
||||||
traceACME(chal)
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
cert, err := ac.TLSALPN01ChallengeCert(ch.Token, az.Identifier.Value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("TLSALPN01ChallengeCert: %w", err)
|
||||||
|
}
|
||||||
|
cleanup := b.storeACMETLSALPNCert(az.Identifier.Value, &cert)
|
||||||
|
defer cleanup()
|
||||||
|
chal, err := ac.Accept(ctx, ch)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Accept: %v", err)
|
||||||
|
}
|
||||||
|
args.traceACME(chal)
|
||||||
|
case acmeChallengeDNS01:
|
||||||
|
if err := b.fulfillACMEDNS01Challenge(ctx, ac, az, args.logf, args.traceACME); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown ACME challenge type %q", args.challengeType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -660,13 +796,13 @@ var getCertPEM = func(ctx context.Context, b *LocalBackend, cs certStore, logf l
|
|||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
}
|
}
|
||||||
if oe, ok := err.(*acme.OrderError); ok {
|
if oe, ok := err.(*acme.OrderError); ok {
|
||||||
logf("acme: WaitOrder: OrderError status %q", oe.Status)
|
args.logf("acme: WaitOrder: OrderError status %q", oe.Status)
|
||||||
} else {
|
} else {
|
||||||
logf("acme: WaitOrder error: %v", err)
|
args.logf("acme: WaitOrder error: %v", err)
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
traceACME(order)
|
args.traceACME(order)
|
||||||
|
|
||||||
certPrivKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
certPrivKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -677,18 +813,18 @@ var getCertPEM = func(ctx context.Context, b *LocalBackend, cs certStore, logf l
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
csr, err := certRequest(certPrivKey, domain, nil)
|
csr, err := certRequest(certPrivKey, args.domain, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
logf("requesting cert...")
|
args.logf("requesting cert...")
|
||||||
traceACME(csr)
|
args.traceACME(csr)
|
||||||
der, _, err := ac.CreateOrderCert(ctx, order.FinalizeURL, csr, true)
|
der, _, err := ac.CreateOrderCert(ctx, order.FinalizeURL, csr, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("CreateOrder: %v", err)
|
return nil, fmt.Errorf("CreateOrder: %v", err)
|
||||||
}
|
}
|
||||||
logf("got cert")
|
args.logf("got cert")
|
||||||
|
|
||||||
var certPEM bytes.Buffer
|
var certPEM bytes.Buffer
|
||||||
for _, b := range der {
|
for _, b := range der {
|
||||||
@@ -697,14 +833,55 @@ var getCertPEM = func(ctx context.Context, b *LocalBackend, cs certStore, logf l
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := cs.WriteTLSCertAndKey(domain, certPEM.Bytes(), privPEM.Bytes()); err != nil {
|
if err := args.cs.WriteTLSCertAndKey(args.domain, certPEM.Bytes(), privPEM.Bytes()); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
b.domainRenewed(domain)
|
b.domainRenewed(args.domain)
|
||||||
|
|
||||||
return &TLSCertKeyPair{CertPEM: certPEM.Bytes(), KeyPEM: privPEM.Bytes()}, nil
|
return &TLSCertKeyPair{CertPEM: certPEM.Bytes(), KeyPEM: privPEM.Bytes()}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *LocalBackend) fulfillACMEDNS01Challenge(ctx context.Context, ac *acme.Client, az *acme.Authorization, logf logger.Logf, traceACME func(any)) error {
|
||||||
|
for _, ch := range az.Challenges {
|
||||||
|
if ch.Type != string(acmeChallengeDNS01) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rec, err := ac.DNS01ChallengeRecord(ch.Token)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
// case we ask the server to create something that already exists.
|
||||||
|
var resolver net.Resolver
|
||||||
|
lookupCtx, lookupCancel := context.WithTimeout(ctx, 500*time.Millisecond)
|
||||||
|
txts, _ := resolver.LookupTXT(lookupCtx, key)
|
||||||
|
lookupCancel()
|
||||||
|
if slices.Contains(txts, rec) {
|
||||||
|
logf("TXT record already existed for %s", key)
|
||||||
|
} else {
|
||||||
|
logf("starting SetDNS call for %s...", key)
|
||||||
|
err = b.SetDNS(ctx, key, rec)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("SetDNS %q => %q: %w", key, rec, err)
|
||||||
|
}
|
||||||
|
logf("did SetDNS for %s", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
chal, err := ac.Accept(ctx, ch)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Accept: %v", err)
|
||||||
|
}
|
||||||
|
traceACME(chal)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New("dns-01 challenge not offered")
|
||||||
|
}
|
||||||
|
|
||||||
// certRequest generates a CSR for the given domain and optional SANs.
|
// certRequest generates a CSR for the given domain and optional SANs.
|
||||||
func certRequest(key crypto.Signer, domain string, ext []pkix.Extension) ([]byte, error) {
|
func certRequest(key crypto.Signer, domain string, ext []pkix.Extension) ([]byte, error) {
|
||||||
dnsNames := []string{domain}
|
dnsNames := []string{domain}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package ipnlocal
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -27,6 +28,18 @@ func (b *LocalBackend) GetCertPEM(ctx context.Context, domain string) (*TLSCertK
|
|||||||
return nil, errNoCerts
|
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")
|
var errCertExpired = errors.New("cert expired")
|
||||||
|
|
||||||
type certStore interface{}
|
type certStore interface{}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"crypto/ecdsa"
|
"crypto/ecdsa"
|
||||||
"crypto/elliptic"
|
"crypto/elliptic"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"crypto/x509/pkix"
|
"crypto/x509/pkix"
|
||||||
"embed"
|
"embed"
|
||||||
@@ -23,8 +24,10 @@ import (
|
|||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
"tailscale.com/envknob"
|
"tailscale.com/envknob"
|
||||||
|
"tailscale.com/ipn"
|
||||||
"tailscale.com/ipn/store/mem"
|
"tailscale.com/ipn/store/mem"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
|
"tailscale.com/tempfork/acme"
|
||||||
"tailscale.com/tstest"
|
"tailscale.com/tstest"
|
||||||
"tailscale.com/types/logger"
|
"tailscale.com/types/logger"
|
||||||
"tailscale.com/types/netmap"
|
"tailscale.com/types/netmap"
|
||||||
@@ -250,6 +253,87 @@ func TestValidLookingCertDomain(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 domain = "example.com"
|
||||||
|
b := newTestLocalBackend(t)
|
||||||
|
b.mu.Lock()
|
||||||
|
b.serveConfig = (&ipn.ServeConfig{
|
||||||
|
AllowFunnel: map[ipn.HostPort]bool{
|
||||||
|
domain + ":443": true,
|
||||||
|
},
|
||||||
|
}).View()
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
previous := &TLSCertKeyPair{}
|
||||||
|
if !b.shouldUseACMETLSALPN01(domain, previous, t.Logf) {
|
||||||
|
t.Fatal("shouldUseACMETLSALPN01 = false, want true")
|
||||||
|
}
|
||||||
|
if b.shouldUseACMETLSALPN01(domain, nil, t.Logf) {
|
||||||
|
t.Fatal("shouldUseACMETLSALPN01 without cached cert = true, want false")
|
||||||
|
}
|
||||||
|
if b.shouldUseACMETLSALPN01("*."+domain, previous, t.Logf) {
|
||||||
|
t.Fatal("shouldUseACMETLSALPN01 for wildcard = true, want false")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
b.serveConfig = (&ipn.ServeConfig{}).View()
|
||||||
|
b.mu.Unlock()
|
||||||
|
if b.shouldUseACMETLSALPN01(domain, previous, t.Logf) {
|
||||||
|
t.Fatal("shouldUseACMETLSALPN01 without Funnel = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//go:embed testdata/*
|
//go:embed testdata/*
|
||||||
var certTestFS embed.FS
|
var certTestFS embed.FS
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"cmp"
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -245,6 +246,13 @@ type LocalBackend struct {
|
|||||||
// is never called.
|
// is never called.
|
||||||
getTCPHandlerForFunnelFlow func(srcAddr netip.AddrPort, dstPort uint16) (handler func(net.Conn))
|
getTCPHandlerForFunnelFlow func(srcAddr netip.AddrPort, dstPort uint16) (handler func(net.Conn))
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
containsViaIPFuncAtomic syncs.AtomicValue[func(netip.Addr) bool] // TODO(nickkhyl): move to nodeBackend
|
containsViaIPFuncAtomic syncs.AtomicValue[func(netip.Addr) bool] // TODO(nickkhyl): move to nodeBackend
|
||||||
shouldInterceptTCPPortAtomic syncs.AtomicValue[func(uint16) bool] // TODO(nickkhyl): move to nodeBackend
|
shouldInterceptTCPPortAtomic syncs.AtomicValue[func(uint16) bool] // TODO(nickkhyl): move to nodeBackend
|
||||||
shouldInterceptVIPServicesTCPPortAtomic syncs.AtomicValue[func(netip.AddrPort) bool] // TODO(nickkhyl): move to nodeBackend
|
shouldInterceptVIPServicesTCPPortAtomic syncs.AtomicValue[func(netip.AddrPort) bool] // TODO(nickkhyl): move to nodeBackend
|
||||||
|
|||||||
+78
-85
@@ -560,68 +560,14 @@ func (b *LocalBackend) tcpHandlerForVIPService(dstAddr, srcAddr netip.AddrPort)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if tcph.HTTPS() || tcph.HTTP() {
|
// TODO(kevinliang10): just leaving this TLS cert creation as if we don't have other
|
||||||
hs := &http.Server{
|
// hostnames, but for services this getTLSServeCetForPort will need a version that also take
|
||||||
Handler: http.HandlerFunc(b.serveWebHandler),
|
// in the hostname. How to store the TLS cert is still being discussed.
|
||||||
BaseContext: func(_ net.Listener) context.Context {
|
return b.tcpHandlerForServeTCP(tcph, dport, srcAddr, &serveHTTPContext{
|
||||||
return serveHTTPContextKey.WithValue(context.Background(), &serveHTTPContext{
|
SrcAddr: srcAddr,
|
||||||
SrcAddr: srcAddr,
|
ForVIPService: dstSvc,
|
||||||
ForVIPService: dstSvc,
|
DestPort: dport,
|
||||||
DestPort: dport,
|
}, dstSvc)
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if tcph.HTTPS() {
|
|
||||||
// TODO(kevinliang10): just leaving this TLS cert creation as if we don't have other
|
|
||||||
// hostnames, but for services this getTLSServeCetForPort will need a version that also take
|
|
||||||
// in the hostname. How to store the TLS cert is still being discussed.
|
|
||||||
hs.TLSConfig = &tls.Config{
|
|
||||||
GetCertificate: b.getTLSServeCertForPort(dport, dstSvc),
|
|
||||||
}
|
|
||||||
return func(c net.Conn) error {
|
|
||||||
return hs.ServeTLS(netutil.NewOneConnListener(c, nil), "", "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return func(c net.Conn) error {
|
|
||||||
return hs.Serve(netutil.NewOneConnListener(c, nil))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if backDst := tcph.TCPForward(); backDst != "" {
|
|
||||||
return func(conn net.Conn) error {
|
|
||||||
defer conn.Close()
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
backConn, err := b.dialer.SystemDial(ctx, "tcp", backDst)
|
|
||||||
cancel()
|
|
||||||
if err != nil {
|
|
||||||
b.logf("localbackend: failed to TCP proxy port %v (from %v) to %s: %v", dport, srcAddr, backDst, err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
defer backConn.Close()
|
|
||||||
if sni := tcph.TerminateTLS(); sni != "" {
|
|
||||||
conn = tls.Server(conn, &tls.Config{
|
|
||||||
GetCertificate: func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
pair, err := b.GetCertPEM(ctx, sni)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
cert, err := tls.X509KeyPair(pair.CertPEM, pair.KeyPEM)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &cert, nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return b.forwardTCPWithProxyProtocol(conn, backConn, tcph.ProxyProtocol(), srcAddr, dport, backDst)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// tcpHandlerForServe returns a handler for a TCP connection to be served via
|
// tcpHandlerForServe returns a handler for a TCP connection to be served via
|
||||||
@@ -641,21 +587,24 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return b.tcpHandlerForServeTCP(tcph, dport, srcAddr, &serveHTTPContext{
|
||||||
|
Funnel: f,
|
||||||
|
SrcAddr: srcAddr,
|
||||||
|
DestPort: dport,
|
||||||
|
}, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *LocalBackend) tcpHandlerForServeTCP(tcph ipn.TCPPortHandlerView, dport uint16, srcAddr netip.AddrPort, httpCtx *serveHTTPContext, forVIPService tailcfg.ServiceName) func(net.Conn) error {
|
||||||
if tcph.HTTPS() || tcph.HTTP() {
|
if tcph.HTTPS() || tcph.HTTP() {
|
||||||
hs := &http.Server{
|
hs := &http.Server{
|
||||||
Handler: http.HandlerFunc(b.serveWebHandler),
|
Handler: http.HandlerFunc(b.serveWebHandler),
|
||||||
BaseContext: func(_ net.Listener) context.Context {
|
BaseContext: func(_ net.Listener) context.Context {
|
||||||
return serveHTTPContextKey.WithValue(context.Background(), &serveHTTPContext{
|
c := *httpCtx
|
||||||
Funnel: f,
|
return serveHTTPContextKey.WithValue(context.Background(), &c)
|
||||||
SrcAddr: srcAddr,
|
|
||||||
DestPort: dport,
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if tcph.HTTPS() {
|
if tcph.HTTPS() {
|
||||||
hs.TLSConfig = &tls.Config{
|
hs.TLSConfig = b.serveTLSConfig(b.getTLSServeCertForPort(dport, forVIPService), serveTLSNextProtos())
|
||||||
GetCertificate: b.getTLSServeCertForPort(dport, ""),
|
|
||||||
}
|
|
||||||
return func(c net.Conn) error {
|
return func(c net.Conn) error {
|
||||||
return hs.ServeTLS(netutil.NewOneConnListener(c, nil), "", "")
|
return hs.ServeTLS(netutil.NewOneConnListener(c, nil), "", "")
|
||||||
}
|
}
|
||||||
@@ -678,21 +627,22 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort,
|
|||||||
}
|
}
|
||||||
defer backConn.Close()
|
defer backConn.Close()
|
||||||
if sni := tcph.TerminateTLS(); sni != "" {
|
if sni := tcph.TerminateTLS(); sni != "" {
|
||||||
conn = tls.Server(conn, &tls.Config{
|
conn = tls.Server(conn, b.serveTLSConfig(func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||||
GetCertificate: func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
if cert, ok := b.getACMETLSALPNCert(hi); ok {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
return cert, nil
|
||||||
defer cancel()
|
}
|
||||||
pair, err := b.GetCertPEM(ctx, sni)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||||
if err != nil {
|
defer cancel()
|
||||||
return nil, err
|
pair, err := b.GetCertPEM(ctx, sni)
|
||||||
}
|
if err != nil {
|
||||||
cert, err := tls.X509KeyPair(pair.CertPEM, pair.KeyPEM)
|
return nil, err
|
||||||
if err != nil {
|
}
|
||||||
return nil, err
|
cert, err := tls.X509KeyPair(pair.CertPEM, pair.KeyPEM)
|
||||||
}
|
if err != nil {
|
||||||
return &cert, nil
|
return nil, err
|
||||||
},
|
}
|
||||||
})
|
return &cert, nil
|
||||||
|
}, nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(bradfitz): do the RegisterIPPortIdentity and
|
// TODO(bradfitz): do the RegisterIPPortIdentity and
|
||||||
@@ -1311,6 +1261,9 @@ func (b *LocalBackend) getTLSServeCertForPort(port uint16, forVIPService tailcfg
|
|||||||
if hi == nil || hi.ServerName == "" {
|
if hi == nil || hi.ServerName == "" {
|
||||||
return nil, errors.New("no SNI ServerName")
|
return nil, errors.New("no SNI ServerName")
|
||||||
}
|
}
|
||||||
|
if cert, ok := b.getACMETLSALPNCert(hi); ok {
|
||||||
|
return cert, nil
|
||||||
|
}
|
||||||
_, ok := b.webServerConfig(hi.ServerName, forVIPService, port)
|
_, ok := b.webServerConfig(hi.ServerName, forVIPService, port)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, errors.New("no webserver configured for name/port")
|
return nil, errors.New("no webserver configured for name/port")
|
||||||
@@ -1330,6 +1283,46 @@ func (b *LocalBackend) getTLSServeCertForPort(port uint16, forVIPService tailcfg
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// serveTLSConfig returns the TLS configuration used by Serve and TCP-forwarded
|
||||||
|
// TLS listeners. nextProtos is the ALPN list to advertise for normal
|
||||||
|
// handshakes; it should be serveTLSNextProtos for HTTPS Serve and nil for
|
||||||
|
// TLS-terminated TCP forwarding where we don't know the backend protocol.
|
||||||
|
// During an ACME tls-alpn-01 renewal, GetConfigForClient clones the base config
|
||||||
|
// and temporarily prepends acme-tls/1, but only for the exact SNI with a pending
|
||||||
|
// challenge certificate. This keeps ordinary Serve traffic from advertising
|
||||||
|
// ACME support and lets Go's TLS stack negotiate the challenge protocol before
|
||||||
|
// GetCertificate is called.
|
||||||
|
func (b *LocalBackend) serveTLSConfig(getCert func(*tls.ClientHelloInfo) (*tls.Certificate, error), nextProtos []string) *tls.Config {
|
||||||
|
base := &tls.Config{
|
||||||
|
GetCertificate: getCert,
|
||||||
|
NextProtos: nextProtos,
|
||||||
|
}
|
||||||
|
base.GetConfigForClient = func(hi *tls.ClientHelloInfo) (*tls.Config, error) {
|
||||||
|
var nextProtos []string
|
||||||
|
if proto, ok := b.getACMETLSALPNProto(hi); ok {
|
||||||
|
b.logf("serve: accepting ACME tls-alpn-01 challenge for %q", hi.ServerName)
|
||||||
|
nextProtos = append(nextProtos, proto)
|
||||||
|
}
|
||||||
|
if len(nextProtos) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
cfg := base.Clone()
|
||||||
|
cfg.NextProtos = append(nextProtos, base.NextProtos...)
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *LocalBackend) hasFunnelForHostPort(host string, port uint16) bool {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
if !b.serveConfig.Valid() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hp := ipn.HostPort(net.JoinHostPort(host, strconv.Itoa(int(port))))
|
||||||
|
return b.serveConfig.HasFunnelForTarget(hp)
|
||||||
|
}
|
||||||
|
|
||||||
// setServeProxyHandlersLocked ensures there is an http proxy handler for each
|
// setServeProxyHandlersLocked ensures there is an http proxy handler for each
|
||||||
// backend specified in serveConfig. It expects serveConfig to be valid and
|
// backend specified in serveConfig. It expects serveConfig to be valid and
|
||||||
// up-to-date, so should be called after reloadServeConfigLocked.
|
// up-to-date, so should be called after reloadServeConfigLocked.
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ type funnelFlow = struct{}
|
|||||||
|
|
||||||
func (*LocalBackend) hasIngressEnabledLocked() bool { return false }
|
func (*LocalBackend) hasIngressEnabledLocked() bool { return false }
|
||||||
func (*LocalBackend) shouldWireInactiveIngressLocked() bool { return false }
|
func (*LocalBackend) shouldWireInactiveIngressLocked() bool { return false }
|
||||||
|
func (*LocalBackend) hasFunnelForHostPort(host string, port uint16) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (b *LocalBackend) vipServicesFromPrefsLocked(prefs ipn.PrefsView) []*tailcfg.VIPService {
|
func (b *LocalBackend) vipServicesFromPrefsLocked(prefs ipn.PrefsView) []*tailcfg.VIPService {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
Reference in New Issue
Block a user