Skip to content

Commit

Permalink
Issue #135: Add Vault PKI certificate source
Browse files Browse the repository at this point in the history
Add a new cert.Source, VaultPKISource, that issues certficates on demand
from a HashiCorp Vault PKI backend.
  • Loading branch information
pschultz committed Oct 10, 2017
1 parent 8a4b0dd commit be917a3
Show file tree
Hide file tree
Showing 8 changed files with 466 additions and 212 deletions.
12 changes: 9 additions & 3 deletions cert/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"crypto/tls"
"crypto/x509"
"fmt"
"os"

"github.com/fabiolb/fabio/config"
"golang.org/x/sync/singleflight"
Expand Down Expand Up @@ -67,13 +66,20 @@ 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)
Expand Down
158 changes: 112 additions & 46 deletions cert/source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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
Expand Down
134 changes: 134 additions & 0 deletions cert/vault_client.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading

0 comments on commit be917a3

Please sign in to comment.