cmd/derper: add GCP Certificate Manager support (#18161)

Add --certmode=gcp for using Google Cloud Certificate Manager's
public CA instead of Let's Encrypt. GCP requires External Account
Binding (EAB) credentials for ACME registration, so this adds
--acme-eab-kid and --acme-eab-key flags.

The EAB key accepts both base64url and standard base64 encoding
to support both ACME spec format and gcloud output.

Fixes tailscale/corp#34881

Signed-off-by: Raj Singh <raj@tailscale.com>
Co-authored-by: Brad Fitzpatrick <bradfitz@tailscale.com>
pull/18066/merge
Raj Singh 3 days ago committed by GitHub
parent 1dfdee8521
commit 8eda947530
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -11,6 +11,7 @@ import (
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"crypto/x509/pkix" "crypto/x509/pkix"
"encoding/base64"
"encoding/json" "encoding/json"
"encoding/pem" "encoding/pem"
"errors" "errors"
@ -24,6 +25,7 @@ import (
"regexp" "regexp"
"time" "time"
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert" "golang.org/x/crypto/acme/autocert"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
) )
@ -42,17 +44,33 @@ type certProvider interface {
HTTPHandler(fallback http.Handler) http.Handler HTTPHandler(fallback http.Handler) http.Handler
} }
func certProviderByCertMode(mode, dir, hostname string) (certProvider, error) { func certProviderByCertMode(mode, dir, hostname, eabKID, eabKey string) (certProvider, error) {
if dir == "" { if dir == "" {
return nil, errors.New("missing required --certdir flag") return nil, errors.New("missing required --certdir flag")
} }
switch mode { switch mode {
case "letsencrypt": case "letsencrypt", "gcp":
certManager := &autocert.Manager{ certManager := &autocert.Manager{
Prompt: autocert.AcceptTOS, Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(hostname), HostPolicy: autocert.HostWhitelist(hostname),
Cache: autocert.DirCache(dir), Cache: autocert.DirCache(dir),
} }
if mode == "gcp" {
if eabKID == "" || eabKey == "" {
return nil, errors.New("--certmode=gcp requires --acme-eab-kid and --acme-eab-key flags")
}
keyBytes, err := decodeEABKey(eabKey)
if err != nil {
return nil, err
}
certManager.Client = &acme.Client{
DirectoryURL: "https://dv.acme-v02.api.pki.goog/directory",
}
certManager.ExternalAccountBinding = &acme.ExternalAccountBinding{
KID: eabKID,
Key: keyBytes,
}
}
if hostname == "derp.tailscale.com" { if hostname == "derp.tailscale.com" {
certManager.HostPolicy = prodAutocertHostPolicy certManager.HostPolicy = prodAutocertHostPolicy
certManager.Email = "security@tailscale.com" certManager.Email = "security@tailscale.com"
@ -209,3 +227,17 @@ func createSelfSignedIPCert(crtPath, keyPath, ipStr string) (*tls.Certificate, e
} }
return &tlsCert, nil return &tlsCert, nil
} }
// decodeEABKey decodes a base64-encoded EAB key.
// It accepts both standard base64 (with padding) and base64url (without padding).
func decodeEABKey(s string) ([]byte, error) {
// Try base64url first (no padding), then standard base64 (with padding).
// This handles both ACME spec format and gcloud output format.
if b, err := base64.RawURLEncoding.DecodeString(s); err == nil {
return b, nil
}
if b, err := base64.StdEncoding.DecodeString(s); err == nil {
return b, nil
}
return nil, errors.New("invalid base64 encoding for EAB key")
}

@ -91,7 +91,7 @@ func TestCertIP(t *testing.T) {
t.Fatalf("Error closing key.pem: %v", err) t.Fatalf("Error closing key.pem: %v", err)
} }
cp, err := certProviderByCertMode("manual", dir, hostname) cp, err := certProviderByCertMode("manual", dir, hostname, "", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -169,3 +169,37 @@ func TestPinnedCertRawIP(t *testing.T) {
} }
defer connClose.Close() defer connClose.Close()
} }
func TestGCPCertMode(t *testing.T) {
dir := t.TempDir()
// Missing EAB credentials
_, err := certProviderByCertMode("gcp", dir, "test.example.com", "", "")
if err == nil {
t.Fatal("expected error when EAB credentials are missing")
}
// Invalid base64
_, err = certProviderByCertMode("gcp", dir, "test.example.com", "kid", "not-valid!")
if err == nil {
t.Fatal("expected error for invalid base64")
}
// Valid base64url (no padding)
cp, err := certProviderByCertMode("gcp", dir, "test.example.com", "kid", "dGVzdC1rZXk")
if err != nil {
t.Fatalf("base64url: %v", err)
}
if cp == nil {
t.Fatal("base64url: nil certProvider")
}
// Valid standard base64 (with padding, gcloud format)
cp, err = certProviderByCertMode("gcp", dir, "test.example.com", "kid", "dGVzdC1rZXk=")
if err != nil {
t.Fatalf("base64: %v", err)
}
if cp == nil {
t.Fatal("base64: nil certProvider")
}
}

@ -171,7 +171,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/version from tailscale.com/cmd/derper+ tailscale.com/version from tailscale.com/cmd/derper+
tailscale.com/version/distro from tailscale.com/envknob+ tailscale.com/version/distro from tailscale.com/envknob+
tailscale.com/wgengine/filter/filtertype from tailscale.com/types/netmap tailscale.com/wgengine/filter/filtertype from tailscale.com/types/netmap
golang.org/x/crypto/acme from golang.org/x/crypto/acme/autocert golang.org/x/crypto/acme from golang.org/x/crypto/acme/autocert+
golang.org/x/crypto/acme/autocert from tailscale.com/cmd/derper golang.org/x/crypto/acme/autocert from tailscale.com/cmd/derper
golang.org/x/crypto/argon2 from tailscale.com/tka golang.org/x/crypto/argon2 from tailscale.com/tka
golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+ golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+

@ -60,9 +60,11 @@ var (
httpPort = flag.Int("http-port", 80, "The port on which to serve HTTP. Set to -1 to disable. The listener is bound to the same IP (if any) as specified in the -a flag.") httpPort = flag.Int("http-port", 80, "The port on which to serve HTTP. Set to -1 to disable. The listener is bound to the same IP (if any) as specified in the -a flag.")
stunPort = flag.Int("stun-port", 3478, "The UDP port on which to serve STUN. The listener is bound to the same IP (if any) as specified in the -a flag.") stunPort = flag.Int("stun-port", 3478, "The UDP port on which to serve STUN. The listener is bound to the same IP (if any) as specified in the -a flag.")
configPath = flag.String("c", "", "config file path") configPath = flag.String("c", "", "config file path")
certMode = flag.String("certmode", "letsencrypt", "mode for getting a cert. possible options: manual, letsencrypt") 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 LetsEncrypt certs, if addr's port is :443") 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", "LetsEncrypt host name, 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. When --certmode=manual, this can be an IP address to avoid SNI checks")
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)")
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.") 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.") 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") 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")
@ -343,7 +345,7 @@ func main() {
if serveTLS { if serveTLS {
log.Printf("derper: serving on %s with TLS", *addr) log.Printf("derper: serving on %s with TLS", *addr)
var certManager certProvider var certManager certProvider
certManager, err = certProviderByCertMode(*certMode, *certDir, *hostname) certManager, err = certProviderByCertMode(*certMode, *certDir, *hostname, *acmeEABKid, *acmeEABKey)
if err != nil { if err != nil {
log.Fatalf("derper: can not start cert provider: %v", err) log.Fatalf("derper: can not start cert provider: %v", err)
} }

Loading…
Cancel
Save