mirror of
https://github.com/tailscale/tailscale.git
synced 2026-07-28 00:06:30 -04:00
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://<IP> without the DERPMap CertName pinning that self-signed certs from --certmode=manual require. Updates tailscale/corp#45167 Updates #11776 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com> Change-Id: I8e2d5b0a7c4f9e1b3d6a8c2f5e0b9d4a7c1f3e6d
This commit is contained in:
committed by
Brad Fitzpatrick
parent
fdd81c68b3
commit
5384d23690
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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+
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
497
cmd/derper/ipcert.go
Normal file
497
cmd/derper/ipcert.go
Normal file
@@ -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
|
||||
}
|
||||
486
cmd/derper/ipcert_test.go
Normal file
486
cmd/derper/ipcert_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user