Skip to content

Commit 8eda947

Browse files
cmd/derper: add GCP Certificate Manager support (tailscale#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>
1 parent 1dfdee8 commit 8eda947

4 files changed

Lines changed: 76 additions & 8 deletions

File tree

cmd/derper/cert.go

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"crypto/tls"
1212
"crypto/x509"
1313
"crypto/x509/pkix"
14+
"encoding/base64"
1415
"encoding/json"
1516
"encoding/pem"
1617
"errors"
@@ -24,6 +25,7 @@ import (
2425
"regexp"
2526
"time"
2627

28+
"golang.org/x/crypto/acme"
2729
"golang.org/x/crypto/acme/autocert"
2830
"tailscale.com/tailcfg"
2931
)
@@ -42,17 +44,33 @@ type certProvider interface {
4244
HTTPHandler(fallback http.Handler) http.Handler
4345
}
4446

45-
func certProviderByCertMode(mode, dir, hostname string) (certProvider, error) {
47+
func certProviderByCertMode(mode, dir, hostname, eabKID, eabKey string) (certProvider, error) {
4648
if dir == "" {
4749
return nil, errors.New("missing required --certdir flag")
4850
}
4951
switch mode {
50-
case "letsencrypt":
52+
case "letsencrypt", "gcp":
5153
certManager := &autocert.Manager{
5254
Prompt: autocert.AcceptTOS,
5355
HostPolicy: autocert.HostWhitelist(hostname),
5456
Cache: autocert.DirCache(dir),
5557
}
58+
if mode == "gcp" {
59+
if eabKID == "" || eabKey == "" {
60+
return nil, errors.New("--certmode=gcp requires --acme-eab-kid and --acme-eab-key flags")
61+
}
62+
keyBytes, err := decodeEABKey(eabKey)
63+
if err != nil {
64+
return nil, err
65+
}
66+
certManager.Client = &acme.Client{
67+
DirectoryURL: "https://dv.acme-v02.api.pki.goog/directory",
68+
}
69+
certManager.ExternalAccountBinding = &acme.ExternalAccountBinding{
70+
KID: eabKID,
71+
Key: keyBytes,
72+
}
73+
}
5674
if hostname == "derp.tailscale.com" {
5775
certManager.HostPolicy = prodAutocertHostPolicy
5876
certManager.Email = "security@tailscale.com"
@@ -209,3 +227,17 @@ func createSelfSignedIPCert(crtPath, keyPath, ipStr string) (*tls.Certificate, e
209227
}
210228
return &tlsCert, nil
211229
}
230+
231+
// decodeEABKey decodes a base64-encoded EAB key.
232+
// It accepts both standard base64 (with padding) and base64url (without padding).
233+
func decodeEABKey(s string) ([]byte, error) {
234+
// Try base64url first (no padding), then standard base64 (with padding).
235+
// This handles both ACME spec format and gcloud output format.
236+
if b, err := base64.RawURLEncoding.DecodeString(s); err == nil {
237+
return b, nil
238+
}
239+
if b, err := base64.StdEncoding.DecodeString(s); err == nil {
240+
return b, nil
241+
}
242+
return nil, errors.New("invalid base64 encoding for EAB key")
243+
}

cmd/derper/cert_test.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ func TestCertIP(t *testing.T) {
9191
t.Fatalf("Error closing key.pem: %v", err)
9292
}
9393

94-
cp, err := certProviderByCertMode("manual", dir, hostname)
94+
cp, err := certProviderByCertMode("manual", dir, hostname, "", "")
9595
if err != nil {
9696
t.Fatal(err)
9797
}
@@ -169,3 +169,37 @@ func TestPinnedCertRawIP(t *testing.T) {
169169
}
170170
defer connClose.Close()
171171
}
172+
173+
func TestGCPCertMode(t *testing.T) {
174+
dir := t.TempDir()
175+
176+
// Missing EAB credentials
177+
_, err := certProviderByCertMode("gcp", dir, "test.example.com", "", "")
178+
if err == nil {
179+
t.Fatal("expected error when EAB credentials are missing")
180+
}
181+
182+
// Invalid base64
183+
_, err = certProviderByCertMode("gcp", dir, "test.example.com", "kid", "not-valid!")
184+
if err == nil {
185+
t.Fatal("expected error for invalid base64")
186+
}
187+
188+
// Valid base64url (no padding)
189+
cp, err := certProviderByCertMode("gcp", dir, "test.example.com", "kid", "dGVzdC1rZXk")
190+
if err != nil {
191+
t.Fatalf("base64url: %v", err)
192+
}
193+
if cp == nil {
194+
t.Fatal("base64url: nil certProvider")
195+
}
196+
197+
// Valid standard base64 (with padding, gcloud format)
198+
cp, err = certProviderByCertMode("gcp", dir, "test.example.com", "kid", "dGVzdC1rZXk=")
199+
if err != nil {
200+
t.Fatalf("base64: %v", err)
201+
}
202+
if cp == nil {
203+
t.Fatal("base64: nil certProvider")
204+
}
205+
}

cmd/derper/depaware.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
171171
tailscale.com/version from tailscale.com/cmd/derper+
172172
tailscale.com/version/distro from tailscale.com/envknob+
173173
tailscale.com/wgengine/filter/filtertype from tailscale.com/types/netmap
174-
golang.org/x/crypto/acme from golang.org/x/crypto/acme/autocert
174+
golang.org/x/crypto/acme from golang.org/x/crypto/acme/autocert+
175175
golang.org/x/crypto/acme/autocert from tailscale.com/cmd/derper
176176
golang.org/x/crypto/argon2 from tailscale.com/tka
177177
golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+

cmd/derper/derper.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,11 @@ var (
6060
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.")
6161
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.")
6262
configPath = flag.String("c", "", "config file path")
63-
certMode = flag.String("certmode", "letsencrypt", "mode for getting a cert. possible options: manual, letsencrypt")
64-
certDir = flag.String("certdir", tsweb.DefaultCertDir("derper-certs"), "directory to store LetsEncrypt certs, if addr's port is :443")
65-
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")
63+
certMode = flag.String("certmode", "letsencrypt", "mode for getting a cert. possible options: manual, letsencrypt, gcp")
64+
certDir = flag.String("certdir", tsweb.DefaultCertDir("derper-certs"), "directory to store ACME (e.g. LetsEncrypt) certs, if addr's port is :443")
65+
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")
66+
acmeEABKid = flag.String("acme-eab-kid", "", "ACME External Account Binding (EAB) Key ID (required for --certmode=gcp)")
67+
acmeEABKey = flag.String("acme-eab-key", "", "ACME External Account Binding (EAB) HMAC key, base64-encoded (required for --certmode=gcp)")
6668
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.")
6769
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.")
6870
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() {
343345
if serveTLS {
344346
log.Printf("derper: serving on %s with TLS", *addr)
345347
var certManager certProvider
346-
certManager, err = certProviderByCertMode(*certMode, *certDir, *hostname)
348+
certManager, err = certProviderByCertMode(*certMode, *certDir, *hostname, *acmeEABKid, *acmeEABKey)
347349
if err != nil {
348350
log.Fatalf("derper: can not start cert provider: %v", err)
349351
}

0 commit comments

Comments
 (0)