ipn/ipnlocal: use ACME ALPN for authorized Funnel non-CertDomain domains
If a user explicitly adds a non-ts.net (not a CertDomain domain) domain
like "foo.com" to their serve config as a web target that's also an allowed
funnel domain (using raw "tailscale serve set-config"), then use the new
ALPN cert fetching (from b553969b) to get certs for that domain.
This is just plumbing; there's no new product functionality to
actually enable this easily client-side, and it also has no visible
product surface to enable it server-side.
Updates tailscale/corp#41736
Change-Id: Ie2e421ac9611bce64bba3de6a454b2d505ea0e8a
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
committed by
Brad Fitzpatrick
parent
788a49eca5
commit
412c812d76
+44
-1
@@ -168,8 +168,13 @@ func (b *LocalBackend) GetCertPEM(ctx context.Context, domain string) (*TLSCertK
|
|||||||
//
|
//
|
||||||
// - An exact CertDomain (e.g., "node.ts.net")
|
// - An exact CertDomain (e.g., "node.ts.net")
|
||||||
// - A wildcard domain (e.g., "*.node.ts.net")
|
// - A wildcard domain (e.g., "*.node.ts.net")
|
||||||
|
// - A bring-your-own Funnel domain referenced by the local serve config
|
||||||
|
// (e.g., "foo.com" when ServeConfig.AllowFunnel has "foo.com:443").
|
||||||
//
|
//
|
||||||
// The wildcard format requires the NodeAttrDNSSubdomainResolve capability.
|
// The wildcard format requires the NodeAttrDNSSubdomainResolve capability.
|
||||||
|
// ts.net domains are issued via dns-01 against control's DNS zone; BYO
|
||||||
|
// Funnel domains are issued via tls-alpn-01 over the same Funnel TLS path
|
||||||
|
// that serves real traffic.
|
||||||
func (b *LocalBackend) GetCertPEMWithValidity(ctx context.Context, domain string, minValidity time.Duration) (*TLSCertKeyPair, error) {
|
func (b *LocalBackend) GetCertPEMWithValidity(ctx context.Context, domain string, minValidity time.Duration) (*TLSCertKeyPair, error) {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
getCertForTest := b.getCertForTest
|
getCertForTest := b.getCertForTest
|
||||||
@@ -311,6 +316,13 @@ func (b *LocalBackend) shouldUseACMETLSALPN01(domain string, previous *TLSCertKe
|
|||||||
logf("acme: using dns-01: Funnel is not enabled for %s:443", domain)
|
logf("acme: using dns-01: Funnel is not enabled for %s:443", domain)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if b.isBYOFunnelDomain(domain) {
|
||||||
|
// BYO Funnel domain: dns-01 is not a viable path because control
|
||||||
|
// does not own the user's DNS zone. Use tls-alpn-01 even on
|
||||||
|
// first issuance.
|
||||||
|
logf("acme: using tls-alpn-01 (BYO Funnel domain)")
|
||||||
|
return true
|
||||||
|
}
|
||||||
if previous == nil {
|
if previous == nil {
|
||||||
logf("acme: using dns-01: no cached certificate for Funnel renewal")
|
logf("acme: using dns-01: no cached certificate for Funnel renewal")
|
||||||
return false
|
return false
|
||||||
@@ -319,6 +331,22 @@ func (b *LocalBackend) shouldUseACMETLSALPN01(domain string, previous *TLSCertKe
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isBYOFunnelDomain reports whether domain is a "bring your own" Funnel
|
||||||
|
// hostname: a domain that is not in the netmap's CertDomains but is
|
||||||
|
// referenced as a Funnel target on :443 by the local serve config.
|
||||||
|
// BYO domains can only be issued via tls-alpn-01 because control does
|
||||||
|
// not own their DNS zone.
|
||||||
|
func (b *LocalBackend) isBYOFunnelDomain(domain string) bool {
|
||||||
|
if domain == "" || isWildcardDomain(domain) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
nm := b.NetMapNoPeers()
|
||||||
|
if nm != nil && slices.Contains(nm.DNS.CertDomains, domain) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return b.hasFunnelForHostPort(domain, 443)
|
||||||
|
}
|
||||||
|
|
||||||
func challengeByType(challenges []*acme.Challenge, typ string) *acme.Challenge {
|
func challengeByType(challenges []*acme.Challenge, typ string) *acme.Challenge {
|
||||||
for _, ch := range challenges {
|
for _, ch := range challenges {
|
||||||
if ch.Type == typ {
|
if ch.Type == typ {
|
||||||
@@ -695,6 +723,12 @@ var getCertPEM = func(ctx context.Context, b *LocalBackend, cs certStore, logf l
|
|||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
}
|
}
|
||||||
|
if b.isBYOFunnelDomain(domain) {
|
||||||
|
// BYO domains have no working dns-01 path (control does not
|
||||||
|
// own the zone), so surface the tls-alpn-01 error instead of
|
||||||
|
// burning an ACME attempt on a guaranteed-to-fail fallback.
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
logf("acme: tls-alpn-01 failed; falling back to dns-01: %v", err)
|
logf("acme: tls-alpn-01 failed; falling back to dns-01: %v", err)
|
||||||
}
|
}
|
||||||
issueArgs.challengeType = acmeChallengeDNS01
|
issueArgs.challengeType = acmeChallengeDNS01
|
||||||
@@ -1077,6 +1111,8 @@ func validLookingCertDomain(name string) bool {
|
|||||||
//
|
//
|
||||||
// - "node.ts.net" -> "node.ts.net" (exact CertDomain match)
|
// - "node.ts.net" -> "node.ts.net" (exact CertDomain match)
|
||||||
// - "*.node.ts.net" -> "*.node.ts.net" (explicit wildcard, requires NodeAttrDNSSubdomainResolve)
|
// - "*.node.ts.net" -> "*.node.ts.net" (explicit wildcard, requires NodeAttrDNSSubdomainResolve)
|
||||||
|
// - "foo.com" -> "foo.com" (bring-your-own Funnel domain referenced by the
|
||||||
|
// local serve config; issued via tls-alpn-01 in getCertPEM)
|
||||||
//
|
//
|
||||||
// Subdomain requests like "app.node.ts.net" are rejected; callers should
|
// Subdomain requests like "app.node.ts.net" are rejected; callers should
|
||||||
// request "*.node.ts.net" explicitly for subdomain coverage.
|
// request "*.node.ts.net" explicitly for subdomain coverage.
|
||||||
@@ -1091,7 +1127,7 @@ func (b *LocalBackend) resolveCertDomain(domain string) (string, error) {
|
|||||||
return "", errors.New("no netmap available")
|
return "", errors.New("no netmap available")
|
||||||
}
|
}
|
||||||
certDomains := nm.DNS.CertDomains
|
certDomains := nm.DNS.CertDomains
|
||||||
if len(certDomains) == 0 {
|
if len(certDomains) == 0 && !b.isBYOFunnelDomain(domain) {
|
||||||
return "", errors.New("your Tailscale account does not support getting TLS certs")
|
return "", errors.New("your Tailscale account does not support getting TLS certs")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1111,6 +1147,13 @@ func (b *LocalBackend) resolveCertDomain(domain string) (string, error) {
|
|||||||
return domain, nil
|
return domain, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bring-your-own Funnel domain (e.g. "foo.com"). The serve config
|
||||||
|
// references the domain as a Funnel target on :443; cert acquisition
|
||||||
|
// happens via tls-alpn-01 in getCertPEM.
|
||||||
|
if b.isBYOFunnelDomain(domain) {
|
||||||
|
return domain, nil
|
||||||
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("invalid domain %q; must be one of %q", domain, certDomains)
|
return "", fmt.Errorf("invalid domain %q; must be one of %q", domain, certDomains)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+192
-16
@@ -305,32 +305,208 @@ func TestServeTLSConfigNextProtos(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestShouldUseACMETLSALPN01(t *testing.T) {
|
func TestShouldUseACMETLSALPN01(t *testing.T) {
|
||||||
const domain = "example.com"
|
const (
|
||||||
|
tsNetDomain = "node.ts.net"
|
||||||
|
byoDomain = "foo.com"
|
||||||
|
)
|
||||||
|
previous := &TLSCertKeyPair{}
|
||||||
|
|
||||||
|
setFunnel := func(b *LocalBackend, hosts ...string) {
|
||||||
|
funnel := map[ipn.HostPort]bool{}
|
||||||
|
for _, h := range hosts {
|
||||||
|
funnel[ipn.HostPort(h+":443")] = true
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
b.serveConfig = (&ipn.ServeConfig{AllowFunnel: funnel}).View()
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
setNetmap := func(b *LocalBackend, certDomains ...string) {
|
||||||
|
b.mu.Lock()
|
||||||
|
b.currentNode().SetNetMap(&netmap.NetworkMap{
|
||||||
|
SelfNode: (&tailcfg.Node{}).View(),
|
||||||
|
DNS: tailcfg.DNSConfig{CertDomains: certDomains},
|
||||||
|
})
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
domain string
|
||||||
|
previous *TLSCertKeyPair
|
||||||
|
funnel []string
|
||||||
|
netmap []string // CertDomains; if nil, no netmap installed
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "tsnet_renewal",
|
||||||
|
domain: tsNetDomain,
|
||||||
|
previous: previous,
|
||||||
|
funnel: []string{tsNetDomain},
|
||||||
|
netmap: []string{tsNetDomain},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tsnet_first_issuance_prefers_dns01",
|
||||||
|
domain: tsNetDomain,
|
||||||
|
previous: nil,
|
||||||
|
funnel: []string{tsNetDomain},
|
||||||
|
netmap: []string{tsNetDomain},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tsnet_wildcard_rejected",
|
||||||
|
domain: "*." + tsNetDomain,
|
||||||
|
previous: previous,
|
||||||
|
funnel: []string{tsNetDomain},
|
||||||
|
netmap: []string{tsNetDomain},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tsnet_without_funnel_rejected",
|
||||||
|
domain: tsNetDomain,
|
||||||
|
previous: previous,
|
||||||
|
funnel: nil,
|
||||||
|
netmap: []string{tsNetDomain},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "byo_first_issuance_uses_alpn",
|
||||||
|
domain: byoDomain,
|
||||||
|
previous: nil,
|
||||||
|
funnel: []string{byoDomain},
|
||||||
|
netmap: []string{tsNetDomain},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "byo_renewal_uses_alpn",
|
||||||
|
domain: byoDomain,
|
||||||
|
previous: previous,
|
||||||
|
funnel: []string{byoDomain},
|
||||||
|
netmap: []string{tsNetDomain},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "byo_without_funnel_rejected",
|
||||||
|
domain: byoDomain,
|
||||||
|
previous: previous,
|
||||||
|
funnel: nil,
|
||||||
|
netmap: []string{tsNetDomain},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
b := newTestLocalBackend(t)
|
||||||
|
if tt.netmap != nil {
|
||||||
|
setNetmap(b, tt.netmap...)
|
||||||
|
}
|
||||||
|
setFunnel(b, tt.funnel...)
|
||||||
|
if got := b.shouldUseACMETLSALPN01(tt.domain, tt.previous, t.Logf); got != tt.want {
|
||||||
|
t.Errorf("shouldUseACMETLSALPN01(%q, previous=%v) = %v, want %v",
|
||||||
|
tt.domain, tt.previous != nil, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsBYOFunnelDomain(t *testing.T) {
|
||||||
|
setFunnel := func(b *LocalBackend, hosts ...string) {
|
||||||
|
funnel := map[ipn.HostPort]bool{}
|
||||||
|
for _, h := range hosts {
|
||||||
|
funnel[ipn.HostPort(h+":443")] = true
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
b.serveConfig = (&ipn.ServeConfig{AllowFunnel: funnel}).View()
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
setNetmap := func(b *LocalBackend, certDomains ...string) {
|
||||||
|
b.mu.Lock()
|
||||||
|
b.currentNode().SetNetMap(&netmap.NetworkMap{
|
||||||
|
SelfNode: (&tailcfg.Node{}).View(),
|
||||||
|
DNS: tailcfg.DNSConfig{CertDomains: certDomains},
|
||||||
|
})
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
domain string
|
||||||
|
certDomains []string
|
||||||
|
funnel []string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "byo_with_funnel", domain: "foo.com", certDomains: []string{"node.ts.net"}, funnel: []string{"foo.com"}, want: true},
|
||||||
|
{name: "byo_without_funnel", domain: "foo.com", certDomains: []string{"node.ts.net"}, want: false},
|
||||||
|
{name: "tsnet_exact_match_not_byo", domain: "node.ts.net", certDomains: []string{"node.ts.net"}, funnel: []string{"node.ts.net"}, want: false},
|
||||||
|
{name: "wildcard_never_byo", domain: "*.foo.com", certDomains: []string{"node.ts.net"}, funnel: []string{"foo.com"}, want: false},
|
||||||
|
{name: "empty_never_byo", domain: "", certDomains: []string{"node.ts.net"}, funnel: []string{"foo.com"}, want: false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
b := newTestLocalBackend(t)
|
||||||
|
setNetmap(b, tt.certDomains...)
|
||||||
|
setFunnel(b, tt.funnel...)
|
||||||
|
if got := b.isBYOFunnelDomain(tt.domain); got != tt.want {
|
||||||
|
t.Errorf("isBYOFunnelDomain(%q) = %v, want %v", tt.domain, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCertDomainBYO(t *testing.T) {
|
||||||
|
const (
|
||||||
|
tsNetDomain = "node.ts.net"
|
||||||
|
byoDomain = "foo.com"
|
||||||
|
)
|
||||||
b := newTestLocalBackend(t)
|
b := newTestLocalBackend(t)
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
|
b.currentNode().SetNetMap(&netmap.NetworkMap{
|
||||||
|
SelfNode: (&tailcfg.Node{}).View(),
|
||||||
|
DNS: tailcfg.DNSConfig{CertDomains: []string{tsNetDomain}},
|
||||||
|
})
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
// Without a serve config, BYO is rejected.
|
||||||
|
if _, err := b.resolveCertDomain(byoDomain); err == nil {
|
||||||
|
t.Fatalf("resolveCertDomain(%q) without serve config: want error, got nil", byoDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web entry alone (no AllowFunnel) is not enough; the gate is Funnel.
|
||||||
|
b.mu.Lock()
|
||||||
b.serveConfig = (&ipn.ServeConfig{
|
b.serveConfig = (&ipn.ServeConfig{
|
||||||
AllowFunnel: map[ipn.HostPort]bool{
|
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||||
domain + ":443": true,
|
byoDomain + ":443": {Handlers: map[string]*ipn.HTTPHandler{"/": {Proxy: "http://127.0.0.1:8080"}}},
|
||||||
},
|
},
|
||||||
}).View()
|
}).View()
|
||||||
b.mu.Unlock()
|
b.mu.Unlock()
|
||||||
|
if _, err := b.resolveCertDomain(byoDomain); err == nil {
|
||||||
previous := &TLSCertKeyPair{}
|
t.Fatalf("resolveCertDomain(%q) with Web but no Funnel: want error, got nil", byoDomain)
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// With AllowFunnel, BYO is accepted.
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
b.serveConfig = (&ipn.ServeConfig{}).View()
|
b.serveConfig = (&ipn.ServeConfig{
|
||||||
|
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||||
|
byoDomain + ":443": {Handlers: map[string]*ipn.HTTPHandler{"/": {Proxy: "http://127.0.0.1:8080"}}},
|
||||||
|
},
|
||||||
|
AllowFunnel: map[ipn.HostPort]bool{byoDomain + ":443": true},
|
||||||
|
}).View()
|
||||||
b.mu.Unlock()
|
b.mu.Unlock()
|
||||||
if b.shouldUseACMETLSALPN01(domain, previous, t.Logf) {
|
got, err := b.resolveCertDomain(byoDomain)
|
||||||
t.Fatal("shouldUseACMETLSALPN01 without Funnel = true, want false")
|
if err != nil {
|
||||||
|
t.Fatalf("resolveCertDomain(%q): %v", byoDomain, err)
|
||||||
|
}
|
||||||
|
if got != byoDomain {
|
||||||
|
t.Errorf("resolveCertDomain(%q) = %q, want %q", byoDomain, got, byoDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ts.net path still works alongside BYO entries.
|
||||||
|
got, err = b.resolveCertDomain(tsNetDomain)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveCertDomain(%q): %v", tsNetDomain, err)
|
||||||
|
}
|
||||||
|
if got != tsNetDomain {
|
||||||
|
t.Errorf("resolveCertDomain(%q) = %q, want %q", tsNetDomain, got, tsNetDomain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user