diff --git a/cert/source.go b/cert/source.go index bf7902736..799a8b6d4 100644 --- a/cert/source.go +++ b/cert/source.go @@ -4,7 +4,6 @@ import ( "crypto/tls" "crypto/x509" "fmt" - "os" "github.com/fabiolb/fabio/config" ) @@ -22,6 +21,14 @@ type Source interface { LoadClientCAs() (*x509.CertPool, error) } +// Issuer is the interface implemented by sources that can issue certificates +// on-demand. +type Issuer interface { + // Issue issues a new certificate for the given common name. Issue must + // return a certificate or an error, never (nil, nil). + Issue(commonName string) (*tls.Certificate, error) +} + // NewSource generates a cert source from the config options. func NewSource(cfg config.CertSource) (Source, error) { switch cfg.Type { @@ -58,27 +65,32 @@ func NewSource(cfg config.CertSource) (Source, error) { case "vault": return &VaultSource{ - Addr: os.Getenv("VAULT_ADDR"), CertPath: cfg.CertPath, ClientCAPath: cfg.ClientCAPath, CAUpgradeCN: cfg.CAUpgradeCN, Refresh: cfg.Refresh, - vaultToken: os.Getenv("VAULT_TOKEN"), + Client: DefaultVaultClient, }, nil + case "vault-pki": + src := NewVaultPKISource() + src.CertPath = cfg.CertPath + src.ClientCAPath = cfg.ClientCAPath + src.CAUpgradeCN = cfg.CAUpgradeCN + src.Refresh = cfg.Refresh + src.Client = DefaultVaultClient + return src, nil default: return nil, fmt.Errorf("invalid certificate source %q", cfg.Type) } } -// TLSConfig creates a tls.Config which sets the -// GetCertificate field to a certificate store -// which uses the given source to update the -// the certificates on demand. +// TLSConfig creates a tls.Config which sets the GetCertificate field to a +// certificate store which uses the given source to update the the certificates +// on-demand. // -// It also sets the ClientCAs field if -// src.LoadClientCAs returns a non-nil value -// and sets ClientAuth to RequireAndVerifyClientCert. +// It also sets the ClientCAs field if src.LoadClientCAs returns a non-nil +// value and sets ClientAuth to RequireAndVerifyClientCert. func TLSConfig(src Source, strictMatch bool, minVersion, maxVersion uint16, cipherSuites []uint16) (*tls.Config, error) { clientCAs, err := src.LoadClientCAs() if err != nil { @@ -92,7 +104,31 @@ func TLSConfig(src Source, strictMatch bool, minVersion, maxVersion uint16, ciph CipherSuites: cipherSuites, NextProtos: []string{"h2", "http/1.1"}, GetCertificate: func(clientHello *tls.ClientHelloInfo) (cert *tls.Certificate, err error) { - return getCertificate(store.certstore(), clientHello, strictMatch) + cert, err = getCertificate(store.certstore(), clientHello, strictMatch) + if cert != nil { + return + } + + switch err { + case nil, ErrNoCertsStored: + // Store doesn't contain a suitable cert. Perhaps the source can issue one? + default: + // an unrecoverable error + return + } + + ca, ok := src.(Issuer) + if !ok { + return + } + + // TODO: do we need to lock something here? + cert, err = ca.Issue(clientHello.ServerName) + if err != nil { + return + } + + return }, } diff --git a/cert/source_test.go b/cert/source_test.go index 316bde34d..03cd658c1 100644 --- a/cert/source_test.go +++ b/cert/source_test.go @@ -140,8 +140,7 @@ func TestNewSource(t *testing.T) { desc: "vault", cfg: certsource("vault"), src: &VaultSource{ - Addr: os.Getenv("VAULT_ADDR"), - vaultToken: os.Getenv("VAULT_TOKEN"), + Client: DefaultVaultClient, CertPath: "cert", ClientCAPath: "clientca", CAUpgradeCN: "upgcn", @@ -205,7 +204,7 @@ func TestPathSource(t *testing.T) { defer os.RemoveAll(dir) certPEM, keyPEM := makePEM("localhost", time.Minute) saveCert(dir, "localhost", certPEM, keyPEM) - testSource(t, PathSource{CertPath: dir}, makeCertPool(certPEM), 0) + testSource(t, PathSource{CertPath: dir}, makeCertPool(certPEM), 10*time.Millisecond) } func TestHTTPSource(t *testing.T) { @@ -339,6 +338,10 @@ func vaultServer(t *testing.T, addr, rootToken string) (*exec.Cmd, *vaultapi.Cli path "secret/fabio/cert/*" { capabilities = ["read"] } + + path "test-pki/issue/fabio" { + capabilities = ["update"] + } ` if err := c.Sys().PutPolicy("fabio", policy); err != nil { @@ -371,6 +374,43 @@ func makeToken(t *testing.T, c *vaultapi.Client, wrapTTL string, req *vaultapi.T return resp.Auth.ClientToken } +var vaultTestCases = []struct { + desc string + wrapTTL string + req *vaultapi.TokenCreateRequest + dropWarn bool +}{ + { + desc: "renewable token", + req: &vaultapi.TokenCreateRequest{Lease: "1m", TTL: "1m", Policies: []string{"fabio"}}, + }, + { + desc: "non-renewable token", + req: &vaultapi.TokenCreateRequest{Lease: "1m", TTL: "1m", Renewable: new(bool), Policies: []string{"fabio"}}, + dropWarn: true, + }, + { + desc: "renewable orphan token", + req: &vaultapi.TokenCreateRequest{Lease: "1m", TTL: "1m", NoParent: true, Policies: []string{"fabio"}}, + }, + { + desc: "non-renewable orphan token", + req: &vaultapi.TokenCreateRequest{Lease: "1m", TTL: "1m", NoParent: true, Renewable: new(bool), Policies: []string{"fabio"}}, + dropWarn: true, + }, + { + desc: "renewable wrapped token", + wrapTTL: "10s", + req: &vaultapi.TokenCreateRequest{Lease: "1m", TTL: "1m", Policies: []string{"fabio"}}, + }, + { + desc: "non-renewable wrapped token", + wrapTTL: "10s", + req: &vaultapi.TokenCreateRequest{Lease: "1m", TTL: "1m", Renewable: new(bool), Policies: []string{"fabio"}}, + dropWarn: true, + }, +} + func TestVaultSource(t *testing.T) { const ( addr = "127.0.0.1:58421" @@ -389,55 +429,17 @@ func TestVaultSource(t *testing.T) { t.Fatalf("logical.Write failed: %s", err) } - newBool := func(b bool) *bool { return &b } - - // run tests - tests := []struct { - desc string - wrapTTL string - req *vaultapi.TokenCreateRequest - dropWarn bool - }{ - { - desc: "renewable token", - req: &vaultapi.TokenCreateRequest{Lease: "1m", TTL: "1m", Policies: []string{"fabio"}}, - }, - { - desc: "non-renewable token", - req: &vaultapi.TokenCreateRequest{Lease: "1m", TTL: "1m", Renewable: newBool(false), Policies: []string{"fabio"}}, - dropWarn: true, - }, - { - desc: "renewable orphan token", - req: &vaultapi.TokenCreateRequest{Lease: "1m", TTL: "1m", NoParent: true, Policies: []string{"fabio"}}, - }, - { - desc: "non-renewable orphan token", - req: &vaultapi.TokenCreateRequest{Lease: "1m", TTL: "1m", NoParent: true, Renewable: newBool(false), Policies: []string{"fabio"}}, - dropWarn: true, - }, - { - desc: "renewable wrapped token", - wrapTTL: "10s", - req: &vaultapi.TokenCreateRequest{Lease: "1m", TTL: "1m", Policies: []string{"fabio"}}, - }, - { - desc: "non-renewable wrapped token", - wrapTTL: "10s", - req: &vaultapi.TokenCreateRequest{Lease: "1m", TTL: "1m", Renewable: newBool(false), Policies: []string{"fabio"}}, - dropWarn: true, - }, - } - pool := makeCertPool(certPEM) timeout := 500 * time.Millisecond - for _, tt := range tests { + for _, tt := range vaultTestCases { tt := tt // capture loop var t.Run(tt.desc, func(t *testing.T) { src := &VaultSource{ - Addr: "http://" + addr, - CertPath: certPath, - vaultToken: makeToken(t, client, tt.wrapTTL, tt.req), + Client: &vaultClient{ + addr: "http://" + addr, + token: makeToken(t, client, tt.wrapTTL, tt.req), + }, + CertPath: certPath, } // suppress the log warning about a non-renewable token @@ -449,6 +451,70 @@ func TestVaultSource(t *testing.T) { } } +func TestVaultPKISource(t *testing.T) { + const ( + addr = "127.0.0.1:58421" + rootToken = "token" + certPath = "test-pki/issue/fabio" + ) + + // start a vault server + vault, client := vaultServer(t, addr, rootToken) + defer vault.Process.Kill() + + // mount the PKI backend + err := client.Sys().Mount("test-pki", &vaultapi.MountInput{ + Type: "pki", + Config: vaultapi.MountConfigInput{ + DefaultLeaseTTL: "1h", // default validity period of issued certificates + MaxLeaseTTL: "2h", // maximum validity period of issued certificates + }, + }) + if err != nil { + t.Fatalf("Mount pki backend failed: %s", err) + } + + // generate root CA cert + resp, err := client.Logical().Write("test-pki/root/generate/internal", map[string]interface{}{ + "common_name": "Fabio Test CA", + "ttl": "2h", + }) + if err != nil { + t.Fatalf("Generate root failed: %s", err) + } + caPool := makeCertPool([]byte(resp.Data["certificate"].(string))) + + // create role + role := filepath.Base(certPath) + _, err = client.Logical().Write("test-pki/roles/"+role, map[string]interface{}{ + "allowed_domains": "", + "allow_localhost": true, + "allow_ip_sans": true, + "organization": "Fabio Test", + }) + if err != nil { + t.Fatalf("Write role failed: %s", err) + } + + for _, tt := range vaultTestCases { + tt := tt // capture loop var + t.Run(tt.desc, func(t *testing.T) { + src := NewVaultPKISource() + src.Client = &vaultClient{ + addr: "http://" + addr, + token: makeToken(t, client, tt.wrapTTL, tt.req), + } + src.CertPath = certPath + + // suppress the log warning about a non-renewable token + // since this is the expected behavior. + dropNotRenewableWarning = tt.dropWarn + testSource(t, src, caPool, 0) + dropNotRenewableWarning = false + }) + } +} + // testSource runs an integration test by making an HTTPS request // to https://localhost/ expecting that the source provides a valid // certificate for "localhost". rootCAs is expected to contain a @@ -505,19 +571,18 @@ func testSource(t *testing.T, source Source, rootCAs *x509.CertPool, sleep time. } } - // make a call for which certificate validation fails. - fail(http11) - fail(http20) - - // now make the call that should succeed + // make a call for which certificate validation succeeds. succeed(http11, "OK HTTP/1.1") succeed(http20, "OK HTTP/2.0") + + // now make the call that should fail. + fail(http11) + fail(http20) } // roundtrip starts a TLS server with the given server configuration and -// then calls "https:///" with the given client. "host" must resolve -// to 127.0.0.1. -func roundtrip(host string, srvConfig *tls.Config, client *http.Client) (code int, body string, err error) { +// then sends an SNI request with the given serverName. +func roundtrip(serverName string, srvConfig *tls.Config, client *http.Client) (code int, body string, err error) { // create an HTTPS server and start it. It will be listening on 127.0.0.1 srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "OK ", r.Proto) @@ -526,11 +591,9 @@ func roundtrip(host string, srvConfig *tls.Config, client *http.Client) (code in srv.StartTLS() defer srv.Close() - // for the certificate validation to work we need to use a hostname - // in the URL which resolves to 127.0.0.1. We can't fake the hostname - // via the Host header. - url := strings.Replace(srv.URL, "127.0.0.1", host, 1) - resp, err := client.Get(url) + // configure SNI + client.Transport.(*http.Transport).TLSClientConfig.ServerName = serverName + resp, err := client.Get(srv.URL) if err != nil { return 0, "", err } diff --git a/cert/store.go b/cert/store.go index cdc7ece7d..f497cfc6b 100644 --- a/cert/store.go +++ b/cert/store.go @@ -38,9 +38,11 @@ func (s *Store) certstore() certstore { return s.cs.Load().(certstore) } +var ErrNoCertsStored = errors.New("cert: no certificates stored") + func getCertificate(cs certstore, clientHello *tls.ClientHelloInfo, strictMatch bool) (cert *tls.Certificate, err error) { if len(cs.Certificates) == 0 { - return nil, errors.New("cert: no certificates stored") + return nil, ErrNoCertsStored } // There's only one choice, so no point doing any work. diff --git a/cert/vault_client.go b/cert/vault_client.go new file mode 100644 index 000000000..e41afe79f --- /dev/null +++ b/cert/vault_client.go @@ -0,0 +1,134 @@ +package cert + +import ( + "encoding/json" + "errors" + "log" + "strings" + "sync" + "time" + + "github.com/hashicorp/vault/api" +) + +// vaultClient wraps an *api.Client and takes care of token renewal +// automatically. +type vaultClient struct { + addr string // overrides the default config + token string // overrides the VAULT_TOKEN environment variable + + client *api.Client + mu sync.Mutex +} + +var DefaultVaultClient = &vaultClient{} + +func (c *vaultClient) Get() (*api.Client, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.client != nil { + return c.client, nil + } + + conf := api.DefaultConfig() + if err := conf.ReadEnvironment(); err != nil { + return nil, err + } + + if c.addr != "" { + conf.Address = c.addr + } + + client, err := api.NewClient(conf) + if err != nil { + return nil, err + } + + if c.token != "" { + client.SetToken(c.token) + } + + token := client.Token() + if token == "" { + return nil, errors.New("vault: no token") + } + + // did we get a wrapped token? + resp, err := client.Logical().Unwrap(token) + switch { + case err == nil: + log.Printf("[INFO] vault: Unwrapped token %s", token) + client.SetToken(resp.Auth.ClientToken) + case strings.HasPrefix(err.Error(), "no value found at"): + // not a wrapped token + default: + return nil, err + } + + c.client = client + go c.keepTokenAlive() + + return client, nil +} + +// dropNotRenewableWarning controls whether the 'Token is not renewable' +// warning is logged. This is useful for testing where this is the expected +// behavior. On production, this should always be set to false. +var dropNotRenewableWarning bool + +func (c *vaultClient) keepTokenAlive() { + resp, err := c.client.Auth().Token().LookupSelf() + if err != nil { + log.Printf("[WARN] vault: lookup-self failed, token renewal is disabled: %s", err) + return + } + + b, _ := json.Marshal(resp.Data) + var data struct { + TTL int `json:"ttl"` + CreationTTL int `json:"creation_ttl"` + Renewable bool `json:"renewable"` + ExpireTime time.Time `json:"expire_time"` + } + if err := json.Unmarshal(b, &data); err != nil { + log.Printf("[WARN] vault: lookup-self failed, token renewal is disabled: %s", err) + return + } + + switch { + case data.Renewable: + // no-op + case data.ExpireTime.IsZero(): + // token doesn't expire + return + case dropNotRenewableWarning: + return + default: + ttl := time.Until(data.ExpireTime) + ttl = ttl / time.Second * time.Second // truncate to seconds + log.Printf("[WARN] vault: Token is not renewable and will expire %s from now at %s", + ttl, data.ExpireTime.Format(time.RFC3339)) + return + } + + ttl := time.Duration(data.TTL) * time.Second + timer := time.NewTimer(ttl / 2) + + for range timer.C { + resp, err := c.client.Auth().Token().RenewSelf(data.CreationTTL) + if err != nil { + log.Printf("[WARN] vault: Failed to renew token: %s", err) + timer.Reset(time.Second) // TODO: backoff? abort after N consecutive failures? + continue + } + + if !resp.Auth.Renewable || resp.Auth.LeaseDuration == 0 { + // token isn't renewable anymore, we're done. + return + } + + ttl = time.Duration(resp.Auth.LeaseDuration) * time.Second + timer.Reset(ttl / 2) + } +} diff --git a/cert/vault_pki_source.go b/cert/vault_pki_source.go new file mode 100644 index 000000000..bab12e700 --- /dev/null +++ b/cert/vault_pki_source.go @@ -0,0 +1,145 @@ +package cert + +import ( + "bytes" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "log" + "math/big" + "sync" + "time" +) + +// VaultPKISource implements a certificate source which issues TLS certificates +// on-demand using a Vault PKI backend. Client authorization certificates are +// loaded from a generic backend (same as in VaultSource). The Vault token +// should be set through the VAULT_TOKEN environment variable. +// +// The TLS certificates are re-issued automatically before they expire. +type VaultPKISource struct { + Client *vaultClient + CertPath string + ClientCAPath string + CAUpgradeCN string + + // Re-issue certificates this long before they expire. Cannot be less then + // one hour. + Refresh time.Duration + + certsCh chan []tls.Certificate + + mu sync.Mutex + certs map[string]tls.Certificate // issued certs +} + +func NewVaultPKISource() *VaultPKISource { + return &VaultPKISource{ + certs: make(map[string]tls.Certificate, 0), + certsCh: make(chan []tls.Certificate, 1), + } +} + +func (s *VaultPKISource) LoadClientCAs() (*x509.CertPool, error) { + return (&VaultSource{ + Client: s.Client, + ClientCAPath: s.ClientCAPath, + CAUpgradeCN: s.CAUpgradeCN, + }).LoadClientCAs() +} + +func (s *VaultPKISource) Certificates() chan []tls.Certificate { + return s.certsCh +} + +func (s *VaultPKISource) Issue(commonName string) (*tls.Certificate, error) { + c, err := s.Client.Get() + if err != nil { + return nil, fmt.Errorf("vault: client: %s", err) + } + + resp, err := c.Logical().Write(s.CertPath, map[string]interface{}{ + "common_name": commonName, + }) + if err != nil { + fmt.Printf("Issue: %v\n", err) + return nil, fmt.Errorf("vault: issue: %s", err) + } + + b, _ := json.Marshal(resp.Data) + var data struct { + PrivateKey string `json:"private_key"` + Certificate string `json:"certificate"` + CAChain []string `json:"ca_chain"` + } + if err := json.Unmarshal(b, &data); err != nil { + return nil, fmt.Errorf("vault: issue: %s", err) + } + + if data.PrivateKey == "" { + return nil, fmt.Errorf("vault: issue: missing private key") + } + if data.Certificate == "" { + return nil, fmt.Errorf("vault: issue: missing certificate") + } + + key := []byte(data.PrivateKey) + fullChain := []byte(data.Certificate) + for _, c := range data.CAChain { + fullChain = append(fullChain, '\n') + fullChain = append(fullChain, []byte(c)...) + } + + cert, err := tls.X509KeyPair(fullChain, key) + if err != nil { + return nil, fmt.Errorf("vault: issue: %s", err) + } + + x509Cert, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + // Should never happen because x509.ParseCertificate did this + // successfully already, but threw the result away. + return nil, fmt.Errorf("vault: issue: %s", err) + } + + refresh := s.Refresh + if refresh < time.Hour { + refresh = time.Hour + } + + expires := x509Cert.NotAfter + certTTL := time.Until(expires) - refresh + time.AfterFunc(certTTL, func() { + _, err := s.Issue(commonName) + if err != nil { + log.Printf("[ERROR] cert: vault: Failed to re-issue cert for %s: %s", commonName, err) + // TODO: Now what? Retry? Do nothing? + return + } + }) + + s.mu.Lock() + s.certs[commonName] = cert + allCerts := make([]tls.Certificate, 0, len(s.certs)) + for _, c := range s.certs { + allCerts = append(allCerts, c) + } + s.mu.Unlock() + + go func() { s.certsCh <- allCerts }() + log.Printf("[INFO] cert: vault: issued cert for %s; serial = %s", commonName, s.formatSerial(x509Cert.SerialNumber)) + + return &cert, nil +} + +func (*VaultPKISource) formatSerial(sn *big.Int) string { + var buf bytes.Buffer + for _, b := range sn.Bytes() { + if buf.Len() > 0 { + buf.WriteByte('-') + } + fmt.Fprintf(&buf, "%02x", b) + } + return buf.String() +} diff --git a/cert/vault_source.go b/cert/vault_source.go index c1729058f..a847ad2e8 100644 --- a/cert/vault_source.go +++ b/cert/vault_source.go @@ -3,12 +3,8 @@ package cert import ( "crypto/tls" "crypto/x509" - "encoding/json" - "errors" "fmt" "log" - "strings" - "sync" "time" "github.com/hashicorp/vault/api" @@ -23,126 +19,17 @@ import ( // is not zero. Refresh cannot be less than one second to prevent // busy loops. type VaultSource struct { - Addr string + Client *vaultClient CertPath string ClientCAPath string CAUpgradeCN string Refresh time.Duration - - mu sync.Mutex - vaultToken string // VAULT_TOKEN env var. Might be wrapped. - auth struct { - // token is the actual Vault token. - token string - - // expireTime is the time at which the token expires (becomes useless). - // The zero value indicates that the token is not renewable or never - // expires. - expireTime time.Time - - // renewTTL is the desired token lifetime after renewal, in seconds. - // This value is advisory and the Vault server may ignore or silently - // change it. - renewTTL int - } -} - -func (s *VaultSource) client() (*api.Client, error) { - conf := api.DefaultConfig() - if err := conf.ReadEnvironment(); err != nil { - return nil, err - } - if s.Addr != "" { - conf.Address = s.Addr - } - c, err := api.NewClient(conf) - if err != nil { - return nil, err - } - - if err := s.setAuth(c); err != nil { - return nil, err - } - - return c, nil -} - -func (s *VaultSource) setAuth(c *api.Client) error { - s.mu.Lock() - defer s.mu.Unlock() - - if s.auth.token != "" { - c.SetToken(s.auth.token) - return nil - } - - if s.vaultToken == "" { - return errors.New("vault: no token") - } - - // did we get a wrapped token? - resp, err := c.Logical().Unwrap(s.vaultToken) - switch { - case err == nil: - log.Printf("[INFO] vault: Unwrapped token %s", s.vaultToken) - s.auth.token = resp.Auth.ClientToken - case strings.HasPrefix(err.Error(), "no value found at"): - // not a wrapped token - s.auth.token = s.vaultToken - default: - return err - } - - c.SetToken(s.auth.token) - s.checkRenewal(c) - - return nil -} - -// dropNotRenewableWarning controls whether the 'Token is not renewable' -// warning is logged. This is useful for testing where this is the expected -// behavior. On production, this should always be set to false. -var dropNotRenewableWarning bool - -// checkRenewal checks if the Vault token can be renewed, and if so when it -// expires and how big the renewal increment should be. -func (s *VaultSource) checkRenewal(c *api.Client) { - resp, err := c.Auth().Token().LookupSelf() - if err != nil { - log.Printf("[WARN] vault: lookup-self failed, token renewal is disabled: %s", err) - return - } - - b, _ := json.Marshal(resp.Data) - var data struct { - CreationTTL int `json:"creation_ttl"` - ExpireTime time.Time `json:"expire_time"` - Renewable bool `json:"renewable"` - } - if err := json.Unmarshal(b, &data); err != nil { - log.Printf("[WARN] vault: lookup-self failed, token renewal is disabled: %s", err) - return - } - - switch { - case data.Renewable: - s.auth.renewTTL = data.CreationTTL - s.auth.expireTime = data.ExpireTime - case data.ExpireTime.IsZero(): - // token doesn't expire - return - case dropNotRenewableWarning: - return - default: - ttl := time.Until(data.ExpireTime) - ttl = ttl / time.Second * time.Second // truncate to seconds - log.Printf("[WARN] vault: Token is not renewable and will expire %s from now at %s", - ttl, data.ExpireTime.Format(time.RFC3339)) - } - } func (s *VaultSource) LoadClientCAs() (*x509.CertPool, error) { + if s.ClientCAPath == "" { + return nil, nil + } return newCertPool(s.ClientCAPath, s.CAUpgradeCN, s.load) } @@ -180,15 +67,11 @@ func (s *VaultSource) load(path string) (pemBlocks map[string][]byte, err error) pemBlocks[name+"-"+typ+".pem"] = b } - c, err := s.client() + c, err := s.Client.Get() if err != nil { return nil, fmt.Errorf("vault: client: %s", err) } - if err := s.renewToken(c); err != nil { - log.Printf("[WARN] vault: Failed to renew token: %s", err) - } - // get the subkeys under 'path'. // Each subkey refers to a certificate. certs, err := c.Logical().List(path) @@ -213,37 +96,3 @@ func (s *VaultSource) load(path string) (pemBlocks map[string][]byte, err error) return pemBlocks, nil } - -func (s *VaultSource) renewToken(c *api.Client) error { - s.mu.Lock() - defer s.mu.Unlock() - - ttl := time.Until(s.auth.expireTime) - switch { - case s.auth.expireTime.IsZero(): - // Token isn't renewable. - return nil - case ttl < 2*s.Refresh: - // Renew the token if it isn't valid for two more refresh intervals. - break - case ttl < time.Minute: - // Renew the token if it isn't valid for one more minute. This happens - // if s.Refresh is small, say one second. It is risky to renew the - // token just one or two seconds before expiration; networks are - // unreliable, clocks can be skewed, etc. - break - default: - // Token doesn't need to be renewed yet. - return nil - } - - resp, err := c.Auth().Token().RenewSelf(s.auth.renewTTL) - if err != nil { - return err - } - - leaseDuration := time.Duration(resp.Auth.LeaseDuration) * time.Second - s.auth.expireTime = time.Now().Add(leaseDuration) - - return nil -} diff --git a/config/load.go b/config/load.go index 7ad4bb778..ea71b2ff0 100644 --- a/config/load.go +++ b/config/load.go @@ -494,17 +494,19 @@ func parseCertSource(cfg map[string]string) (c CertSource, err error) { if c.Name == "" { return CertSource{}, fmt.Errorf("missing 'cs' in %s", cfg) } - if c.Type == "" { - return CertSource{}, fmt.Errorf("missing 'type' in %s", cfg) - } if c.CertPath == "" { return CertSource{}, fmt.Errorf("missing 'cert' in %s", cfg) } - if c.Type != "file" && c.Type != "path" && c.Type != "http" && c.Type != "consul" && c.Type != "vault" { - return CertSource{}, fmt.Errorf("unknown cert source type %s", c.Type) - } - if c.Type == "file" { + switch c.Type { + case "": + return CertSource{}, fmt.Errorf("missing 'type' in %s", cfg) + case "file": c.Refresh = 0 + case "path", "http", "consul", "vault", "vault-pki": + // no-op + default: + return CertSource{}, fmt.Errorf("unknown cert source type %s", c.Type) } + return } diff --git a/fabio.properties b/fabio.properties index 0e5336363..a39c34c56 100644 --- a/fabio.properties +++ b/fabio.properties @@ -119,6 +119,23 @@ # # cs=;type=vault;cert=secret/fabio/certs # +# Vault PKI +# +# The Vault PKI certificate store uses HashiCorp Vault's PKI backend to issue +# certificates on-demand. +# +# The 'cert' option provides a PKI backend path for issuing certificates. The +# 'clientca' option works in the same way as for the generic Vault source. +# +# The 'refresh' option determines how long before the expiration date +# certificates are re-issued. Values smaller than one hour are silently changed +# to one hour, which is also the default. +# +# cs=;type=vault-pki;cert=pki/issue/example-dot-com;refresh=24h;clientca=secret/fabio/client-certs +# +# This source will issue server certificates on-demand using the PKI backend +# and re-issue them 24 hours before they expire. The CA for client +# authentication is expected to be stored at secret/fabio/client-certs. # # Common options # @@ -149,6 +166,9 @@ # # # Vault certificate source # proxy.cs = cs=some-name;type=vault;cert=secret/fabio/certs + +# # Vault PKI certificate source +# proxy.cs = cs=some-name;type=vault-pki;cert=pki/issue/example-dot-com # # # Multiple certificate sources # proxy.cs = cs=srcA;type=path;path=path/to/certs,\ diff --git a/main.go b/main.go index e4ea3b252..e9c863279 100644 --- a/main.go +++ b/main.go @@ -199,6 +199,12 @@ func makeTLSConfig(l config.Listen) (*tls.Config, error) { if err != nil { return nil, fmt.Errorf("Failed to create cert source %s. %s", l.CertSource.Name, err) } + if _, ok := src.(cert.Issuer); ok { + // StrictMatch must be enabled for issuing sources, otherwise the first + // issued certificate is used for all subsequent requests, even if the + // common name doesn't match. + l.StrictMatch = true + } tlscfg, err := cert.TLSConfig(src, l.StrictMatch, l.TLSMinVersion, l.TLSMaxVersion, l.TLSCiphers) if err != nil { return nil, fmt.Errorf("[FATAL] Failed to create TLS config for cert source %s. %s", l.CertSource.Name, err)