summaryrefslogtreecommitdiff
path: root/modules/caddytls/certmanagers.go
diff options
context:
space:
mode:
authorMatt Holt <mholt@users.noreply.github.com>2022-02-17 15:40:34 -0700
committerGitHub <noreply@github.com>2022-02-17 15:40:34 -0700
commit57a708d189cfe4ccc20ae92df95dd35e52a434a8 (patch)
tree6a8b2ded2d0d70cea75c7b1cc5bd536b7999e9d8 /modules/caddytls/certmanagers.go
parent32aad909380f08a40389a33bfe788c8a35b1d850 (diff)
caddytls: Support external certificate Managers (like Tailscale) (#4541)
Huge thank-you to Tailscale (https://tailscale.com) for making this change possible! This is a great feature for Caddy and Tailscale is a great fit for a standard implementation. * caddytls: GetCertificate modules; Tailscale * Caddyfile support for get_certificate Also fix AP provisioning in case of empty subject list (persist loaded module on struct, much like Issuers, to surive reprovisioning). And implement start of HTTP cert getter, still WIP. * Update modules/caddytls/automation.go Co-authored-by: Francis Lavoie <lavofr@gmail.com> * Use tsclient package, check status for name * Implement HTTP cert getter And use reuse CertMagic's PEM functions for private keys. * Remove cache option from Tailscale getter Tailscale does its own caching and we don't need the added complexity... for now, at least. * Several updates - Option to disable cert automation in auto HTTPS - Support multiple cert managers - Remove cache feature from cert manager modules - Minor improvements to auto HTTPS logging * Run go mod tidy * Try to get certificates from Tailscale implicitly Only for domains ending in .ts.net. I think this is really cool! Co-authored-by: Francis Lavoie <lavofr@gmail.com>
Diffstat (limited to 'modules/caddytls/certmanagers.go')
-rw-r--r--modules/caddytls/certmanagers.go208
1 files changed, 208 insertions, 0 deletions
diff --git a/modules/caddytls/certmanagers.go b/modules/caddytls/certmanagers.go
new file mode 100644
index 0000000..653e9f5
--- /dev/null
+++ b/modules/caddytls/certmanagers.go
@@ -0,0 +1,208 @@
+package caddytls
+
+import (
+ "context"
+ "crypto/tls"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/caddyserver/caddy/v2"
+ "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
+ "github.com/caddyserver/certmagic"
+ "github.com/tailscale/tscert"
+ "go.uber.org/zap"
+)
+
+func init() {
+ caddy.RegisterModule(Tailscale{})
+ caddy.RegisterModule(HTTPCertGetter{})
+}
+
+// Tailscale is a module that can get certificates from the local Tailscale process.
+type Tailscale struct {
+ // If true, this module will operate in "best-effort" mode and
+ // ignore "soft" errors; i.e. try Tailscale, and if it doesn't connect
+ // or return a certificate, oh well. Failure to connect to Tailscale
+ // results in a no-op instead of an error. Intended for the use case
+ // where this module is added implicitly for convenience, even if
+ // Tailscale isn't necessarily running.
+ Optional bool `json:"optional,omitempty"`
+
+ logger *zap.Logger
+}
+
+// CaddyModule returns the Caddy module information.
+func (Tailscale) CaddyModule() caddy.ModuleInfo {
+ return caddy.ModuleInfo{
+ ID: "tls.get_certificate.tailscale",
+ New: func() caddy.Module { return new(Tailscale) },
+ }
+}
+
+func (ts *Tailscale) Provision(ctx caddy.Context) error {
+ ts.logger = ctx.Logger(ts)
+ return nil
+}
+
+func (ts Tailscale) GetCertificate(ctx context.Context, hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
+ canGetCert, err := ts.canHazCertificate(ctx, hello)
+ if err == nil && !canGetCert {
+ return nil, nil // pass-thru: Tailscale can't offer a cert for this name
+ }
+ if err != nil {
+ ts.logger.Warn("could not get status; will try to get certificate anyway", zap.Error(err))
+ }
+ return tscert.GetCertificate(hello)
+}
+
+// canHazCertificate returns true if Tailscale reports it can get a certificate for the given ClientHello.
+func (ts Tailscale) canHazCertificate(ctx context.Context, hello *tls.ClientHelloInfo) (bool, error) {
+ if ts.Optional && !strings.HasSuffix(strings.ToLower(hello.ServerName), tailscaleDomainAliasEnding) {
+ return false, nil
+ }
+ status, err := tscert.GetStatus(ctx)
+ if err != nil {
+ if ts.Optional {
+ return false, nil // ignore error if we don't expect/require it to work anyway
+ }
+ return false, err
+ }
+ for _, domain := range status.CertDomains {
+ if certmagic.MatchWildcard(hello.ServerName, domain) {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+// UnmarshalCaddyfile deserializes Caddyfile tokens into ts.
+//
+// ... tailscale
+//
+func (Tailscale) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
+ for d.Next() {
+ if d.NextArg() {
+ return d.ArgErr()
+ }
+ }
+ return nil
+}
+
+// tailscaleDomainAliasEnding is the ending for all Tailscale custom domains.
+const tailscaleDomainAliasEnding = ".ts.net"
+
+// HTTPCertGetter can get a certificate via HTTP(S) request.
+type HTTPCertGetter struct {
+ // The URL from which to download the certificate. Required.
+ //
+ // The URL will be augmented with query string parameters taken
+ // from the TLS handshake:
+ //
+ // - server_name: The SNI value
+ // - signature_schemes: Comma-separated list of hex IDs of signatures
+ // - cipher_suites: Comma-separated list of hex IDs of cipher suites
+ //
+ // To be valid, the response must be HTTP 200 with a PEM body
+ // consisting of blocks for the certificate chain and the private
+ // key.
+ URL string `json:"url,omitempty"`
+
+ ctx context.Context
+}
+
+// CaddyModule returns the Caddy module information.
+func (hcg HTTPCertGetter) CaddyModule() caddy.ModuleInfo {
+ return caddy.ModuleInfo{
+ ID: "tls.get_certificate.http",
+ New: func() caddy.Module { return new(HTTPCertGetter) },
+ }
+}
+
+func (hcg *HTTPCertGetter) Provision(ctx caddy.Context) error {
+ hcg.ctx = ctx
+ if hcg.URL == "" {
+ return fmt.Errorf("URL is required")
+ }
+ return nil
+}
+
+func (hcg HTTPCertGetter) GetCertificate(ctx context.Context, hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
+ sigs := make([]string, len(hello.SignatureSchemes))
+ for i, sig := range hello.SignatureSchemes {
+ sigs[i] = fmt.Sprintf("%x", uint16(sig)) // you won't believe what %x uses if the val is a Stringer
+ }
+ suites := make([]string, len(hello.CipherSuites))
+ for i, cs := range hello.CipherSuites {
+ suites[i] = fmt.Sprintf("%x", cs)
+ }
+
+ parsed, err := url.Parse(hcg.URL)
+ if err != nil {
+ return nil, err
+ }
+ qs := parsed.Query()
+ qs.Set("server_name", hello.ServerName)
+ qs.Set("signature_schemes", strings.Join(sigs, ","))
+ qs.Set("cipher_suites", strings.Join(suites, ","))
+ parsed.RawQuery = qs.Encode()
+
+ req, err := http.NewRequestWithContext(hcg.ctx, http.MethodGet, parsed.String(), nil)
+ if err != nil {
+ return nil, err
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("got HTTP %d", resp.StatusCode)
+ }
+
+ bodyBytes, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("error reading response body: %v", err)
+ }
+
+ cert, err := tlsCertFromCertAndKeyPEMBundle(bodyBytes)
+ if err != nil {
+ return nil, err
+ }
+
+ return &cert, nil
+}
+
+// UnmarshalCaddyfile deserializes Caddyfile tokens into ts.
+//
+// ... http <url>
+//
+func (hcg *HTTPCertGetter) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
+ for d.Next() {
+ if !d.NextArg() {
+ return d.ArgErr()
+ }
+ hcg.URL = d.Val()
+ if d.NextArg() {
+ return d.ArgErr()
+ }
+ for nesting := d.Nesting(); d.NextBlock(nesting); {
+ return d.Err("block not allowed here")
+ }
+ }
+ return nil
+}
+
+// Interface guards
+var (
+ _ certmagic.CertificateManager = (*Tailscale)(nil)
+ _ caddy.Provisioner = (*Tailscale)(nil)
+ _ caddyfile.Unmarshaler = (*Tailscale)(nil)
+
+ _ certmagic.CertificateManager = (*HTTPCertGetter)(nil)
+ _ caddy.Provisioner = (*HTTPCertGetter)(nil)
+ _ caddyfile.Unmarshaler = (*HTTPCertGetter)(nil)
+)