Просмотр исходного кода

feature/featuretags, all: add ts_omit_acme to disable TLS cert support

I'd started to do this in the earlier ts_omit_server PR but
decided to split it into this separate PR.

Updates #17128

Change-Id: Ief8823a78d1f7bbb79e64a5cab30a7d0a5d6ff4b
Signed-off-by: Brad Fitzpatrick <[email protected]>
Brad Fitzpatrick 5 месяцев назад
Родитель
Сommit
e180fc267b

+ 1 - 1
build_dist.sh

@@ -41,7 +41,7 @@ while [ "$#" -gt 1 ]; do
 		fi
 		shift
 		ldflags="$ldflags -w -s"
-		tags="${tags:+$tags,},$($go run ./cmd/featuretags --min)"
+		tags="${tags:+$tags,},$(GOOS= GOARCH= $go run ./cmd/featuretags --min)"
 		;;
 	--box)
 		if [ ! -z "${TAGS:-}" ]; then

+ 151 - 0
client/local/cert.go

@@ -0,0 +1,151 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build !js && !ts_omit_acme
+
+package local
+
+import (
+	"context"
+	"crypto/tls"
+	"errors"
+	"fmt"
+	"net/url"
+	"strings"
+	"time"
+
+	"go4.org/mem"
+)
+
+// SetDNS adds a DNS TXT record for the given domain name, containing
+// the provided TXT value. The intended use case is answering
+// LetsEncrypt/ACME dns-01 challenges.
+//
+// The control plane will only permit SetDNS requests with very
+// specific names and values. The name should be
+// "_acme-challenge." + your node's MagicDNS name. It's expected that
+// clients cache the certs from LetsEncrypt (or whichever CA is
+// providing them) and only request new ones as needed; the control plane
+// rate limits SetDNS requests.
+//
+// This is a low-level interface; it's expected that most Tailscale
+// users use a higher level interface to getting/using TLS
+// certificates.
+func (lc *Client) SetDNS(ctx context.Context, name, value string) error {
+	v := url.Values{}
+	v.Set("name", name)
+	v.Set("value", value)
+	_, err := lc.send(ctx, "POST", "/localapi/v0/set-dns?"+v.Encode(), 200, nil)
+	return err
+}
+
+// CertPair returns a cert and private key for the provided DNS domain.
+//
+// It returns a cached certificate from disk if it's still valid.
+//
+// Deprecated: use [Client.CertPair].
+func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
+	return defaultClient.CertPair(ctx, domain)
+}
+
+// CertPair returns a cert and private key for the provided DNS domain.
+//
+// It returns a cached certificate from disk if it's still valid.
+//
+// API maturity: this is considered a stable API.
+func (lc *Client) CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
+	return lc.CertPairWithValidity(ctx, domain, 0)
+}
+
+// CertPairWithValidity returns a cert and private key for the provided DNS
+// domain.
+//
+// It returns a cached certificate from disk if it's still valid.
+// When minValidity is non-zero, the returned certificate will be valid for at
+// least the given duration, if permitted by the CA. If the certificate is
+// valid, but for less than minValidity, it will be synchronously renewed.
+//
+// API maturity: this is considered a stable API.
+func (lc *Client) CertPairWithValidity(ctx context.Context, domain string, minValidity time.Duration) (certPEM, keyPEM []byte, err error) {
+	res, err := lc.send(ctx, "GET", fmt.Sprintf("/localapi/v0/cert/%s?type=pair&min_validity=%s", domain, minValidity), 200, nil)
+	if err != nil {
+		return nil, nil, err
+	}
+	// with ?type=pair, the response PEM is first the one private
+	// key PEM block, then the cert PEM blocks.
+	i := mem.Index(mem.B(res), mem.S("--\n--"))
+	if i == -1 {
+		return nil, nil, fmt.Errorf("unexpected output: no delimiter")
+	}
+	i += len("--\n")
+	keyPEM, certPEM = res[:i], res[i:]
+	if mem.Contains(mem.B(certPEM), mem.S(" PRIVATE KEY-----")) {
+		return nil, nil, fmt.Errorf("unexpected output: key in cert")
+	}
+	return certPEM, keyPEM, nil
+}
+
+// GetCertificate fetches a TLS certificate for the TLS ClientHello in hi.
+//
+// It returns a cached certificate from disk if it's still valid.
+//
+// It's the right signature to use as the value of
+// [tls.Config.GetCertificate].
+//
+// Deprecated: use [Client.GetCertificate].
+func GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
+	return defaultClient.GetCertificate(hi)
+}
+
+// GetCertificate fetches a TLS certificate for the TLS ClientHello in hi.
+//
+// It returns a cached certificate from disk if it's still valid.
+//
+// It's the right signature to use as the value of
+// [tls.Config.GetCertificate].
+//
+// API maturity: this is considered a stable API.
+func (lc *Client) GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
+	if hi == nil || hi.ServerName == "" {
+		return nil, errors.New("no SNI ServerName")
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
+	defer cancel()
+
+	name := hi.ServerName
+	if !strings.Contains(name, ".") {
+		if v, ok := lc.ExpandSNIName(ctx, name); ok {
+			name = v
+		}
+	}
+	certPEM, keyPEM, err := lc.CertPair(ctx, name)
+	if err != nil {
+		return nil, err
+	}
+	cert, err := tls.X509KeyPair(certPEM, keyPEM)
+	if err != nil {
+		return nil, err
+	}
+	return &cert, nil
+}
+
+// ExpandSNIName expands bare label name into the most likely actual TLS cert name.
+//
+// Deprecated: use [Client.ExpandSNIName].
+func ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
+	return defaultClient.ExpandSNIName(ctx, name)
+}
+
+// ExpandSNIName expands bare label name into the most likely actual TLS cert name.
+func (lc *Client) ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
+	st, err := lc.StatusWithoutPeers(ctx)
+	if err != nil {
+		return "", false
+	}
+	for _, d := range st.CertDomains {
+		if len(d) > len(name)+1 && strings.HasPrefix(d, name) && d[len(name)] == '.' {
+			return d, true
+		}
+	}
+	return "", false
+}

+ 0 - 135
client/local/local.go

@@ -9,7 +9,6 @@ import (
 	"bytes"
 	"cmp"
 	"context"
-	"crypto/tls"
 	"encoding/base64"
 	"encoding/json"
 	"errors"
@@ -28,7 +27,6 @@ import (
 	"sync"
 	"time"
 
-	"go4.org/mem"
 	"tailscale.com/client/tailscale/apitype"
 	"tailscale.com/drive"
 	"tailscale.com/envknob"
@@ -907,28 +905,6 @@ func (lc *Client) Logout(ctx context.Context) error {
 	return err
 }
 
-// SetDNS adds a DNS TXT record for the given domain name, containing
-// the provided TXT value. The intended use case is answering
-// LetsEncrypt/ACME dns-01 challenges.
-//
-// The control plane will only permit SetDNS requests with very
-// specific names and values. The name should be
-// "_acme-challenge." + your node's MagicDNS name. It's expected that
-// clients cache the certs from LetsEncrypt (or whichever CA is
-// providing them) and only request new ones as needed; the control plane
-// rate limits SetDNS requests.
-//
-// This is a low-level interface; it's expected that most Tailscale
-// users use a higher level interface to getting/using TLS
-// certificates.
-func (lc *Client) SetDNS(ctx context.Context, name, value string) error {
-	v := url.Values{}
-	v.Set("name", name)
-	v.Set("value", value)
-	_, err := lc.send(ctx, "POST", "/localapi/v0/set-dns?"+v.Encode(), 200, nil)
-	return err
-}
-
 // DialTCP connects to the host's port via Tailscale.
 //
 // The host may be a base DNS name (resolved from the netmap inside
@@ -1009,117 +985,6 @@ func (lc *Client) CurrentDERPMap(ctx context.Context) (*tailcfg.DERPMap, error)
 	return &derpMap, nil
 }
 
-// CertPair returns a cert and private key for the provided DNS domain.
-//
-// It returns a cached certificate from disk if it's still valid.
-//
-// Deprecated: use [Client.CertPair].
-func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
-	return defaultClient.CertPair(ctx, domain)
-}
-
-// CertPair returns a cert and private key for the provided DNS domain.
-//
-// It returns a cached certificate from disk if it's still valid.
-//
-// API maturity: this is considered a stable API.
-func (lc *Client) CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
-	return lc.CertPairWithValidity(ctx, domain, 0)
-}
-
-// CertPairWithValidity returns a cert and private key for the provided DNS
-// domain.
-//
-// It returns a cached certificate from disk if it's still valid.
-// When minValidity is non-zero, the returned certificate will be valid for at
-// least the given duration, if permitted by the CA. If the certificate is
-// valid, but for less than minValidity, it will be synchronously renewed.
-//
-// API maturity: this is considered a stable API.
-func (lc *Client) CertPairWithValidity(ctx context.Context, domain string, minValidity time.Duration) (certPEM, keyPEM []byte, err error) {
-	res, err := lc.send(ctx, "GET", fmt.Sprintf("/localapi/v0/cert/%s?type=pair&min_validity=%s", domain, minValidity), 200, nil)
-	if err != nil {
-		return nil, nil, err
-	}
-	// with ?type=pair, the response PEM is first the one private
-	// key PEM block, then the cert PEM blocks.
-	i := mem.Index(mem.B(res), mem.S("--\n--"))
-	if i == -1 {
-		return nil, nil, fmt.Errorf("unexpected output: no delimiter")
-	}
-	i += len("--\n")
-	keyPEM, certPEM = res[:i], res[i:]
-	if mem.Contains(mem.B(certPEM), mem.S(" PRIVATE KEY-----")) {
-		return nil, nil, fmt.Errorf("unexpected output: key in cert")
-	}
-	return certPEM, keyPEM, nil
-}
-
-// GetCertificate fetches a TLS certificate for the TLS ClientHello in hi.
-//
-// It returns a cached certificate from disk if it's still valid.
-//
-// It's the right signature to use as the value of
-// [tls.Config.GetCertificate].
-//
-// Deprecated: use [Client.GetCertificate].
-func GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
-	return defaultClient.GetCertificate(hi)
-}
-
-// GetCertificate fetches a TLS certificate for the TLS ClientHello in hi.
-//
-// It returns a cached certificate from disk if it's still valid.
-//
-// It's the right signature to use as the value of
-// [tls.Config.GetCertificate].
-//
-// API maturity: this is considered a stable API.
-func (lc *Client) GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
-	if hi == nil || hi.ServerName == "" {
-		return nil, errors.New("no SNI ServerName")
-	}
-	ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
-	defer cancel()
-
-	name := hi.ServerName
-	if !strings.Contains(name, ".") {
-		if v, ok := lc.ExpandSNIName(ctx, name); ok {
-			name = v
-		}
-	}
-	certPEM, keyPEM, err := lc.CertPair(ctx, name)
-	if err != nil {
-		return nil, err
-	}
-	cert, err := tls.X509KeyPair(certPEM, keyPEM)
-	if err != nil {
-		return nil, err
-	}
-	return &cert, nil
-}
-
-// ExpandSNIName expands bare label name into the most likely actual TLS cert name.
-//
-// Deprecated: use [Client.ExpandSNIName].
-func ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
-	return defaultClient.ExpandSNIName(ctx, name)
-}
-
-// ExpandSNIName expands bare label name into the most likely actual TLS cert name.
-func (lc *Client) ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
-	st, err := lc.StatusWithoutPeers(ctx)
-	if err != nil {
-		return "", false
-	}
-	for _, d := range st.CertDomains {
-		if len(d) > len(name)+1 && strings.HasPrefix(d, name) && d[len(name)] == '.' {
-			return d, true
-		}
-	}
-	return "", false
-}
-
 // PingOpts contains options for the ping request.
 //
 // The zero value is valid, which means to use defaults.

+ 34 - 0
client/tailscale/cert.go

@@ -0,0 +1,34 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build !js && !ts_omit_acme
+
+package tailscale
+
+import (
+	"context"
+	"crypto/tls"
+
+	"tailscale.com/client/local"
+)
+
+// GetCertificate is an alias for [tailscale.com/client/local.GetCertificate].
+//
+// Deprecated: import [tailscale.com/client/local] instead and use [local.Client.GetCertificate].
+func GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
+	return local.GetCertificate(hi)
+}
+
+// CertPair is an alias for [tailscale.com/client/local.CertPair].
+//
+// Deprecated: import [tailscale.com/client/local] instead and use [local.Client.CertPair].
+func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
+	return local.CertPair(ctx, domain)
+}
+
+// ExpandSNIName is an alias for [tailscale.com/client/local.ExpandSNIName].
+//
+// Deprecated: import [tailscale.com/client/local] instead and use [local.Client.ExpandSNIName].
+func ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
+	return local.ExpandSNIName(ctx, name)
+}

+ 0 - 22
client/tailscale/localclient_aliases.go

@@ -5,7 +5,6 @@ package tailscale
 
 import (
 	"context"
-	"crypto/tls"
 
 	"tailscale.com/client/local"
 	"tailscale.com/client/tailscale/apitype"
@@ -37,13 +36,6 @@ type BugReportOpts = local.BugReportOpts
 // Deprecated: import [tailscale.com/client/local] instead.
 type PingOpts = local.PingOpts
 
-// GetCertificate is an alias for [tailscale.com/client/local.GetCertificate].
-//
-// Deprecated: import [tailscale.com/client/local] instead and use [local.Client.GetCertificate].
-func GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
-	return local.GetCertificate(hi)
-}
-
 // SetVersionMismatchHandler is an alias for [tailscale.com/client/local.SetVersionMismatchHandler].
 //
 // Deprecated: import [tailscale.com/client/local] instead.
@@ -85,17 +77,3 @@ func Status(ctx context.Context) (*ipnstate.Status, error) {
 func StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
 	return local.StatusWithoutPeers(ctx)
 }
-
-// CertPair is an alias for [tailscale.com/client/local.CertPair].
-//
-// Deprecated: import [tailscale.com/client/local] instead and use [local.Client.CertPair].
-func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
-	return local.CertPair(ctx, domain)
-}
-
-// ExpandSNIName is an alias for [tailscale.com/client/local.ExpandSNIName].
-//
-// Deprecated: import [tailscale.com/client/local] instead and use [local.Client.ExpandSNIName].
-func ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
-	return local.ExpandSNIName(ctx, name)
-}

+ 19 - 13
cmd/tailscale/cli/cert.go

@@ -1,6 +1,8 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 
+//go:build !js && !ts_omit_acme
+
 package cli
 
 import (
@@ -25,19 +27,23 @@ import (
 	"tailscale.com/version"
 )
 
-var certCmd = &ffcli.Command{
-	Name:       "cert",
-	Exec:       runCert,
-	ShortHelp:  "Get TLS certs",
-	ShortUsage: "tailscale cert [flags] <domain>",
-	FlagSet: (func() *flag.FlagSet {
-		fs := newFlagSet("cert")
-		fs.StringVar(&certArgs.certFile, "cert-file", "", "output cert file or \"-\" for stdout; defaults to DOMAIN.crt if --cert-file and --key-file are both unset")
-		fs.StringVar(&certArgs.keyFile, "key-file", "", "output key file or \"-\" for stdout; defaults to DOMAIN.key if --cert-file and --key-file are both unset")
-		fs.BoolVar(&certArgs.serve, "serve-demo", false, "if true, serve on port :443 using the cert as a demo, instead of writing out the files to disk")
-		fs.DurationVar(&certArgs.minValidity, "min-validity", 0, "ensure the certificate is valid for at least this duration; the output certificate is never expired if this flag is unset or 0, but the lifetime may vary; the maximum allowed min-validity depends on the CA")
-		return fs
-	})(),
+func init() {
+	maybeCertCmd = func() *ffcli.Command {
+		return &ffcli.Command{
+			Name:       "cert",
+			Exec:       runCert,
+			ShortHelp:  "Get TLS certs",
+			ShortUsage: "tailscale cert [flags] <domain>",
+			FlagSet: (func() *flag.FlagSet {
+				fs := newFlagSet("cert")
+				fs.StringVar(&certArgs.certFile, "cert-file", "", "output cert file or \"-\" for stdout; defaults to DOMAIN.crt if --cert-file and --key-file are both unset")
+				fs.StringVar(&certArgs.keyFile, "key-file", "", "output key file or \"-\" for stdout; defaults to DOMAIN.key if --cert-file and --key-file are both unset")
+				fs.BoolVar(&certArgs.serve, "serve-demo", false, "if true, serve on port :443 using the cert as a demo, instead of writing out the files to disk")
+				fs.DurationVar(&certArgs.minValidity, "min-validity", 0, "ensure the certificate is valid for at least this duration; the output certificate is never expired if this flag is unset or 0, but the lifetime may vary; the maximum allowed min-validity depends on the CA")
+				return fs
+			})(),
+		}
+	}
 }
 
 var certArgs struct {

+ 2 - 1
cmd/tailscale/cli/cli.go

@@ -215,6 +215,7 @@ var (
 	maybeNetlockCmd,
 	maybeFunnelCmd,
 	maybeServeCmd,
+	maybeCertCmd,
 	_ func() *ffcli.Command
 )
 
@@ -262,7 +263,7 @@ change in the future.
 			nilOrCall(maybeWebCmd),
 			nilOrCall(fileCmd),
 			bugReportCmd,
-			certCmd,
+			nilOrCall(maybeCertCmd),
 			nilOrCall(maybeNetlockCmd),
 			licensesCmd,
 			exitNodeCmd(),

+ 6 - 0
cmd/tailscale/cli/configure-synology-cert.go

@@ -1,6 +1,8 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 
+//go:build linux && !ts_omit_acme
+
 package cli
 
 import (
@@ -22,6 +24,10 @@ import (
 	"tailscale.com/version/distro"
 )
 
+func init() {
+	maybeConfigSynologyCertCmd = synologyConfigureCertCmd
+}
+
 func synologyConfigureCertCmd() *ffcli.Command {
 	if runtime.GOOS != "linux" || distro.Get() != distro.Synology {
 		return nil

+ 2 - 0
cmd/tailscale/cli/configure-synology-cert_test.go

@@ -1,6 +1,8 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 
+//go:build linux && !ts_omit_acme
+
 package cli
 
 import (

+ 6 - 2
cmd/tailscale/cli/configure.go

@@ -10,7 +10,11 @@ import (
 	"github.com/peterbourgon/ff/v3/ffcli"
 )
 
-var maybeJetKVMConfigureCmd func() *ffcli.Command // non-nil only on Linux/arm for JetKVM
+var (
+	maybeJetKVMConfigureCmd,
+	maybeConfigSynologyCertCmd,
+	_ func() *ffcli.Command // non-nil only on Linux/arm for JetKVM
+)
 
 func configureCmd() *ffcli.Command {
 	return &ffcli.Command{
@@ -28,7 +32,7 @@ services on the host to use Tailscale in more ways.
 		Subcommands: nonNilCmds(
 			configureKubeconfigCmd(),
 			synologyConfigureCmd(),
-			synologyConfigureCertCmd(),
+			ccall(maybeConfigSynologyCertCmd),
 			ccall(maybeSysExtCmd),
 			ccall(maybeVPNConfigCmd),
 			ccall(maybeJetKVMConfigureCmd),

+ 13 - 0
cmd/tailscaled/deps_test.go

@@ -108,3 +108,16 @@ func TestOmitPortmapper(t *testing.T) {
 		},
 	}.Check(t)
 }
+
+func TestOmitACME(t *testing.T) {
+	deptest.DepChecker{
+		GOOS:   "linux",
+		GOARCH: "amd64",
+		Tags:   "ts_omit_acme,ts_include_cli",
+		OnDep: func(dep string) {
+			if strings.Contains(dep, "/acme") {
+				t.Errorf("unexpected dep with ts_omit_acme: %q", dep)
+			}
+		},
+	}.Check(t)
+}

+ 13 - 0
feature/buildfeatures/feature_acme_disabled.go

@@ -0,0 +1,13 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+// Code generated by gen.go; DO NOT EDIT.
+
+//go:build ts_omit_acme
+
+package buildfeatures
+
+// HasACME is whether the binary was built with support for modular feature "ACME TLS certificate management".
+// Specifically, it's whether the binary was NOT built with the "ts_omit_acme" build tag.
+// It's a const so it can be used for dead code elimination.
+const HasACME = false

+ 13 - 0
feature/buildfeatures/feature_acme_enabled.go

@@ -0,0 +1,13 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+// Code generated by gen.go; DO NOT EDIT.
+
+//go:build !ts_omit_acme
+
+package buildfeatures
+
+// HasACME is whether the binary was built with support for modular feature "ACME TLS certificate management".
+// Specifically, it's whether the binary was NOT built with the "ts_omit_acme" build tag.
+// It's a const so it can be used for dead code elimination.
+const HasACME = true

+ 1 - 0
feature/featuretags/featuretags.go

@@ -42,6 +42,7 @@ type FeatureMeta struct {
 // Features are the known Tailscale features that can be selectively included or
 // excluded via build tags, and a description of each.
 var Features = map[FeatureTag]FeatureMeta{
+	"acme":             {"ACME", "ACME TLS certificate management"},
 	"aws":              {"AWS", "AWS integration"},
 	"bird":             {"Bird", "Bird BGP integration"},
 	"capture":          {"Capture", "Packet capture"},

+ 0 - 56
ipn/ipnlocal/c2n.go

@@ -4,9 +4,7 @@
 package ipnlocal
 
 import (
-	"crypto/x509"
 	"encoding/json"
-	"encoding/pem"
 	"errors"
 	"fmt"
 	"io"
@@ -54,9 +52,6 @@ var c2nHandlers = map[methodAndPath]c2nHandler{
 	req("POST /logtail/flush"): handleC2NLogtailFlush,
 	req("POST /sockstats"):     handleC2NSockStats,
 
-	// Check TLS certificate status.
-	req("GET /tls-cert-status"): handleC2NTLSCertStatus,
-
 	// SSH
 	req("/ssh/usernames"): handleC2NSSHUsernames,
 
@@ -497,54 +492,3 @@ func regularFileExists(path string) bool {
 	fi, err := os.Stat(path)
 	return err == nil && fi.Mode().IsRegular()
 }
-
-// handleC2NTLSCertStatus returns info about the last TLS certificate issued for the
-// provided domain. This can be called by the controlplane to clean up DNS TXT
-// records when they're no longer needed by LetsEncrypt.
-//
-// It does not kick off a cert fetch or async refresh. It only reports anything
-// that's already sitting on disk, and only reports metadata about the public
-// cert (stuff that'd be the in CT logs anyway).
-func handleC2NTLSCertStatus(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
-	cs, err := b.getCertStore()
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	domain := r.FormValue("domain")
-	if domain == "" {
-		http.Error(w, "no 'domain'", http.StatusBadRequest)
-		return
-	}
-
-	ret := &tailcfg.C2NTLSCertInfo{}
-	pair, err := getCertPEMCached(cs, domain, b.clock.Now())
-	ret.Valid = err == nil
-	if err != nil {
-		ret.Error = err.Error()
-		if errors.Is(err, errCertExpired) {
-			ret.Expired = true
-		} else if errors.Is(err, ipn.ErrStateNotExist) {
-			ret.Missing = true
-			ret.Error = "no certificate"
-		}
-	} else {
-		block, _ := pem.Decode(pair.CertPEM)
-		if block == nil {
-			ret.Error = "invalid PEM"
-			ret.Valid = false
-		} else {
-			cert, err := x509.ParseCertificate(block.Bytes)
-			if err != nil {
-				ret.Error = fmt.Sprintf("invalid certificate: %v", err)
-				ret.Valid = false
-			} else {
-				ret.NotBefore = cert.NotBefore.UTC().Format(time.RFC3339)
-				ret.NotAfter = cert.NotAfter.UTC().Format(time.RFC3339)
-			}
-		}
-	}
-
-	writeJSON(w, ret)
-}

+ 58 - 1
ipn/ipnlocal/cert.go

@@ -1,7 +1,7 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 
-//go:build !js
+//go:build !js && !ts_omit_acme
 
 package ipnlocal
 
@@ -24,6 +24,7 @@ import (
 	"log"
 	randv2 "math/rand/v2"
 	"net"
+	"net/http"
 	"os"
 	"path/filepath"
 	"runtime"
@@ -40,6 +41,7 @@ import (
 	"tailscale.com/ipn/store"
 	"tailscale.com/ipn/store/mem"
 	"tailscale.com/net/bakedroots"
+	"tailscale.com/tailcfg"
 	"tailscale.com/tempfork/acme"
 	"tailscale.com/types/logger"
 	"tailscale.com/util/testenv"
@@ -47,6 +49,10 @@ import (
 	"tailscale.com/version/distro"
 )
 
+func init() {
+	RegisterC2N("GET /tls-cert-status", handleC2NTLSCertStatus)
+}
+
 // Process-wide cache. (A new *Handler is created per connection,
 // effectively per request)
 var (
@@ -836,3 +842,54 @@ func checkCertDomain(st *ipnstate.Status, domain string) error {
 	}
 	return fmt.Errorf("invalid domain %q; must be one of %q", domain, st.CertDomains)
 }
+
+// handleC2NTLSCertStatus returns info about the last TLS certificate issued for the
+// provided domain. This can be called by the controlplane to clean up DNS TXT
+// records when they're no longer needed by LetsEncrypt.
+//
+// It does not kick off a cert fetch or async refresh. It only reports anything
+// that's already sitting on disk, and only reports metadata about the public
+// cert (stuff that'd be the in CT logs anyway).
+func handleC2NTLSCertStatus(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
+	cs, err := b.getCertStore()
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	domain := r.FormValue("domain")
+	if domain == "" {
+		http.Error(w, "no 'domain'", http.StatusBadRequest)
+		return
+	}
+
+	ret := &tailcfg.C2NTLSCertInfo{}
+	pair, err := getCertPEMCached(cs, domain, b.clock.Now())
+	ret.Valid = err == nil
+	if err != nil {
+		ret.Error = err.Error()
+		if errors.Is(err, errCertExpired) {
+			ret.Expired = true
+		} else if errors.Is(err, ipn.ErrStateNotExist) {
+			ret.Missing = true
+			ret.Error = "no certificate"
+		}
+	} else {
+		block, _ := pem.Decode(pair.CertPEM)
+		if block == nil {
+			ret.Error = "invalid PEM"
+			ret.Valid = false
+		} else {
+			cert, err := x509.ParseCertificate(block.Bytes)
+			if err != nil {
+				ret.Error = fmt.Sprintf("invalid certificate: %v", err)
+				ret.Valid = false
+			} else {
+				ret.NotBefore = cert.NotBefore.UTC().Format(time.RFC3339)
+				ret.NotAfter = cert.NotAfter.UTC().Format(time.RFC3339)
+			}
+		}
+	}
+
+	writeJSON(w, ret)
+}

+ 18 - 3
ipn/ipnlocal/cert_js.go → ipn/ipnlocal/cert_disabled.go

@@ -1,20 +1,30 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 
+//go:build js || ts_omit_acme
+
 package ipnlocal
 
 import (
 	"context"
 	"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, errors.New("not implemented for js/wasm")
+	return nil, errNoCerts
 }
 
 var errCertExpired = errors.New("cert expired")
@@ -22,9 +32,14 @@ var errCertExpired = errors.New("cert expired")
 type certStore interface{}
 
 func getCertPEMCached(cs certStore, domain string, now time.Time) (p *TLSCertKeyPair, err error) {
-	return nil, errors.New("not implemented for js/wasm")
+	return nil, errNoCerts
 }
 
 func (b *LocalBackend) getCertStore() (certStore, error) {
-	return nil, errors.New("not implemented for js/wasm")
+	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
 }

+ 5 - 1
ipn/localapi/cert.go

@@ -1,7 +1,7 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 
-//go:build !ios && !android && !js
+//go:build !ios && !android && !js && !ts_omit_acme
 
 package localapi
 
@@ -14,6 +14,10 @@ import (
 	"tailscale.com/ipn/ipnlocal"
 )
 
+func init() {
+	Register("cert/", (*Handler).serveCert)
+}
+
 func (h *Handler) serveCert(w http.ResponseWriter, r *http.Request) {
 	if !h.PermitWrite && !h.PermitCert {
 		http.Error(w, "cert access denied", http.StatusForbidden)

+ 0 - 1
ipn/localapi/localapi.go

@@ -67,7 +67,6 @@ type LocalAPIHandler func(*Handler, http.ResponseWriter, *http.Request)
 // then it's a prefix match.
 var handler = map[string]LocalAPIHandler{
 	// The prefix match handlers end with a slash:
-	"cert/":     (*Handler).serveCert,
 	"profiles/": (*Handler).serveProfiles,
 
 	// The other /localapi/v0/NAME handlers are exact matches and contain only NAME