From 5384d2369031bce8745d7927002ab2103e33af09 Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Mon, 20 Jul 2026 15:48:54 +0000 Subject: [PATCH] cmd/derper: add opt-in support for LetsEncrypt IP address certificates LetsEncrypt made certificates for bare IP addresses generally available in January 2026. They require the short-lived ACME certificate profile and are valid for about six days. Add a new --acme-ip-certs flag. When set (with the default --certmode=letsencrypt), connections that arrive by IP address (no TLS SNI, or an IP address SNI matching the connection's destination address) get a LetsEncrypt cert for that IP, obtained on demand using the "shortlived" profile and the HTTP-01 challenge served on derper's plaintext HTTP port. Because the certificate is requested for whatever address the connection actually arrived on, it works for both IPv4 and IPv6 with no per-address configuration, and a client can never make us request a certificate for an address that isn't ours. Connections with a DNS name in the SNI keep using the regular autocert manager for --hostname. autocert can't do any of this itself, as it neither orders IP address identifiers nor serves connections without SNI, so this adds a small dedicated cert manager using tailscale.com/tempfork/acme instead. Clients can then connect to https:// without the DERPMap CertName pinning that self-signed certs from --certmode=manual require. Updates tailscale/corp#45167 Updates #11776 Signed-off-by: Brad Fitzpatrick Change-Id: I8e2d5b0a7c4f9e1b3d6a8c2f5e0b9d4a7c1f3e6d --- cmd/derper/cert.go | 19 +- cmd/derper/cert_test.go | 12 +- cmd/derper/depaware.txt | 1 + cmd/derper/derper.go | 5 +- cmd/derper/ipcert.go | 497 ++++++++++++++++++++++++++++++++++++++ cmd/derper/ipcert_test.go | 486 +++++++++++++++++++++++++++++++++++++ 6 files changed, 1011 insertions(+), 9 deletions(-) create mode 100644 cmd/derper/ipcert.go create mode 100644 cmd/derper/ipcert_test.go diff --git a/cmd/derper/cert.go b/cmd/derper/cert.go index 0cf814b3e..d12a457c2 100644 --- a/cmd/derper/cert.go +++ b/cmd/derper/cert.go @@ -44,12 +44,26 @@ type certProvider interface { HTTPHandler(fallback http.Handler) http.Handler } -func certProviderByCertMode(mode, dir, hostname, eabKID, eabKey, email string) (certProvider, error) { +func certProviderByCertMode(mode, dir, hostname string, ipCerts bool, eabKID, eabKey, email string) (certProvider, error) { if dir == "" { return nil, errors.New("missing required --certdir flag") } + if ipCerts && mode != "letsencrypt" { + return nil, errors.New("--acme-ip-certs requires --certmode=letsencrypt") + } switch mode { case "letsencrypt", "gcp": + if net.ParseIP(hostname) != nil { + if mode == "gcp" { + return nil, errors.New("--certmode=gcp requires --hostname to be a DNS name, not an IP address") + } + if !ipCerts { + return nil, errors.New("--hostname is an IP address; use --certmode=manual for a self-signed cert, or set --acme-ip-certs to get LetsEncrypt IP address certs") + } + // IP-only server: certs are issued on demand per + // connection, so there is no hostname cert provider. + return newIPCertManager(dir, email, "", nil) + } certManager := &autocert.Manager{ Prompt: autocert.AcceptTOS, HostPolicy: autocert.HostWhitelist(hostname), @@ -82,6 +96,9 @@ func certProviderByCertMode(mode, dir, hostname, eabKID, eabKey, email string) ( } else if hostname == "derp.tailscale.com" { certManager.Email = "security@tailscale.com" } + if ipCerts { + return newIPCertManager(dir, email, "", certManager) + } return certManager, nil case "manual": return NewManualCertManager(dir, hostname) diff --git a/cmd/derper/cert_test.go b/cmd/derper/cert_test.go index e111ed76b..1b359385a 100644 --- a/cmd/derper/cert_test.go +++ b/cmd/derper/cert_test.go @@ -91,7 +91,7 @@ func TestCertIP(t *testing.T) { t.Fatalf("Error closing key.pem: %v", err) } - cp, err := certProviderByCertMode("manual", dir, hostname, "", "", "") + cp, err := certProviderByCertMode("manual", dir, hostname, false, "", "", "") if err != nil { t.Fatal(err) } @@ -174,25 +174,25 @@ func TestGCPCertMode(t *testing.T) { dir := t.TempDir() // Missing EAB credentials - _, err := certProviderByCertMode("gcp", dir, "test.example.com", "", "", "test@example.com") + _, err := certProviderByCertMode("gcp", dir, "test.example.com", false, "", "", "test@example.com") if err == nil { t.Fatal("expected error when EAB credentials are missing") } // Missing email - _, err = certProviderByCertMode("gcp", dir, "test.example.com", "kid", "dGVzdC1rZXk", "") + _, err = certProviderByCertMode("gcp", dir, "test.example.com", false, "kid", "dGVzdC1rZXk", "") if err == nil { t.Fatal("expected error when email is missing") } // Invalid base64 - _, err = certProviderByCertMode("gcp", dir, "test.example.com", "kid", "not-valid!", "test@example.com") + _, err = certProviderByCertMode("gcp", dir, "test.example.com", false, "kid", "not-valid!", "test@example.com") if err == nil { t.Fatal("expected error for invalid base64") } // Valid base64url (no padding) - cp, err := certProviderByCertMode("gcp", dir, "test.example.com", "kid", "dGVzdC1rZXk", "test@example.com") + cp, err := certProviderByCertMode("gcp", dir, "test.example.com", false, "kid", "dGVzdC1rZXk", "test@example.com") if err != nil { t.Fatalf("base64url: %v", err) } @@ -201,7 +201,7 @@ func TestGCPCertMode(t *testing.T) { } // Valid standard base64 (with padding, gcloud format) - cp, err = certProviderByCertMode("gcp", dir, "test.example.com", "kid", "dGVzdC1rZXk=", "test@example.com") + cp, err = certProviderByCertMode("gcp", dir, "test.example.com", false, "kid", "dGVzdC1rZXk=", "test@example.com") if err != nil { t.Fatalf("base64: %v", err) } diff --git a/cmd/derper/depaware.txt b/cmd/derper/depaware.txt index 28b20ac56..7e85af037 100644 --- a/cmd/derper/depaware.txt +++ b/cmd/derper/depaware.txt @@ -123,6 +123,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa 💣 tailscale.com/safesocket from tailscale.com/client/local tailscale.com/syncs from tailscale.com/cmd/derper+ tailscale.com/tailcfg from tailscale.com/client/local+ + tailscale.com/tempfork/acme from tailscale.com/cmd/derper tailscale.com/tka from tailscale.com/client/local+ tailscale.com/tsconst from tailscale.com/net/netmon+ tailscale.com/tstime from tailscale.com/derp+ diff --git a/cmd/derper/derper.go b/cmd/derper/derper.go index ba5100ae3..bae4d9f01 100644 --- a/cmd/derper/derper.go +++ b/cmd/derper/derper.go @@ -62,10 +62,11 @@ configPath = flag.String("c", "", "config file path") certMode = flag.String("certmode", "letsencrypt", "mode for getting a cert. possible options: manual, letsencrypt, gcp") certDir = flag.String("certdir", tsweb.DefaultCertDir("derper-certs"), "directory to store ACME (e.g. LetsEncrypt) certs, if addr's port is :443") - hostname = flag.String("hostname", "derp.tailscale.com", "TLS host name for certs, if addr's port is :443. When --certmode=manual, this can be an IP address to avoid SNI checks") + hostname = flag.String("hostname", "derp.tailscale.com", "TLS host name for certs, if addr's port is :443. It can be an IP address when --certmode=manual (to avoid SNI checks) or when --acme-ip-certs is set (to run an IP-only server with no hostname cert)") acmeEABKid = flag.String("acme-eab-kid", "", "ACME External Account Binding (EAB) Key ID (required for --certmode=gcp)") acmeEABKey = flag.String("acme-eab-key", "", "ACME External Account Binding (EAB) HMAC key, base64-encoded (required for --certmode=gcp)") acmeEmail = flag.String("acme-email", "", "ACME account contact email address (required for --certmode=gcp, optional for letsencrypt)") + acmeIPCerts = flag.Bool("acme-ip-certs", false, "whether to serve LetsEncrypt certs for the server's IP addresses: when a client connects by IP address (sending no TLS SNI, or an IP address SNI matching the connection's destination IP), get and serve a LetsEncrypt cert for that IP, using the short-lived (~6 day) ACME certificate profile. This works for both IPv4 and IPv6 with no per-address configuration. It requires --certmode=letsencrypt and the ACME server must be able to reach port 80 at each such IP for the HTTP-01 challenge.") runSTUN = flag.Bool("stun", true, "whether to run a STUN server. It will bind to the same IP (if any) as the --addr flag value.") runDERP = flag.Bool("derp", true, "whether to run a DERP server. The only reason to set this false is if you're decommissioning a server but want to keep its bootstrap DNS functionality still running.") flagHome = flag.String("home", "", "what to serve at the root path. It may be left empty (the default, for a default homepage), \"blank\" for a blank page, or a URL to redirect to") @@ -349,7 +350,7 @@ func main() { if serveTLS { log.Printf("derper: serving on %s with TLS", *addr) var certManager certProvider - certManager, err = certProviderByCertMode(*certMode, *certDir, *hostname, *acmeEABKid, *acmeEABKey, *acmeEmail) + certManager, err = certProviderByCertMode(*certMode, *certDir, *hostname, *acmeIPCerts, *acmeEABKid, *acmeEABKey, *acmeEmail) if err != nil { log.Fatalf("derper: can not start cert provider: %v", err) } diff --git a/cmd/derper/ipcert.go b/cmd/derper/ipcert.go new file mode 100644 index 000000000..b3b4192de --- /dev/null +++ b/cmd/derper/ipcert.go @@ -0,0 +1,497 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "net/netip" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "time" + + "tailscale.com/atomicfile" + "tailscale.com/tailcfg" + "tailscale.com/tempfork/acme" +) + +// shortlivedProfile is the ACME certificate profile required by +// LetsEncrypt for IP address certificates. Certificates issued under +// it are valid for about six days. +// See https://letsencrypt.org/docs/profiles/. +const shortlivedProfile = "shortlived" + +// ipCertManager is a certProvider that obtains and renews LetsEncrypt +// TLS certificates for the server's IP addresses on demand, using the +// short-lived ACME certificate profile and the HTTP-01 challenge +// served on the derper's plaintext HTTP port. +// +// Clients connecting to an IP address usually send no SNI, so the +// requested IP address is taken from the TCP connection's local +// address. That works for however many IPv4 and IPv6 addresses the +// server has, with no configuration. Clients that do send an IP +// address in the SNI get a certificate only if it matches the +// connection's local address, so a client can never make us request a +// certificate for an address that isn't ours. +// +// Connections with a DNS name in the SNI are passed through to the +// optional next provider (the regular autocert manager for the +// --hostname certificate), if any. +type ipCertManager struct { + certDir string + email string // optional ACME account contact + client *acme.Client + next certProvider // provider for DNS hostname connections, or nil + nextTLS *tls.Config // next.TLSConfig(), or nil + + mu sync.Mutex + certs map[netip.Addr]*ipCertEntry + tokens map[string]string // HTTP-01 challenge URL path => response body +} + +// ipCertEntry is the issuance state for one IP address. +// All fields are guarded by ipCertManager.mu. +type ipCertEntry struct { + cert *tls.Certificate // current cert with Leaf set, or nil if not yet issued + + flight chan struct{} // non-nil while an issuance is running; closed when it finishes + flightErr error // result of the last finished issuance + nextAttempt time.Time // earliest time of the next issuance attempt, after a failure + retryDelay time.Duration // backoff to apply after the next failure +} + +// newIPCertManager returns an ipCertManager storing its ACME account +// key and issued certificates in certdir. +// +// If directoryURL is empty, the LetsEncrypt production directory is +// used; tests point it at a fake ACME server. If next is non-nil, +// connections with a DNS name in the SNI are served by it. +func newIPCertManager(certdir, email, directoryURL string, next certProvider) (*ipCertManager, error) { + if err := os.MkdirAll(certdir, 0700); err != nil { + return nil, err + } + accountKey, err := loadOrCreateAccountKey(filepath.Join(certdir, "acme-account.key")) + if err != nil { + return nil, fmt.Errorf("ACME account key: %w", err) + } + m := &ipCertManager{ + certDir: certdir, + email: email, + client: &acme.Client{ + Key: accountKey, + DirectoryURL: directoryURL, + UserAgent: "tailscale-derper", + }, + next: next, + certs: make(map[netip.Addr]*ipCertEntry), + tokens: make(map[string]string), + } + if next != nil { + m.nextTLS = next.TLSConfig() + } + go m.renewLoop() + return m, nil +} + +func loadOrCreateAccountKey(path string) (*ecdsa.PrivateKey, error) { + if pemBytes, err := os.ReadFile(path); err == nil { + block, _ := pem.Decode(pemBytes) + if block == nil { + return nil, fmt.Errorf("invalid PEM in %s", path) + } + return x509.ParseECPrivateKey(block.Bytes) + } else if !os.IsNotExist(err) { + return nil, err + } + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + der, err := x509.MarshalECPrivateKey(key) + if err != nil { + return nil, err + } + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}) + if err := atomicfile.WriteFile(path, pemBytes, 0600); err != nil { + return nil, err + } + return key, nil +} + +// certPaths returns the cert and key file paths for ip in m.certDir. +// Colons in IPv6 addresses are replaced with dots to keep the names +// filesystem-safe. +func (m *ipCertManager) certPaths(ip netip.Addr) (crtPath, keyPath string) { + base := strings.ReplaceAll(ip.String(), ":", ".") + return filepath.Join(m.certDir, base+".crt"), filepath.Join(m.certDir, base+".key") +} + +func (m *ipCertManager) TLSConfig() *tls.Config { + var conf *tls.Config + if m.nextTLS != nil { + conf = m.nextTLS.Clone() + } else { + conf = &tls.Config{ + NextProtos: []string{ + "http/1.1", + }, + } + } + conf.GetCertificate = m.getCertificate + return conf +} + +// connLocalIP returns the local (server side) IP address of the +// connection that sent the ClientHello. +func connLocalIP(hi *tls.ClientHelloInfo) (netip.Addr, bool) { + if hi.Conn == nil { + return netip.Addr{}, false + } + ta, ok := hi.Conn.LocalAddr().(*net.TCPAddr) + if !ok { + return netip.Addr{}, false + } + ip := ta.AddrPort().Addr().Unmap() + return ip, ip.IsValid() +} + +func (m *ipCertManager) getCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) { + connIP, connIPOK := connLocalIP(hi) + if hi.ServerName != "" { + sniIP, err := netip.ParseAddr(hi.ServerName) + if err != nil { + // The SNI is a DNS name; let the hostname provider handle it. + if m.nextTLS != nil && m.nextTLS.GetCertificate != nil { + return m.nextTLS.GetCertificate(hi) + } + return nil, fmt.Errorf("no certificate for hostname %q; this server only serves IP address certificates", hi.ServerName) + } + if !connIPOK || sniIP.Unmap() != connIP { + return nil, fmt.Errorf("requested certificate for IP %v does not match the connection's IP address", sniIP) + } + } + if !connIPOK { + return nil, errors.New("unable to determine the connection's local IP address") + } + ctx := hi.Context() + if ctx == nil { + ctx = context.Background() + } + return m.certForIP(ctx, connIP) +} + +// certForIP returns the current certificate for ip, obtaining one +// first if there is no unexpired certificate for it. Concurrent +// callers for the same IP share a single issuance. +func (m *ipCertManager) certForIP(ctx context.Context, ip netip.Addr) (*tls.Certificate, error) { + m.mu.Lock() + e := m.entryLocked(ip) + if e.cert != nil && time.Now().Before(e.cert.Leaf.NotAfter) { + defer m.mu.Unlock() + return clipCert(e.cert), nil + } + if e.flight == nil && time.Now().Before(e.nextAttempt) { + m.mu.Unlock() + return nil, fmt.Errorf("cert issuance for %v failed recently; next attempt no earlier than %v", ip, e.nextAttempt.Format(time.RFC3339)) + } + flight := m.startFlightLocked(ip, e) + m.mu.Unlock() + + select { + case <-flight: + case <-ctx.Done(): + return nil, ctx.Err() + } + + m.mu.Lock() + defer m.mu.Unlock() + if e.cert == nil { + return nil, e.flightErr + } + return clipCert(e.cert), nil +} + +func (m *ipCertManager) entryLocked(ip netip.Addr) *ipCertEntry { + e, ok := m.certs[ip] + if !ok { + e = &ipCertEntry{} + m.certs[ip] = e + } + return e +} + +// clipCert returns a shallow copy of cert with a capacity-clamped +// chain so callers can never mutate the manager's long-lived +// certificate. +func clipCert(cert *tls.Certificate) *tls.Certificate { + certCopy := *cert + certCopy.Certificate = slices.Clip(certCopy.Certificate) + return &certCopy +} + +// startFlightLocked starts an issuance for ip if none is running and +// returns a channel that is closed when it finishes. +func (m *ipCertManager) startFlightLocked(ip netip.Addr, e *ipCertEntry) chan struct{} { + if e.flight != nil { + return e.flight + } + done := make(chan struct{}) + e.flight = done + go func() { + err := m.issue(ip) + m.mu.Lock() + defer m.mu.Unlock() + e.flight = nil + e.flightErr = err + if err != nil { + if e.retryDelay == 0 { + e.retryDelay = time.Minute + } + e.nextAttempt = time.Now().Add(e.retryDelay) + e.retryDelay = min(e.retryDelay*2, 30*time.Minute) + log.Printf("derper: acme: getting cert for %v: %v (next attempt in %v)", ip, err, time.Until(e.nextAttempt).Round(time.Second)) + } else { + e.retryDelay = 0 + e.nextAttempt = time.Time{} + } + close(done) + }() + return done +} + +// issue obtains a certificate for ip, preferring a still-fresh one +// cached on disk over a new ACME order. +func (m *ipCertManager) issue(ip netip.Addr) error { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + if cert, err := m.loadCachedCert(ip); err == nil && !certNeedsRenewal(cert.Leaf) { + m.mu.Lock() + m.entryLocked(ip).cert = cert + m.mu.Unlock() + log.Printf("derper: acme: loaded cached cert for %v (expires %v)", ip, cert.Leaf.NotAfter) + return nil + } + return m.obtainCert(ctx, ip) +} + +// loadCachedCert loads a previously issued certificate for ip from +// disk, if present and still valid. +func (m *ipCertManager) loadCachedCert(ip netip.Addr) (*tls.Certificate, error) { + crtPath, keyPath := m.certPaths(ip) + cert, err := tls.LoadX509KeyPair(crtPath, keyPath) + if err != nil { + return nil, err + } + leaf, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + return nil, err + } + now := time.Now() + if now.Before(leaf.NotBefore) || now.After(leaf.NotAfter) { + return nil, fmt.Errorf("cached cert is expired or not yet valid (NotAfter %v)", leaf.NotAfter) + } + if err := leaf.VerifyHostname(ip.String()); err != nil { + return nil, err + } + cert.Leaf = leaf + return &cert, nil +} + +// HTTPHandler returns a handler serving HTTP-01 challenge responses on +// the derper's plaintext HTTP port, sending all other requests to the +// next provider's handler, if any, and otherwise to fallback. +func (m *ipCertManager) HTTPHandler(fallback http.Handler) http.Handler { + if m.next != nil { + fallback = m.next.HTTPHandler(fallback) + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/.well-known/acme-challenge/") { + fallback.ServeHTTP(w, r) + return + } + m.mu.Lock() + response, ok := m.tokens[r.URL.Path] + m.mu.Unlock() + if !ok { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/plain") + io.WriteString(w, response) + }) +} + +func (m *ipCertManager) setToken(path, response string) { + m.mu.Lock() + defer m.mu.Unlock() + m.tokens[path] = response +} + +func (m *ipCertManager) deleteToken(path string) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.tokens, path) +} + +// certNeedsRenewal reports whether leaf has less than a third of its +// lifetime remaining. LetsEncrypt short-lived certs are valid for +// about six days, so renewal happens roughly every four. +func certNeedsRenewal(leaf *x509.Certificate) bool { + total := leaf.NotAfter.Sub(leaf.NotBefore) + return time.Until(leaf.NotAfter) < total/3 +} + +// renewLoop runs for the lifetime of the process, renewing each issued +// certificate as it approaches expiry. +func (m *ipCertManager) renewLoop() { + for { + time.Sleep(time.Hour) + m.mu.Lock() + now := time.Now() + for ip, e := range m.certs { + if e.cert != nil && certNeedsRenewal(e.cert.Leaf) && e.flight == nil && now.After(e.nextAttempt) { + m.startFlightLocked(ip, e) + } + } + m.mu.Unlock() + } +} + +// obtainCert does one ACME issuance flow for ip: it registers the +// account if needed, orders a short-lived profile certificate for the +// IP address identifier, fulfills the HTTP-01 challenges, and installs +// and caches the issued certificate. +func (m *ipCertManager) obtainCert(ctx context.Context, ip netip.Addr) error { + ipStr := ip.String() + + var contact []string + if m.email != "" { + contact = []string{"mailto:" + m.email} + } + _, err := m.client.Register(ctx, &acme.Account{Contact: contact}, acme.AcceptTOS) + if err != nil && !errors.Is(err, acme.ErrAccountAlreadyExists) { + return fmt.Errorf("register: %w", err) + } + + order, err := m.client.AuthorizeOrder(ctx, acme.IPIDs(ipStr), acme.WithOrderProfile(shortlivedProfile)) + if err != nil { + return fmt.Errorf("new order: %w", err) + } + for _, authzURL := range order.AuthzURLs { + if err := m.fulfillAuthz(ctx, authzURL); err != nil { + return err + } + } + order, err = m.client.WaitOrder(ctx, order.URI) + if err != nil { + return fmt.Errorf("waiting for order: %w", err) + } + + certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return err + } + csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + IPAddresses: []net.IP{ip.AsSlice()}, + }, certKey) + if err != nil { + return err + } + der, _, err := m.client.CreateOrderCert(ctx, order.FinalizeURL, csr, true) + if err != nil { + return fmt.Errorf("finalizing order: %w", err) + } + leaf, err := x509.ParseCertificate(der[0]) + if err != nil { + return fmt.Errorf("parsing issued cert: %w", err) + } + if err := leaf.VerifyHostname(ipStr); err != nil { + return fmt.Errorf("issued cert: %w", err) + } + + keyDER, err := x509.MarshalECPrivateKey(certKey) + if err != nil { + return err + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + var chainPEM []byte + for _, b := range der { + chainPEM = append(chainPEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: b})...) + } + crtPath, keyPath := m.certPaths(ip) + if err := atomicfile.WriteFile(keyPath, keyPEM, 0600); err != nil { + return err + } + if err := atomicfile.WriteFile(crtPath, chainPEM, 0644); err != nil { + return err + } + + m.mu.Lock() + m.entryLocked(ip).cert = &tls.Certificate{ + Certificate: der, + PrivateKey: certKey, + Leaf: leaf, + } + m.mu.Unlock() + + dn := &tailcfg.DERPNode{ + Name: "custom", + RegionID: 900, + HostName: ipStr, + } + dnJSON, _ := json.Marshal(dn) + log.Printf("derper: acme: got cert for %v (expires %v). Configure it in DERPMap using (https://tailscale.com/s/custom-derp):\n %s", ip, leaf.NotAfter, dnJSON) + return nil +} + +// fulfillAuthz completes the HTTP-01 challenge for one authorization, +// if it is still pending. +func (m *ipCertManager) fulfillAuthz(ctx context.Context, authzURL string) error { + authz, err := m.client.GetAuthorization(ctx, authzURL) + if err != nil { + return fmt.Errorf("getting authorization: %w", err) + } + if authz.Status != acme.StatusPending { + return nil + } + var challenge *acme.Challenge + for _, c := range authz.Challenges { + if c.Type == "http-01" { + challenge = c + break + } + } + if challenge == nil { + return errors.New("authorization offers no http-01 challenge") + } + response, err := m.client.HTTP01ChallengeResponse(challenge.Token) + if err != nil { + return err + } + path := m.client.HTTP01ChallengePath(challenge.Token) + m.setToken(path, response) + defer m.deleteToken(path) + if _, err := m.client.Accept(ctx, challenge); err != nil { + return fmt.Errorf("accepting challenge: %w", err) + } + if _, err := m.client.WaitAuthorization(ctx, authz.URI); err != nil { + return fmt.Errorf("waiting for authorization: %w", err) + } + return nil +} diff --git a/cmd/derper/ipcert_test.go b/cmd/derper/ipcert_test.go new file mode 100644 index 000000000..b3deb3fdd --- /dev/null +++ b/cmd/derper/ipcert_test.go @@ -0,0 +1,486 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "math/big" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +// fakeIPACME is a minimal fake ACME (RFC 8555) certificate authority +// for testing ipCertManager. It implements just enough of the protocol +// for http-01 order flows with IP address identifiers and the +// "shortlived" profile: one order at a time, no JWS signature +// verification, no nonce tracking. +type fakeIPACME struct { + t *testing.T + srv *httptest.Server + challengeBase string // base URL at which http-01 challenges are fetched + + caKey *ecdsa.PrivateKey + caCert *x509.Certificate + + mu sync.Mutex + orders int // number of orders created + gotProfile string // profile of the last order + gotIDType string // identifier type of the last order + gotIDValue string // identifier value of the last order + authzStatus string // "pending" or "valid" + orderStatus string // "pending", "ready", or "valid" + token string + certPEM []byte +} + +func newFakeIPACME(t *testing.T) *fakeIPACME { + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + caTmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "fake IP ACME root"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign, + BasicConstraintsValid: true, + } + caDER, err := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &caKey.PublicKey, caKey) + if err != nil { + t.Fatal(err) + } + caCert, err := x509.ParseCertificate(caDER) + if err != nil { + t.Fatal(err) + } + f := &fakeIPACME{ + t: t, + caKey: caKey, + caCert: caCert, + } + f.srv = httptest.NewServer(http.HandlerFunc(f.serveHTTP)) + t.Cleanup(f.srv.Close) + return f +} + +func (f *fakeIPACME) directoryURL() string { return f.srv.URL + "/directory" } + +func (f *fakeIPACME) numOrders() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.orders +} + +func (f *fakeIPACME) serveHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Replay-Nonce", "test-nonce") + writeJSON := func(v any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) + } + orderJSON := func() any { + return map[string]any{ + "status": f.orderStatus, + "identifiers": []map[string]string{{"type": f.gotIDType, "value": f.gotIDValue}}, + "authorizations": []string{f.srv.URL + "/authz/1"}, + "finalize": f.srv.URL + "/finalize/1", + "certificate": f.srv.URL + "/cert/1", + } + } + switch { + case r.URL.Path == "/directory": + writeJSON(map[string]any{ + "newNonce": f.srv.URL + "/new-nonce", + "newAccount": f.srv.URL + "/new-account", + "newOrder": f.srv.URL + "/new-order", + "revokeCert": f.srv.URL + "/revoke-cert", + "keyChange": f.srv.URL + "/key-change", + "meta": map[string]any{ + "profiles": map[string]string{ + "classic": "the default profile", + shortlivedProfile: "six day certificates", + }, + }, + }) + case r.URL.Path == "/new-nonce": + // The Replay-Nonce header was already set above. + case r.URL.Path == "/new-account": + w.Header().Set("Location", f.srv.URL+"/account/1") + w.WriteHeader(http.StatusCreated) + writeJSON(map[string]any{"status": "valid"}) + case r.URL.Path == "/new-order": + var req struct { + Identifiers []struct{ Type, Value string } `json:"identifiers"` + Profile string `json:"profile"` + } + if err := decodeJWSPayload(r, &req); err != nil { + f.t.Errorf("new-order payload: %v", err) + } + f.mu.Lock() + f.orders++ + f.gotProfile = req.Profile + if len(req.Identifiers) == 1 { + f.gotIDType = req.Identifiers[0].Type + f.gotIDValue = req.Identifiers[0].Value + } + f.authzStatus = "pending" + f.orderStatus = "pending" + f.token = fmt.Sprintf("tok-%d", f.orders) + f.mu.Unlock() + w.Header().Set("Location", f.srv.URL+"/order/1") + w.WriteHeader(http.StatusCreated) + writeJSON(orderJSON()) + case r.URL.Path == "/authz/1": + f.mu.Lock() + defer f.mu.Unlock() + writeJSON(map[string]any{ + "status": f.authzStatus, + "identifier": map[string]string{"type": f.gotIDType, "value": f.gotIDValue}, + "challenges": []map[string]string{{ + "type": "http-01", + "url": f.srv.URL + "/chal/1", + "token": f.token, + "status": f.authzStatus, + }}, + }) + case r.URL.Path == "/chal/1": + if err := f.validateChallenge(); err != nil { + f.t.Errorf("challenge validation: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + f.mu.Lock() + f.authzStatus = "valid" + f.orderStatus = "ready" + f.mu.Unlock() + writeJSON(map[string]any{"type": "http-01", "status": "valid", "token": f.token}) + case r.URL.Path == "/order/1": + f.mu.Lock() + defer f.mu.Unlock() + w.Header().Set("Location", f.srv.URL+"/order/1") + writeJSON(orderJSON()) + case r.URL.Path == "/finalize/1": + var req struct { + CSR string `json:"csr"` + } + if err := decodeJWSPayload(r, &req); err != nil { + f.t.Errorf("finalize payload: %v", err) + } + if err := f.issueCert(req.CSR); err != nil { + f.t.Errorf("issuing cert: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + f.mu.Lock() + f.orderStatus = "valid" + f.mu.Unlock() + w.Header().Set("Location", f.srv.URL+"/order/1") + writeJSON(orderJSON()) + case r.URL.Path == "/cert/1": + f.mu.Lock() + defer f.mu.Unlock() + w.Header().Set("Content-Type", "application/pem-certificate-chain") + w.Write(f.certPEM) + default: + f.t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } +} + +// validateChallenge fetches the http-01 challenge response from the +// server under test, standing in for the CA dialing port 80 at the IP +// address being validated. +func (f *fakeIPACME) validateChallenge() error { + f.mu.Lock() + token := f.token + base := f.challengeBase + f.mu.Unlock() + res, err := http.Get(base + "/.well-known/acme-challenge/" + token) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return fmt.Errorf("status %d", res.StatusCode) + } + body, err := io.ReadAll(res.Body) + if err != nil { + return err + } + if !strings.HasPrefix(string(body), token+".") { + return fmt.Errorf("challenge response %q does not start with %q", body, token+".") + } + return nil +} + +// issueCert signs a certificate for the CSR (base64url DER), valid for +// six days like a LetsEncrypt shortlived profile certificate. +func (f *fakeIPACME) issueCert(csrB64 string) error { + csrDER, err := base64.RawURLEncoding.DecodeString(csrB64) + if err != nil { + return err + } + csr, err := x509.ParseCertificateRequest(csrDER) + if err != nil { + return err + } + if len(csr.IPAddresses) != 1 { + return fmt.Errorf("CSR has %d IP addresses; want 1", len(csr.IPAddresses)) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(2), + IPAddresses: csr.IPAddresses, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(6 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, f.caCert, csr.PublicKey, f.caKey) + if err != nil { + return err + } + var buf []byte + buf = append(buf, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})...) + buf = append(buf, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: f.caCert.Raw})...) + f.mu.Lock() + f.certPEM = buf + f.mu.Unlock() + return nil +} + +// decodeJWSPayload decodes the payload of a JWS-encoded ACME request +// without verifying its signature. +func decodeJWSPayload(r *http.Request, v any) error { + var req struct{ Payload string } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return err + } + b, err := base64.RawURLEncoding.DecodeString(req.Payload) + if err != nil { + return err + } + return json.Unmarshal(b, v) +} + +// ipConn is a stub net.Conn whose LocalAddr is the given IP, standing +// in for the accepted TLS connection whose destination address decides +// which certificate to serve. +type ipConn struct { + net.Conn + local net.Addr +} + +func (c ipConn) LocalAddr() net.Addr { return c.local } + +// helloFor returns a ClientHelloInfo as ipCertManager.getCertificate +// would see it for a connection to localIP with the given SNI value. +func helloFor(t *testing.T, localIP, sni string) *tls.ClientHelloInfo { + t.Helper() + ip := net.ParseIP(localIP) + if ip == nil { + t.Fatalf("bad IP %q", localIP) + } + return &tls.ClientHelloInfo{ + ServerName: sni, + Conn: ipConn{local: &net.TCPAddr{IP: ip, Port: 443}}, + } +} + +// stubCertProvider is a certProvider returning a fixed certificate, +// standing in for the autocert manager handling DNS hostname +// connections. +type stubCertProvider struct { + cert *tls.Certificate +} + +func (p *stubCertProvider) TLSConfig() *tls.Config { + return &tls.Config{ + GetCertificate: func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) { + return p.cert, nil + }, + } +} + +func (p *stubCertProvider) HTTPHandler(fallback http.Handler) http.Handler { return fallback } + +// TestIPCertManager exercises the on-demand issuance flow of +// ipCertManager against a fake ACME CA: certs are ordered for whatever +// IP address a connection arrives on (IPv4 and IPv6), with the +// shortlived profile, answering the http-01 challenge, and reusing the +// on-disk cache. +func TestIPCertManager(t *testing.T) { + const ip4 = "203.0.113.7" + const ip6 = "2001:db8::7" + dir := t.TempDir() + ca := newFakeIPACME(t) + + m, err := newIPCertManager(dir, "test@example.com", ca.directoryURL(), nil) + if err != nil { + t.Fatal(err) + } + + // Serve the manager's HTTP-01 challenge handler like derper's port + // 80 listener does. + challengeSrv := httptest.NewServer(m.HTTPHandler(http.NotFoundHandler())) + defer challengeSrv.Close() + ca.challengeBase = challengeSrv.URL + + // A connection to the IPv4 address with no SNI mints a cert for it. + cert, err := m.getCertificate(helloFor(t, ip4, "")) + if err != nil { + t.Fatal(err) + } + if err := cert.Leaf.VerifyHostname(ip4); err != nil { + t.Errorf("issued cert not valid for %v: %v", ip4, err) + } + if got, want := ca.gotProfile, shortlivedProfile; got != want { + t.Errorf("order profile = %q; want %q", got, want) + } + if ca.gotIDType != "ip" || ca.gotIDValue != ip4 { + t.Errorf("order identifier = %q %q; want %q %q", ca.gotIDType, ca.gotIDValue, "ip", ip4) + } + if n := ca.numOrders(); n != 1 { + t.Errorf("orders created = %d; want 1", n) + } + + // A connection to the IPv6 address mints a second, separate cert. + cert6, err := m.getCertificate(helloFor(t, ip6, "")) + if err != nil { + t.Fatal(err) + } + if err := cert6.Leaf.VerifyHostname(ip6); err != nil { + t.Errorf("issued cert not valid for %v: %v", ip6, err) + } + if ca.gotIDType != "ip" || ca.gotIDValue != ip6 { + t.Errorf("order identifier = %q %q; want %q %q", ca.gotIDType, ca.gotIDValue, "ip", ip6) + } + if n := ca.numOrders(); n != 2 { + t.Errorf("orders created = %d; want 2", n) + } + + // An SNI containing the connection's own IP address is served from + // the cache. + if _, err := m.getCertificate(helloFor(t, ip4, ip4)); err != nil { + t.Errorf("getCertificate with matching IP SNI: %v", err) + } + if n := ca.numOrders(); n != 2 { + t.Errorf("orders created after cached hit = %d; want 2", n) + } + + // An SNI naming some other IP address is rejected. + if _, err := m.getCertificate(helloFor(t, ip4, "203.0.113.8")); err == nil { + t.Error("getCertificate with mismatched IP SNI succeeded; want error") + } + + // A DNS name SNI has no provider to go to here. + if _, err := m.getCertificate(helloFor(t, ip4, "derp.example.com")); err == nil { + t.Error("getCertificate with DNS SNI and no next provider succeeded; want error") + } + + // A second manager over the same cert directory must use the + // on-disk cache rather than creating more orders. + m2, err := newIPCertManager(dir, "test@example.com", ca.directoryURL(), nil) + if err != nil { + t.Fatal(err) + } + cachedCert, err := m2.getCertificate(helloFor(t, ip4, "")) + if err != nil { + t.Fatal(err) + } + if err := cachedCert.Leaf.VerifyHostname(ip4); err != nil { + t.Errorf("cached cert not valid for %v: %v", ip4, err) + } + if n := ca.numOrders(); n != 2 { + t.Errorf("orders created after cache reuse = %d; want 2", n) + } + + // Non-challenge requests go to the fallback handler. + rec := httptest.NewRecorder() + m.HTTPHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "fallback") + })).ServeHTTP(rec, httptest.NewRequest("GET", "/other", nil)) + if got := rec.Body.String(); got != "fallback" { + t.Errorf("fallback body = %q; want %q", got, "fallback") + } +} + +// TestIPCertManagerNextProvider verifies that connections with a DNS +// name in the SNI are passed through to the next provider. +func TestIPCertManagerNextProvider(t *testing.T) { + dir := t.TempDir() + ca := newFakeIPACME(t) + stubCert := &tls.Certificate{} + m, err := newIPCertManager(dir, "", ca.directoryURL(), &stubCertProvider{cert: stubCert}) + if err != nil { + t.Fatal(err) + } + got, err := m.getCertificate(helloFor(t, "203.0.113.7", "derp.example.com")) + if err != nil { + t.Fatal(err) + } + if got != stubCert { + t.Errorf("DNS SNI returned %p; want the next provider's cert %p", got, stubCert) + } + if n := ca.numOrders(); n != 0 { + t.Errorf("orders created = %d; want 0", n) + } +} + +// TestCertModeIPCertsGating verifies the flag validation around +// --acme-ip-certs and IP address hostnames. +func TestCertModeIPCertsGating(t *testing.T) { + tests := []struct { + name string + mode string + host string + ipCerts bool + wantErr string // or empty to expect success + }{ + {"letsencrypt_ip_no_flag", "letsencrypt", "1.2.3.4", false, "--acme-ip-certs"}, + {"gcp_ip", "gcp", "1.2.3.4", false, "--certmode=gcp requires --hostname to be a DNS name"}, + {"gcp_flag", "gcp", "1.2.3.4", true, "--acme-ip-certs requires --certmode=letsencrypt"}, + {"manual_flag", "manual", "1.2.3.4", true, "--acme-ip-certs requires --certmode=letsencrypt"}, + {"letsencrypt_ip_flag", "letsencrypt", "1.2.3.4", true, ""}, + {"letsencrypt_hostname_flag", "letsencrypt", "derp.example.com", true, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cp, err := certProviderByCertMode(tt.mode, t.TempDir(), tt.host, tt.ipCerts, "", "", "") + if tt.wantErr == "" { + if err != nil { + t.Fatalf("certProviderByCertMode(%q, %q, ipCerts=%v) = %v; want success", tt.mode, tt.host, tt.ipCerts, err) + } + m, ok := cp.(*ipCertManager) + if !ok { + t.Fatalf("provider type = %T; want *ipCertManager", cp) + } + wantNext := net.ParseIP(tt.host) == nil + if gotNext := m.next != nil; gotNext != wantNext { + t.Errorf("has next provider = %v; want %v", gotNext, wantNext) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("certProviderByCertMode(%q, %q, ipCerts=%v) error = %v; want contains %q", + tt.mode, tt.host, tt.ipCerts, err, tt.wantErr) + } + }) + } +}