mirror of
https://github.com/tailscale/tailscale.git
synced 2026-07-29 16:56:32 -04:00
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
487 lines
15 KiB
Go
487 lines
15 KiB
Go
// 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)
|
|
}
|
|
})
|
|
}
|
|
}
|