Something went wrong. Try again.
forked from tailscale/tailscale
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487// 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) } }) }}