mirror of
https://github.com/pomerium/pomerium.git
synced 2025-08-04 01:09:36 +02:00
envoy: support autocert (#695)
* envoy: support autocert * envoy: fallback to http host routing if sni fails to match * update comment * envoy: renew certs when necessary * fix tests
This commit is contained in:
parent
0c1ac5a575
commit
dccec1e646
18 changed files with 689 additions and 391 deletions
102
config/autocert.go
Normal file
102
config/autocert.go
Normal file
|
@ -0,0 +1,102 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/caddyserver/certmagic"
|
||||
|
||||
"github.com/pomerium/pomerium/internal/log"
|
||||
)
|
||||
|
||||
// AutocertManager manages Let's Encrypt certificates based on configuration options.
|
||||
var AutocertManager = newAutocertManager()
|
||||
|
||||
type autocertManager struct {
|
||||
mu sync.RWMutex
|
||||
certmagic *certmagic.Config
|
||||
acmeMgr *certmagic.ACMEManager
|
||||
}
|
||||
|
||||
func newAutocertManager() *autocertManager {
|
||||
mgr := &autocertManager{}
|
||||
return mgr
|
||||
}
|
||||
|
||||
func (mgr *autocertManager) getConfig(options *Options) (*certmagic.Config, error) {
|
||||
mgr.mu.Lock()
|
||||
defer mgr.mu.Unlock()
|
||||
|
||||
cm := mgr.certmagic
|
||||
if cm == nil {
|
||||
cm = certmagic.NewDefault()
|
||||
}
|
||||
|
||||
cm.OnDemand = nil // disable on-demand
|
||||
cm.Storage = &certmagic.FileStorage{Path: options.AutoCertFolder}
|
||||
// add existing certs to the cache, and staple OCSP
|
||||
for _, cert := range options.Certificates {
|
||||
if err := cm.CacheUnmanagedTLSCertificate(cert, nil); err != nil {
|
||||
return nil, fmt.Errorf("config: failed caching cert: %w", err)
|
||||
}
|
||||
}
|
||||
acmeMgr := certmagic.NewACMEManager(cm, certmagic.DefaultACME)
|
||||
acmeMgr.Agreed = true
|
||||
if options.AutoCertUseStaging {
|
||||
acmeMgr.CA = certmagic.LetsEncryptStagingCA
|
||||
}
|
||||
acmeMgr.DisableTLSALPNChallenge = true
|
||||
cm.Issuer = acmeMgr
|
||||
mgr.acmeMgr = acmeMgr
|
||||
|
||||
return cm, nil
|
||||
}
|
||||
|
||||
func (mgr *autocertManager) update(options *Options) error {
|
||||
if !options.AutoCert {
|
||||
return nil
|
||||
}
|
||||
|
||||
cm, err := mgr.getConfig(options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, domain := range options.sourceHostnames() {
|
||||
cert, err := cm.CacheManagedCertificate(domain)
|
||||
if err != nil {
|
||||
log.Info().Str("domain", domain).Msg("obtaining certificate")
|
||||
err = cm.ObtainCert(context.Background(), domain, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: failed to obtain client certificate: %w", err)
|
||||
}
|
||||
cert, err = cm.CacheManagedCertificate(domain)
|
||||
}
|
||||
if err == nil && cert.NeedsRenewal(cm) {
|
||||
log.Info().Str("domain", domain).Msg("renewing certificate")
|
||||
err = cm.RenewCert(context.Background(), domain, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: failed to renew client certificate: %w", err)
|
||||
}
|
||||
cert, err = cm.CacheManagedCertificate(domain)
|
||||
}
|
||||
if err == nil {
|
||||
options.Certificates = append(options.Certificates, cert.Certificate)
|
||||
} else {
|
||||
log.Error().Err(err).Msg("config: failed to obtain client certificate")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mgr *autocertManager) HandleHTTPChallenge(w http.ResponseWriter, r *http.Request) bool {
|
||||
mgr.mu.RLock()
|
||||
acmeMgr := mgr.acmeMgr
|
||||
mgr.mu.RUnlock()
|
||||
if acmeMgr == nil {
|
||||
return false
|
||||
}
|
||||
return acmeMgr.HandleHTTPChallenge(w, r)
|
||||
}
|
|
@ -1,15 +1,16 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
@ -68,10 +69,6 @@ type Options struct {
|
|||
// and renewal from LetsEncrypt. Must be used in conjunction with AutoCertFolder.
|
||||
AutoCert bool `mapstructure:"autocert" yaml:"autocert,omitempty"`
|
||||
|
||||
// AutoCertHandler is the HTTP challenge handler used in a http-01 acme
|
||||
// https://letsencrypt.org/docs/challenge-types/#http-01-challenge
|
||||
AutoCertHandler func(h http.Handler) http.Handler `hash:"ignore"`
|
||||
|
||||
// AutoCertFolder specifies the location to store, and load autocert managed
|
||||
// TLS certificates.
|
||||
// defaults to $XDG_DATA_HOME/pomerium
|
||||
|
@ -83,7 +80,7 @@ type Options struct {
|
|||
// https://letsencrypt.org/docs/staging-environment/
|
||||
AutoCertUseStaging bool `mapstructure:"autocert_use_staging" yaml:"autocert_use_staging,omitempty"`
|
||||
|
||||
Certificates []certificateFilePair `mapstructure:"certificates" yaml:"certificates,omitempty"`
|
||||
CertificateFiles []certificateFilePair `mapstructure:"certificates" yaml:"certificates,omitempty"`
|
||||
|
||||
// Cert and Key is the x509 certificate used to create the HTTPS server.
|
||||
Cert string `mapstructure:"certificate" yaml:"certificate,omitempty"`
|
||||
|
@ -93,7 +90,7 @@ type Options struct {
|
|||
CertFile string `mapstructure:"certificate_file" yaml:"certificate_file,omitempty"`
|
||||
KeyFile string `mapstructure:"certificate_key_file" yaml:"certificate_key_file,omitempty"`
|
||||
|
||||
TLSConfig *tls.Config `hash:"ignore"`
|
||||
Certificates []tls.Certificate `yaml:"-"`
|
||||
|
||||
// HttpRedirectAddr, if set, specifies the host and port to run the HTTP
|
||||
// to HTTPS redirect server on. If empty, no redirect server is started.
|
||||
|
@ -534,38 +531,42 @@ func (o *Options) Validate() error {
|
|||
}
|
||||
|
||||
if o.Cert != "" || o.Key != "" {
|
||||
o.TLSConfig, err = cryptutil.TLSConfigFromBase64(o.TLSConfig, o.Cert, o.Key)
|
||||
cert, err := cryptutil.CertificateFromBase64(o.Cert, o.Key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: bad cert base64 %w", err)
|
||||
}
|
||||
o.Certificates = append(o.Certificates, *cert)
|
||||
}
|
||||
|
||||
if len(o.Certificates) != 0 {
|
||||
for _, c := range o.Certificates {
|
||||
o.TLSConfig, err = cryptutil.TLSConfigFromFile(o.TLSConfig, c.CertFile, c.KeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: bad cert file %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if o.CertFile != "" || o.KeyFile != "" {
|
||||
o.TLSConfig, err = cryptutil.TLSConfigFromFile(o.TLSConfig, o.CertFile, o.KeyFile)
|
||||
for _, c := range o.CertificateFiles {
|
||||
cert, err := cryptutil.CertificateFromFile(c.CertFile, c.KeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: bad cert file %w", err)
|
||||
}
|
||||
o.Certificates = append(o.Certificates, *cert)
|
||||
}
|
||||
if o.AutoCert {
|
||||
o.TLSConfig, o.AutoCertHandler, err = cryptutil.NewAutocert(
|
||||
o.TLSConfig,
|
||||
o.sourceHostnames(),
|
||||
o.AutoCertUseStaging,
|
||||
o.AutoCertFolder)
|
||||
|
||||
if o.CertFile != "" || o.KeyFile != "" {
|
||||
cert, err := cryptutil.CertificateFromFile(o.CertFile, o.KeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: autocert failed %w", err)
|
||||
return fmt.Errorf("config: bad cert file %w", err)
|
||||
}
|
||||
o.Certificates = append(o.Certificates, *cert)
|
||||
}
|
||||
if !o.InsecureServer && o.TLSConfig == nil {
|
||||
|
||||
RedirectAndAutocertServer.update(o)
|
||||
|
||||
err = AutocertManager.update(o)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: failed to setup autocert: %w", err)
|
||||
}
|
||||
|
||||
// sort the certificates so we get a consistent hash
|
||||
sort.Slice(o.Certificates, func(i, j int) bool {
|
||||
return compareByteSliceSlice(o.Certificates[i].Certificate, o.Certificates[j].Certificate) < 0
|
||||
})
|
||||
|
||||
if !o.InsecureServer && len(o.Certificates) == 0 {
|
||||
return fmt.Errorf("config: server must be run with `autocert`, " +
|
||||
"`insecure_server` or manually provided certificates to start")
|
||||
}
|
||||
|
@ -576,13 +577,21 @@ func (o *Options) sourceHostnames() []string {
|
|||
if len(o.Policies) == 0 {
|
||||
return nil
|
||||
}
|
||||
var h []string
|
||||
|
||||
dedupe := map[string]struct{}{}
|
||||
for _, p := range o.Policies {
|
||||
h = append(h, p.Source.Hostname())
|
||||
dedupe[p.Source.Hostname()] = struct{}{}
|
||||
}
|
||||
if o.AuthenticateURL != nil {
|
||||
h = append(h, o.AuthenticateURL.Hostname())
|
||||
dedupe[o.AuthenticateURL.Hostname()] = struct{}{}
|
||||
}
|
||||
|
||||
var h []string
|
||||
for k := range dedupe {
|
||||
h = append(h, k)
|
||||
}
|
||||
sort.Strings(h)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
|
@ -601,10 +610,37 @@ func (o *Options) Checksum() uint64 {
|
|||
return hash
|
||||
}
|
||||
|
||||
// HandleConfigUpdate takes configuration file, an existing options struct, and
|
||||
// WatchChanges takes a configuration file, an existing options struct, and
|
||||
// updates each service in the services slice OptionsUpdater with a new set
|
||||
// of options if any change is detected. It also periodically rechecks if
|
||||
// any computed properties have changed.
|
||||
func WatchChanges(configFile string, opt *Options, services []OptionsUpdater) {
|
||||
onchange := make(chan struct{}, 1)
|
||||
ticker := time.NewTicker(10 * time.Minute) // force check every 10 minutes
|
||||
defer ticker.Stop()
|
||||
|
||||
opt.OnConfigChange(func(fs fsnotify.Event) {
|
||||
log.Info().Str("file", fs.Name).Msg("config: file changed")
|
||||
select {
|
||||
case onchange <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-onchange:
|
||||
case <-ticker.C:
|
||||
}
|
||||
|
||||
opt = handleConfigUpdate(configFile, opt, services)
|
||||
}
|
||||
}
|
||||
|
||||
// handleConfigUpdate takes configuration file, an existing options struct, and
|
||||
// updates each service in the services slice OptionsUpdater with a new set of
|
||||
// options if any change is detected.
|
||||
func HandleConfigUpdate(configFile string, opt *Options, services []OptionsUpdater) *Options {
|
||||
func handleConfigUpdate(configFile string, opt *Options, services []OptionsUpdater) *Options {
|
||||
newOpt, err := NewOptionsFromConfig(configFile)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("config: could not reload configuration")
|
||||
|
@ -648,3 +684,31 @@ func dataDir() string {
|
|||
}
|
||||
return filepath.Join(baseDir, "pomerium")
|
||||
}
|
||||
|
||||
func compareByteSliceSlice(a, b [][]byte) int {
|
||||
sz := min(len(a), len(b))
|
||||
for i := 0; i < sz; i++ {
|
||||
switch bytes.Compare(a[i], b[i]) {
|
||||
case -1:
|
||||
return -1
|
||||
case 1:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(a) < len(b):
|
||||
return -1
|
||||
case len(b) < len(a):
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func min(x, y int) int {
|
||||
if x < y {
|
||||
return x
|
||||
}
|
||||
return y
|
||||
}
|
||||
|
|
|
@ -416,7 +416,7 @@ func Test_HandleConfigUpdate(t *testing.T) {
|
|||
os.Setenv(k, v)
|
||||
defer os.Unsetenv(k)
|
||||
}
|
||||
HandleConfigUpdate("", oldOpts, []OptionsUpdater{tt.service})
|
||||
handleConfigUpdate("", oldOpts, []OptionsUpdater{tt.service})
|
||||
if tt.service.Updated != tt.wantUpdate {
|
||||
t.Errorf("Failed to update config on service")
|
||||
}
|
||||
|
@ -441,7 +441,7 @@ func TestOptions_sourceHostnames(t *testing.T) {
|
|||
}{
|
||||
{"empty", []Policy{}, "", nil},
|
||||
{"good no authN", []Policy{{From: "https://from.example", To: "https://to.example"}}, "", []string{"from.example"}},
|
||||
{"good with authN", []Policy{{From: "https://from.example", To: "https://to.example"}}, "https://authn.example.com", []string{"from.example", "authn.example.com"}},
|
||||
{"good with authN", []Policy{{From: "https://from.example", To: "https://to.example"}}, "https://authn.example.com", []string{"authn.example.com", "from.example"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
@ -459,3 +459,66 @@ func TestOptions_sourceHostnames(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareByteSliceSlice(t *testing.T) {
|
||||
type Bytes = [][]byte
|
||||
|
||||
tests := []struct {
|
||||
expect int
|
||||
a Bytes
|
||||
b Bytes
|
||||
}{
|
||||
{
|
||||
0,
|
||||
Bytes{
|
||||
{0, 1, 2, 3},
|
||||
},
|
||||
Bytes{
|
||||
{0, 1, 2, 3},
|
||||
},
|
||||
},
|
||||
{
|
||||
-1,
|
||||
Bytes{
|
||||
{0, 1, 2, 3},
|
||||
},
|
||||
Bytes{
|
||||
{0, 1, 2, 4},
|
||||
},
|
||||
},
|
||||
{
|
||||
1,
|
||||
Bytes{
|
||||
{0, 1, 2, 4},
|
||||
},
|
||||
Bytes{
|
||||
{0, 1, 2, 3},
|
||||
},
|
||||
},
|
||||
{-1,
|
||||
Bytes{
|
||||
{0, 1, 2, 3},
|
||||
},
|
||||
Bytes{
|
||||
{0, 1, 2, 3},
|
||||
{4, 5, 6, 7},
|
||||
},
|
||||
},
|
||||
{1,
|
||||
Bytes{
|
||||
{0, 1, 2, 3},
|
||||
{4, 5, 6, 7},
|
||||
},
|
||||
Bytes{
|
||||
{0, 1, 2, 3},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
actual := compareByteSliceSlice(tt.a, tt.b)
|
||||
if tt.expect != actual {
|
||||
t.Errorf("expected compare(%v, %v) to be %v but got %v",
|
||||
tt.a, tt.b, tt.expect, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -115,7 +115,7 @@ func (p *Policy) Validate() error {
|
|||
}
|
||||
|
||||
if p.TLSClientCert != "" && p.TLSClientKey != "" {
|
||||
p.ClientCertificate, err = cryptutil.CertifcateFromBase64(p.TLSClientCert, p.TLSClientKey)
|
||||
p.ClientCertificate, err = cryptutil.CertificateFromBase64(p.TLSClientCert, p.TLSClientKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: couldn't decode client cert %w", err)
|
||||
}
|
||||
|
|
60
config/redirect.go
Normal file
60
config/redirect.go
Normal file
|
@ -0,0 +1,60 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/pomerium/pomerium/internal/httputil"
|
||||
"github.com/pomerium/pomerium/internal/log"
|
||||
)
|
||||
|
||||
// RedirectAndAutocertServer is an HTTP server which handles redirecting to HTTPS and autocerts.
|
||||
var RedirectAndAutocertServer = newRedirectAndAutoCertServer()
|
||||
|
||||
type redirectAndAutoCertServer struct {
|
||||
mu sync.Mutex
|
||||
srv *http.Server
|
||||
}
|
||||
|
||||
func newRedirectAndAutoCertServer() *redirectAndAutoCertServer {
|
||||
return &redirectAndAutoCertServer{}
|
||||
}
|
||||
|
||||
func (srv *redirectAndAutoCertServer) update(options *Options) {
|
||||
srv.mu.Lock()
|
||||
defer srv.mu.Unlock()
|
||||
|
||||
if srv.srv != nil {
|
||||
// nothing to do if the address hasn't changed
|
||||
if srv.srv.Addr == options.HTTPRedirectAddr {
|
||||
return
|
||||
}
|
||||
// close immediately, don't care about the error
|
||||
_ = srv.srv.Close()
|
||||
srv.srv = nil
|
||||
}
|
||||
|
||||
if options.HTTPRedirectAddr == "" {
|
||||
return
|
||||
}
|
||||
|
||||
redirect := httputil.RedirectHandler()
|
||||
|
||||
hsrv := &http.Server{
|
||||
Addr: options.HTTPRedirectAddr,
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if AutocertManager.HandleHTTPChallenge(w, r) {
|
||||
return
|
||||
}
|
||||
redirect.ServeHTTP(w, r)
|
||||
}),
|
||||
}
|
||||
go func() {
|
||||
log.Info().Str("addr", hsrv.Addr).Msg("starting http redirect server")
|
||||
err := hsrv.ListenAndServe()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to run http redirect server")
|
||||
}
|
||||
}()
|
||||
srv.srv = hsrv
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue