b9633481a5
Puppet calls /usr/local/bin/certmanager from generate() with combine: true, so the CLI must take the common name positionally and keep stderr clean or the catalog compile dies on a parsejson error. - Flatten the CLI: drop the pki subcommand, add -a/-i/-e/-c/--json - Emit ca_certificate so vault.pp stops writing undef to that file - Read output_path from under vault:, where Puppet writes it - Bound every Vault request with a timeout; exit non-zero on failure - Gate TLS verification on vault.tls_skip_verify (default true) - Restrict auth to approle and kubernetes, no fallback between them - Remove the SSH half; add Woodpecker CI, nfpm RPM and completions
262 lines
6.8 KiB
Go
262 lines
6.8 KiB
Go
package vault_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.unkin.net/unkin/certmanager/internal/config"
|
|
"git.unkin.net/unkin/certmanager/internal/vault"
|
|
)
|
|
|
|
func newServer(t *testing.T, h http.HandlerFunc) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewTLSServer(h)
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
func baseConfig(addr string) config.VaultConfig {
|
|
return config.VaultConfig{
|
|
Addr: addr,
|
|
AuthMethod: config.AuthMethodAppRole,
|
|
ApprolePath: "approle",
|
|
RoleID: "role-id",
|
|
MountPoint: "pki_int",
|
|
RoleName: "servers_default",
|
|
}
|
|
}
|
|
|
|
func TestNewAppRoleSendsRoleIDOnly(t *testing.T) {
|
|
var body map[string]any
|
|
var path string
|
|
srv := newServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
path = r.URL.Path
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
_, _ = fmt.Fprint(w, `{"auth":{"client_token":"s.token"}}`)
|
|
})
|
|
|
|
if _, err := vault.New(baseConfig(srv.URL)); err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
if path != "/v1/auth/approle/login" {
|
|
t.Errorf("login path = %q", path)
|
|
}
|
|
if len(body) != 1 || body["role_id"] != "role-id" {
|
|
t.Errorf("login body = %v, want only role_id", body)
|
|
}
|
|
}
|
|
|
|
func TestNewKubernetesSendsRoleAndJWT(t *testing.T) {
|
|
jwt := filepath.Join(t.TempDir(), "token")
|
|
if err := os.WriteFile(jwt, []byte("the-jwt\n"), 0o600); err != nil {
|
|
t.Fatalf("write jwt: %v", err)
|
|
}
|
|
|
|
var body map[string]any
|
|
var path string
|
|
srv := newServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
path = r.URL.Path
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
_, _ = fmt.Fprint(w, `{"auth":{"client_token":"s.token"}}`)
|
|
})
|
|
|
|
cfg := baseConfig(srv.URL)
|
|
cfg.AuthMethod = config.AuthMethodKubernetes
|
|
cfg.K8sMount = "k8s/au/syd1"
|
|
cfg.K8sRole = "puppet_certmanager"
|
|
cfg.JWTPath = jwt
|
|
|
|
if _, err := vault.New(cfg); err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
if path != "/v1/auth/k8s/au/syd1/login" {
|
|
t.Errorf("login path = %q", path)
|
|
}
|
|
if body["role"] != "puppet_certmanager" || body["jwt"] != "the-jwt" {
|
|
t.Errorf("login body = %v", body)
|
|
}
|
|
}
|
|
|
|
func TestNewAuthErrors(t *testing.T) {
|
|
deny := func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = fmt.Fprint(w, `{"errors":["permission denied"]}`)
|
|
}
|
|
noToken := func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = fmt.Fprint(w, `{"auth":{}}`)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
handler http.HandlerFunc
|
|
mutate func(*testing.T, *config.VaultConfig)
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "approle rejected",
|
|
handler: deny,
|
|
wantErr: "approle login: POST auth/approle/login: vault returned 403",
|
|
},
|
|
{
|
|
name: "approle returns no token",
|
|
handler: noToken,
|
|
wantErr: "no auth.client_token",
|
|
},
|
|
{
|
|
name: "kubernetes rejected",
|
|
handler: deny,
|
|
mutate: func(t *testing.T, c *config.VaultConfig) {
|
|
jwt := filepath.Join(t.TempDir(), "token")
|
|
if err := os.WriteFile(jwt, []byte("jwt"), 0o600); err != nil {
|
|
t.Fatalf("write jwt: %v", err)
|
|
}
|
|
c.AuthMethod = config.AuthMethodKubernetes
|
|
c.K8sMount = "k8s/au/syd1"
|
|
c.K8sRole = "role"
|
|
c.JWTPath = jwt
|
|
},
|
|
wantErr: "kubernetes login: POST auth/k8s/au/syd1/login: vault returned 403",
|
|
},
|
|
{
|
|
name: "kubernetes jwt missing",
|
|
handler: noToken,
|
|
mutate: func(t *testing.T, c *config.VaultConfig) {
|
|
c.AuthMethod = config.AuthMethodKubernetes
|
|
c.K8sMount = "k8s/au/syd1"
|
|
c.K8sRole = "role"
|
|
c.JWTPath = filepath.Join(t.TempDir(), "absent")
|
|
},
|
|
wantErr: "read service account token",
|
|
},
|
|
{
|
|
name: "unknown auth method",
|
|
handler: noToken,
|
|
mutate: func(t *testing.T, c *config.VaultConfig) { c.AuthMethod = "ldap" },
|
|
wantErr: `unknown vault.auth_method "ldap"`,
|
|
},
|
|
{
|
|
name: "bad timeout",
|
|
handler: noToken,
|
|
mutate: func(t *testing.T, c *config.VaultConfig) { c.Timeout = "eventually" },
|
|
wantErr: "not a valid duration",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
srv := newServer(t, tc.handler)
|
|
cfg := baseConfig(srv.URL)
|
|
if tc.mutate != nil {
|
|
tc.mutate(t, &cfg)
|
|
}
|
|
_, err := vault.New(cfg)
|
|
if err == nil {
|
|
t.Fatal("New succeeded, want error")
|
|
}
|
|
if !strings.Contains(err.Error(), tc.wantErr) {
|
|
t.Errorf("error = %v, want it to mention %q", err, tc.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewKubernetesEmptyJWT(t *testing.T) {
|
|
jwt := filepath.Join(t.TempDir(), "token")
|
|
if err := os.WriteFile(jwt, []byte("\n\t "), 0o600); err != nil {
|
|
t.Fatalf("write jwt: %v", err)
|
|
}
|
|
srv := newServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
t.Error("vault must not be contacted when the JWT is empty")
|
|
_, _ = fmt.Fprint(w, `{"auth":{"client_token":"s.token"}}`)
|
|
})
|
|
|
|
cfg := baseConfig(srv.URL)
|
|
cfg.AuthMethod = config.AuthMethodKubernetes
|
|
cfg.K8sMount = "k8s/au/syd1"
|
|
cfg.K8sRole = "role"
|
|
cfg.JWTPath = jwt
|
|
|
|
_, err := vault.New(cfg)
|
|
if err == nil || !strings.Contains(err.Error(), "is empty") {
|
|
t.Fatalf("error = %v, want an empty-JWT failure", err)
|
|
}
|
|
}
|
|
|
|
func TestPostSendsToken(t *testing.T) {
|
|
var token string
|
|
srv := newServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasSuffix(r.URL.Path, "/login") {
|
|
_, _ = fmt.Fprint(w, `{"auth":{"client_token":"s.token"}}`)
|
|
return
|
|
}
|
|
token = r.Header.Get("X-Vault-Token")
|
|
_, _ = fmt.Fprint(w, `{"data":{"ok":true}}`)
|
|
})
|
|
|
|
c, err := vault.New(baseConfig(srv.URL))
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
var out struct {
|
|
Data struct {
|
|
OK bool `json:"ok"`
|
|
} `json:"data"`
|
|
}
|
|
if err := c.Post("pki_int/issue/servers_default", map[string]string{}, &out); err != nil {
|
|
t.Fatalf("Post: %v", err)
|
|
}
|
|
if token != "s.token" {
|
|
t.Errorf("X-Vault-Token = %q", token)
|
|
}
|
|
if !out.Data.OK {
|
|
t.Error("response not decoded")
|
|
}
|
|
}
|
|
|
|
func TestPostTimesOut(t *testing.T) {
|
|
srv := newServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasSuffix(r.URL.Path, "/login") {
|
|
_, _ = fmt.Fprint(w, `{"auth":{"client_token":"s.token"}}`)
|
|
return
|
|
}
|
|
time.Sleep(500 * time.Millisecond)
|
|
_, _ = fmt.Fprint(w, `{"data":{}}`)
|
|
})
|
|
|
|
cfg := baseConfig(srv.URL)
|
|
cfg.Timeout = "50ms"
|
|
c, err := vault.New(cfg)
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
|
|
start := time.Now()
|
|
err = c.Post("pki_int/issue/servers_default", map[string]string{}, &struct{}{})
|
|
if err == nil {
|
|
t.Fatal("Post succeeded, want a timeout")
|
|
}
|
|
if elapsed := time.Since(start); elapsed > 400*time.Millisecond {
|
|
t.Errorf("Post took %v, the timeout did not apply", elapsed)
|
|
}
|
|
}
|
|
|
|
func TestTLSVerificationCanBeEnabled(t *testing.T) {
|
|
srv := newServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = fmt.Fprint(w, `{"auth":{"client_token":"s.token"}}`)
|
|
})
|
|
|
|
cfg := baseConfig(srv.URL)
|
|
skip := false
|
|
cfg.TLSSkipVerify = &skip
|
|
if _, err := vault.New(cfg); err == nil {
|
|
t.Fatal("New succeeded against a self-signed server with verification enabled")
|
|
}
|
|
}
|