diff options
Diffstat (limited to 'modules/caddytls/certmanagers.go')
-rw-r--r-- | modules/caddytls/certmanagers.go | 208 |
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) +) |