diff --git a/cmd/derper/cert.go b/cmd/derper/cert.go new file mode 100644 index 000000000..dd71c3af2 --- /dev/null +++ b/cmd/derper/cert.go @@ -0,0 +1,95 @@ +// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package main + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "net/http" + "path/filepath" + "regexp" + + "golang.org/x/crypto/acme/autocert" +) + +var unsafeHostnameCharacters = regexp.MustCompile(`[^a-zA-Z0-9-\.]`) + +type certProvider interface { + // TLSConfig creates a new TLS config suitable for net/http.Server servers. + TLSConfig() *tls.Config + // HTTPHandler handle ACME related request, if any. + HTTPHandler(fallback http.Handler) http.Handler +} + +func certProviderByCertMode(mode, dir, hostname string) (certProvider, error) { + if dir == "" { + return nil, errors.New("missing required --certdir flag") + } + switch mode { + case "letsencrypt": + certManager := &autocert.Manager{ + Prompt: autocert.AcceptTOS, + HostPolicy: autocert.HostWhitelist(hostname), + Cache: autocert.DirCache(dir), + } + if hostname == "derp.tailscale.com" { + certManager.HostPolicy = prodAutocertHostPolicy + certManager.Email = "security@tailscale.com" + } + return certManager, nil + case "manual": + return NewManualCertManager(dir, hostname) + default: + return nil, fmt.Errorf("unsupport cert mode: %q", mode) + } +} + +type manualCertManager struct { + cert *tls.Certificate + hostname string +} + +// NewManualCertManager returns a cert provider which read certificate by given hostname on create. +func NewManualCertManager(certdir, hostname string) (certProvider, error) { + keyname := unsafeHostnameCharacters.ReplaceAllString(hostname, "") + crtPath := filepath.Join(certdir, keyname+".crt") + keyPath := filepath.Join(certdir, keyname+".key") + cert, err := tls.LoadX509KeyPair(crtPath, keyPath) + if err != nil { + return nil, fmt.Errorf("can not load x509 key pair for hostname %q: %w", keyname, err) + } + // ensure hostname matches with the certificate + x509Cert, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + return nil, fmt.Errorf("can not load cert: %w", err) + } + if x509Cert.VerifyHostname(hostname) != nil { + return nil, errors.New("refuse to load cert: hostname mismatch with key") + } + return &manualCertManager{cert: &cert, hostname: hostname}, nil +} + +func (m *manualCertManager) TLSConfig() *tls.Config { + return &tls.Config{ + Certificates: nil, + NextProtos: []string{ + "h2", "http/1.1", // enable HTTP/2 + }, + GetCertificate: m.getCertificate, + } +} + +func (m *manualCertManager) getCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) { + if hi.ServerName != m.hostname { + return nil, fmt.Errorf("cert mismatch with hostname: %q", hi.ServerName) + } + return m.cert, nil +} + +func (m *manualCertManager) HTTPHandler(fallback http.Handler) http.Handler { + return fallback +} diff --git a/cmd/derper/derper.go b/cmd/derper/derper.go index b7c83098f..a1d9b132c 100644 --- a/cmd/derper/derper.go +++ b/cmd/derper/derper.go @@ -23,7 +23,6 @@ "strings" "time" - "golang.org/x/crypto/acme/autocert" "tailscale.com/atomicfile" "tailscale.com/derp" "tailscale.com/derp/derphttp" @@ -39,6 +38,7 @@ dev = flag.Bool("dev", false, "run in localhost development mode") addr = flag.String("a", ":443", "server address") configPath = flag.String("c", "", "config file path") + certMode = flag.String("certmode", "letsencrypt", "mode for getting a cert. possible options: manual, letsencrypt") certDir = flag.String("certdir", tsweb.DefaultCertDir("derper-certs"), "directory to store LetsEncrypt certs, if addr's port is :443") hostname = flag.String("hostname", "derp.tailscale.com", "LetsEncrypt host name, if addr's port is :443") logCollection = flag.String("logcollection", "", "If non-empty, logtail collection to log to") @@ -130,7 +130,7 @@ func main() { cfg := loadConfig() - letsEncrypt := tsweb.IsProd443(*addr) + serveTLS := tsweb.IsProd443(*addr) s := derp.NewServer(key.Private(cfg.PrivateKey), log.Printf) s.SetVerifyClient(*verifyClients) @@ -204,24 +204,16 @@ func main() { WriteTimeout: 30 * time.Second, } - if letsEncrypt { - if *certDir == "" { - log.Fatalf("missing required --certdir flag") - } + if serveTLS { log.Printf("derper: serving on %s with TLS", *addr) - certManager := &autocert.Manager{ - Prompt: autocert.AcceptTOS, - HostPolicy: autocert.HostWhitelist(*hostname), - Cache: autocert.DirCache(*certDir), - } - if *hostname == "derp.tailscale.com" { - certManager.HostPolicy = prodAutocertHostPolicy - certManager.Email = "security@tailscale.com" + certManager, err := certProviderByCertMode(*certMode, *certDir, *hostname) + if err != nil { + log.Fatalf("derper: can not start cert provider: %v", err) } httpsrv.TLSConfig = certManager.TLSConfig() - letsEncryptGetCert := httpsrv.TLSConfig.GetCertificate + getCert := httpsrv.TLSConfig.GetCertificate httpsrv.TLSConfig.GetCertificate = func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) { - cert, err := letsEncryptGetCert(hi) + cert, err := getCert(hi) if err != nil { return nil, err }