From 412c812d7699009d55bb7d92d7550aacea8448c4 Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Thu, 28 May 2026 19:16:42 +0000 Subject: [PATCH] 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 --- ipn/ipnlocal/cert.go | 45 ++++++++- ipn/ipnlocal/cert_test.go | 208 +++++++++++++++++++++++++++++++++++--- 2 files changed, 236 insertions(+), 17 deletions(-) diff --git a/ipn/ipnlocal/cert.go b/ipn/ipnlocal/cert.go index 38afb8e0b..ef4cdf728 100644 --- a/ipn/ipnlocal/cert.go +++ b/ipn/ipnlocal/cert.go @@ -168,8 +168,13 @@ func (b *LocalBackend) GetCertPEM(ctx context.Context, domain string) (*TLSCertK // // - An exact CertDomain (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. +// 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) { b.mu.Lock() 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) 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 { logf("acme: using dns-01: no cached certificate for Funnel renewal") return false @@ -319,6 +331,22 @@ func (b *LocalBackend) shouldUseACMETLSALPN01(domain string, previous *TLSCertKe 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 { for _, ch := range challenges { if ch.Type == typ { @@ -695,6 +723,12 @@ func getCertPEMCached(cs certStore, domain string, now time.Time) (p *TLSCertKey if ctx.Err() != nil { 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) } 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" (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 // 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") } 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") } @@ -1111,6 +1147,13 @@ func (b *LocalBackend) resolveCertDomain(domain string) (string, error) { 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) } diff --git a/ipn/ipnlocal/cert_test.go b/ipn/ipnlocal/cert_test.go index 751f556e5..af39ea0bc 100644 --- a/ipn/ipnlocal/cert_test.go +++ b/ipn/ipnlocal/cert_test.go @@ -305,32 +305,208 @@ func TestServeTLSConfigNextProtos(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.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{ - AllowFunnel: map[ipn.HostPort]bool{ - domain + ":443": true, + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + byoDomain + ":443": {Handlers: map[string]*ipn.HTTPHandler{"/": {Proxy: "http://127.0.0.1:8080"}}}, }, }).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") + if _, err := b.resolveCertDomain(byoDomain); err == nil { + t.Fatalf("resolveCertDomain(%q) with Web but no Funnel: want error, got nil", byoDomain) } + // With AllowFunnel, BYO is accepted. 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() - if b.shouldUseACMETLSALPN01(domain, previous, t.Logf) { - t.Fatal("shouldUseACMETLSALPN01 without Funnel = true, want false") + got, err := b.resolveCertDomain(byoDomain) + 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) } }