Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

caddytls: Support custom GetCertificate modules (like Tailscale) #4541

Merged
merged 10 commits into from Feb 17, 2022
24 changes: 24 additions & 0 deletions caddyconfig/httpcaddyfile/builtins.go
Expand Up @@ -82,6 +82,7 @@ func parseBind(h Helper) ([]ConfigValue, error) {
// on_demand
// eab <key_id> <mac_key>
// issuer <module_name> [...]
// get_certificate <module_name> [...]
// }
//
func parseTLS(h Helper) ([]ConfigValue, error) {
Expand All @@ -93,6 +94,7 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
var keyType string
var internalIssuer *caddytls.InternalIssuer
var issuers []certmagic.Issuer
var certGetter certmagic.CertificateGetter
var onDemand bool

for h.Next() {
Expand Down Expand Up @@ -307,6 +309,22 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
}
issuers = append(issuers, issuer)

case "get_certificate":
if !h.NextArg() {
return nil, h.ArgErr()
}
modName := h.Val()
modID := "tls.get_certificate." + modName
unm, err := caddyfile.UnmarshalModule(h.Dispenser, modID)
if err != nil {
return nil, err
}
var ok bool
certGetter, ok = unm.(certmagic.CertificateGetter)
if !ok {
return nil, h.Errf("module %s (%T) is not a certmagic.CertificateGetter", modID, unm)
}

case "dns":
if !h.NextArg() {
return nil, h.ArgErr()
Expand Down Expand Up @@ -453,6 +471,12 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
Value: true,
})
}
if certGetter != nil {
configVals = append(configVals, ConfigValue{
Class: "tls.cert_getter",
Value: certGetter,
})
}

// custom certificate selection
if len(certSelector.AnyTag) > 0 {
Expand Down
5 changes: 5 additions & 0 deletions caddyconfig/httpcaddyfile/tlsapp.go
Expand Up @@ -116,6 +116,11 @@ func (st ServerType) buildTLSApp(
if _, ok := sblock.pile["tls.on_demand"]; ok {
ap.OnDemand = true
}
if certGetterVals, ok := sblock.pile["tls.cert_getter"]; ok {
certGetter := certGetterVals[0].Value.(certmagic.CertificateGetter)
certGetterName := certGetter.(caddy.Module).CaddyModule().ID.Name()
ap.GetCertificateRaw = caddyconfig.JSONModuleObject(certGetter, "via", certGetterName, &warnings)
}

if keyTypeVals, ok := sblock.pile["tls.key_type"]; ok {
ap.KeyType = keyTypeVals[0].Value.(string)
Expand Down
12 changes: 7 additions & 5 deletions go.mod
Expand Up @@ -3,10 +3,11 @@ module github.com/caddyserver/caddy/v2
go 1.16

require (
cloud.google.com/go/kms v1.1.0 // indirect
github.com/Masterminds/sprig/v3 v3.2.2
github.com/alecthomas/chroma v0.9.2
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b
github.com/caddyserver/certmagic v0.15.2
github.com/caddyserver/certmagic v0.15.3-0.20220124232840-098d54f87dd3
github.com/dustin/go-humanize v1.0.1-0.20200219035652-afde56e7acac
github.com/go-chi/chi v4.1.2+incompatible
github.com/google/cel-go v0.7.3
Expand All @@ -25,11 +26,12 @@ require (
github.com/yuin/goldmark v1.4.1
github.com/yuin/goldmark-highlighting v0.0.0-20210516132338-9216f9c5aa01
go.uber.org/zap v1.19.0
golang.org/x/crypto v0.0.0-20210915214749-c084706c2272
golang.org/x/net v0.0.0-20210913180222-943fd674d43e
golang.org/x/term v0.0.0-20210503060354-a79de5458b56
google.golang.org/genproto v0.0.0-20210719143636-1d5a45f8e492
golang.org/x/crypto v0.0.0-20211202192323-5770296d904e
golang.org/x/net v0.0.0-20211205041911-012df41ee64c
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
google.golang.org/genproto v0.0.0-20211018162055-cf77aa76bad2
google.golang.org/protobuf v1.27.1
gopkg.in/natefinch/lumberjack.v2 v2.0.0
gopkg.in/yaml.v2 v2.4.0
tailscale.com v1.20.1
)
734 changes: 707 additions & 27 deletions go.sum

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions modules/caddyhttp/autohttps.go
Expand Up @@ -482,8 +482,8 @@ func (app *App) createAutomationPolicies(ctx caddy.Context, internalNames []stri
}
}
if baseACMEIssuer == nil {
// note that this happens if basePolicy.Issuer is nil
// OR if it is not nil but is not an ACMEIssuer
// note that this happens if basePolicy.Issuers is empty
// OR if it is not empty but does not have not an ACMEIssuer
baseACMEIssuer = new(caddytls.ACMEIssuer)
}

Expand Down
44 changes: 39 additions & 5 deletions modules/caddytls/automation.go
Expand Up @@ -89,6 +89,14 @@ type AutomationPolicy struct {
// zerossl.
IssuersRaw []json.RawMessage `json:"issuers,omitempty" caddy:"namespace=tls.issuance inline_key=module"`

// A module that can get a custom certificate to use for any
// given TLS handshake at handshake-time. Custom certificates
// can be useful if another entity is managing certificates
// and Caddy need only get it and serve it. Setting this field
// implicitly enables On-Demand TLS (as if `on_demand: true`).
// TODO: This is an EXPERIMENTAL feature. It is subject to change or removal.
GetCertificateRaw json.RawMessage `json:"get_certificate,omitempty" caddy:"namespace=tls.get_certificate inline_key=via"`

// If true, certificates will be requested with MustStaple. Not all
// CAs support this, and there are potentially serious consequences
// of enabling this feature without proper threat modeling.
Expand All @@ -113,7 +121,7 @@ type AutomationPolicy struct {

// If true, certificates will be managed "on demand"; that is, during
// TLS handshakes or when needed, as opposed to at startup or config
// load.
// load. This enable On-Demand TLS for this policy.
mholt marked this conversation as resolved.
Show resolved Hide resolved
OnDemand bool `json:"on_demand,omitempty"`

// Disables OCSP stapling. Disabling OCSP stapling puts clients at
Expand All @@ -129,10 +137,12 @@ type AutomationPolicy struct {
// EXPERIMENTAL. Subject to change.
OCSPOverrides map[string]string `json:"ocsp_overrides,omitempty"`

// Issuers stores the decoded issuer parameters. This is only
// used to populate an underlying certmagic.Config's Issuers
// field; it is not referenced thereafter.
Issuers []certmagic.Issuer `json:"-"`
// Issuers and GetCertificate store the decoded issuer and
// certificate getter modules; they are only used to populate
// an underlying certmagic.Config's fields during provisioning,
// so that the modules could survive a re-provisioning.
Issuers []certmagic.Issuer `json:"-"`
GetCertificate certmagic.CertificateGetter `json:"-"`

magic *certmagic.Config
storage certmagic.Storage
Expand All @@ -153,6 +163,7 @@ func (ap *AutomationPolicy) Provision(tlsApp *TLS) error {
ap.storage = cmStorage
}

// handle explicitly-enabled on-demand TLS
var ond *certmagic.OnDemandConfig
if ap.OnDemand {
ond = &certmagic.OnDemandConfig{
Expand All @@ -176,6 +187,29 @@ func (ap *AutomationPolicy) Provision(tlsApp *TLS) error {
}
}

// we don't store loaded modules directly in the certmagic config since
// policy provisioning may happen more than once (during auto-HTTPS) and
// loading a module clears its config bytes; thus, load the module and
// store them on the policy before putting it on the config

// load and provision any cert-getter module
if ap.GetCertificateRaw != nil {
certGetterModule, err := tlsApp.ctx.LoadModule(ap, "GetCertificateRaw")
if err != nil {
return fmt.Errorf("loading custom certificate getter: %v", err)
}
ap.GetCertificate = certGetterModule.(certmagic.CertificateGetter)
}

// if a cert-getter is configured, this also enables on-demand TLS
if ap.GetCertificate != nil {
if ond == nil {
ond = new(certmagic.OnDemandConfig)
}
ond.CustomGetCertificate = ap.GetCertificate
ap.OnDemand = true
}

// load and provision any explicitly-configured issuer modules
if ap.IssuersRaw != nil {
val, err := tlsApp.ctx.LoadModule(ap, "IssuersRaw")
Expand Down
169 changes: 169 additions & 0 deletions modules/caddytls/certgetters.go
@@ -0,0 +1,169 @@
package caddytls

import (
"context"
"crypto/tls"
"fmt"
"net/http"
"net/url"
"strings"

"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/certmagic"
"tailscale.com/client/tailscale"
)

func init() {
caddy.RegisterModule(Tailscale{})
caddy.RegisterModule(HTTPCertGetter{})
}

// Tailscale is a module that can get certificates from the local Tailscale process.
type Tailscale struct {
// Whether to cache returned certificates in Caddy's in-memory certificate cache.
// If true, Tailscale will only be asked for a certificate if it does not already
// exist in Caddy's cache, or if it is nearing expiration.
Cache bool `json:"cache,omitempty"`
}

// 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) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, bool, error) {
cert, err := tailscale.GetCertificate(hello)
return cert, ts.Cache, err
}

// UnmarshalCaddyfile deserializes Caddyfile tokens into ts.
//
// ... tailscale {
// cache
// }
//
func (ts *Tailscale) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
for d.Next() {
if d.NextArg() {
return d.ArgErr()
}
for nesting := d.Nesting(); d.NextBlock(nesting); {
switch d.Val() {
case "cache":
if ts.Cache {
return d.Errf("caching is already enabled")
}
if d.NextArg() {
return d.ArgErr()
}
ts.Cache = true
default:
return d.Errf("unrecognized tailscale property: %s", d.Val())
}
}
}
return nil
}

// 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 ... TODO: body format?
mholt marked this conversation as resolved.
Show resolved Hide resolved
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(hello *tls.ClientHelloInfo) (*tls.Certificate, bool, error) {
sigs := make([]string, len(hello.SignatureSchemes))
for i, sig := range hello.SignatureSchemes {
sigs[i] = fmt.Sprintf("%x", uint16(sig))
}
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, false, 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, false, err
}

resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, false, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, false, fmt.Errorf("got HTTP %d", resp.StatusCode)
}

return nil, false, fmt.Errorf("TODO: not implemented")
}

// 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.CertificateGetter = (*Tailscale)(nil)
_ caddyfile.Unmarshaler = (*Tailscale)(nil)

_ certmagic.CertificateGetter = (*HTTPCertGetter)(nil)
_ caddyfile.Unmarshaler = (*HTTPCertGetter)(nil)
)