Make certmanager a drop-in for the Python script
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
This commit is contained in:
+113
-122
@@ -1,58 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.unkin.net/unkin/certmanager/internal/config"
|
||||
"git.unkin.net/unkin/certmanager/internal/pki"
|
||||
"git.unkin.net/unkin/certmanager/internal/ssh"
|
||||
"git.unkin.net/unkin/certmanager/internal/vault"
|
||||
)
|
||||
|
||||
var cfgFile string
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
if err := rootCmd().Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
|
||||
}
|
||||
|
||||
// run executes the CLI and returns the process exit code. On the success path
|
||||
// it writes nothing to stderr: Puppet's generate() runs this binary with
|
||||
// combine: true, which splices stderr into the stdout that parsejson() reads.
|
||||
func run(args []string, stdout, stderr io.Writer) int {
|
||||
cmd := rootCmd()
|
||||
cmd.SetArgs(args)
|
||||
cmd.SetOut(stdout)
|
||||
cmd.SetErr(stderr)
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
_, _ = fmt.Fprintf(stderr, "certmanager: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func rootCmd() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "certmanager",
|
||||
Short: "Request PKI certificates and sign SSH host keys via HashiCorp Vault",
|
||||
}
|
||||
root.PersistentFlags().StringVar(&cfgFile, "config", config.DefaultPath(), "path to config.yaml")
|
||||
|
||||
root.AddCommand(pkiCmd())
|
||||
root.AddCommand(sshCmd())
|
||||
return root
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pki subcommand
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func pkiCmd() *cobra.Command {
|
||||
var (
|
||||
altNames []string
|
||||
ipSans []string
|
||||
cfgFile string
|
||||
altNames string
|
||||
ipSans string
|
||||
expiryDays int
|
||||
compress bool
|
||||
jsonOut bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "pki <common_name>",
|
||||
Short: "Issue a PKI certificate from Vault",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Use: "certmanager <common_name>",
|
||||
Short: "Issue a PKI certificate from HashiCorp Vault",
|
||||
Version: version,
|
||||
Args: cobra.ExactArgs(1),
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
commonName := args[0]
|
||||
|
||||
cfg, err := config.Load(cfgFile)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -60,117 +63,105 @@ func pkiCmd() *cobra.Command {
|
||||
|
||||
client, err := vault.New(cfg.Vault)
|
||||
if err != nil {
|
||||
return fmt.Errorf("vault auth: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
cert, err := pki.IssueCert(client, cfg.Vault.MountPoint, cfg.Vault.RoleName, commonName, altNames, ipSans, expiryDays)
|
||||
cert, err := pki.IssueCert(client, cfg.Vault.MountPoint, cfg.Vault.RoleName,
|
||||
args[0], parseList(altNames), parseList(ipSans), expiryDays)
|
||||
if err != nil {
|
||||
return fmt.Errorf("issue cert: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if jsonOut {
|
||||
return json.NewEncoder(os.Stdout).Encode(cert)
|
||||
return writeJSON(cmd.OutOrStdout(), cert)
|
||||
}
|
||||
|
||||
outDir := cfg.Vault.OutputPath
|
||||
if outDir == "" {
|
||||
outDir = "."
|
||||
}
|
||||
return writePKIFiles(outDir, commonName, cert)
|
||||
return writeFiles(cfg.Vault.OutputPath, args[0], compress, cert)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVar(&altNames, "alt-names", nil, "additional DNS SANs (comma-separated or repeated flag)")
|
||||
cmd.Flags().StringSliceVar(&ipSans, "ip-sans", nil, "additional IP SANs (comma-separated or repeated flag)")
|
||||
cmd.Flags().IntVar(&expiryDays, "expiry-days", 90, "certificate TTL in days")
|
||||
cmd.Flags().BoolVar(&jsonOut, "json", false, "print certificate data as JSON instead of writing files")
|
||||
cmd.Flags().StringVar(&cfgFile, "config", config.DefaultPath(), "path to config.yaml")
|
||||
cmd.Flags().StringVarP(&altNames, "alt-names", "a", "", "comma-separated DNS subject alternative names")
|
||||
cmd.Flags().StringVarP(&ipSans, "ip-sans", "i", "", "comma-separated IP subject alternative names")
|
||||
cmd.Flags().IntVarP(&expiryDays, "expiry-days", "e", 365, "certificate validity in days")
|
||||
cmd.Flags().BoolVarP(&compress, "compress", "c", false, "write the certificate, key and chain into a zip file")
|
||||
cmd.Flags().BoolVar(&jsonOut, "json", false, "print the certificate data as JSON instead of writing files")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func writePKIFiles(outDir, commonName string, cert *pki.CertificateResponse) error {
|
||||
dir := fmt.Sprintf("%s/%s", outDir, commonName)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
files := map[string]string{
|
||||
"certificate.crt": cert.Certificate,
|
||||
"private.key": cert.PrivateKey,
|
||||
"full_chain.crt": cert.FullChain,
|
||||
"ca_certificate.crt": cert.CACertificate,
|
||||
"certificate.pem": cert.Certificate + "\n" + cert.PrivateKey,
|
||||
}
|
||||
|
||||
for name, content := range files {
|
||||
mode := os.FileMode(0o644)
|
||||
if name == "private.key" || name == "certificate.pem" {
|
||||
mode = 0o600
|
||||
// parseList splits a comma-separated flag value, trimming whitespace and
|
||||
// dropping empty tokens.
|
||||
func parseList(s string) []string {
|
||||
var out []string
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
if v := strings.TrimSpace(part); v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
path := fmt.Sprintf("%s/%s", dir, name)
|
||||
if err := os.WriteFile(path, []byte(content), mode); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeJSON(w io.Writer, cert *pki.CertificateResponse) error {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
return enc.Encode(cert)
|
||||
}
|
||||
|
||||
// writeFiles is the non-JSON path. outputPath comes from vault.output_path in
|
||||
// the config; the Python read it from the wrong nesting level so it never
|
||||
// resolved and always fell back to the working directory.
|
||||
func writeFiles(outputPath, commonName string, compress bool, cert *pki.CertificateResponse) error {
|
||||
if outputPath == "" {
|
||||
outputPath = "."
|
||||
}
|
||||
|
||||
contents := []struct {
|
||||
name string
|
||||
body string
|
||||
mode os.FileMode
|
||||
}{
|
||||
{"certificate.crt", cert.Certificate, 0o644},
|
||||
{"private.key", cert.PrivateKey, 0o600},
|
||||
{"full_chain.crt", cert.FullChain, 0o644},
|
||||
{"ca_certificate.crt", cert.CACertificate, 0o644},
|
||||
}
|
||||
|
||||
if compress {
|
||||
zipPath := filepath.Join(outputPath, commonName+".zip")
|
||||
if err := os.MkdirAll(outputPath, 0o755); err != nil {
|
||||
return fmt.Errorf("create %q: %w", outputPath, err)
|
||||
}
|
||||
f, err := os.OpenFile(zipPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %q: %w", zipPath, err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
zw := zip.NewWriter(f)
|
||||
for _, c := range contents {
|
||||
w, err := zw.Create(c.name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write %s to %q: %w", c.name, zipPath, err)
|
||||
}
|
||||
if _, err := io.WriteString(w, c.body); err != nil {
|
||||
return fmt.Errorf("write %s to %q: %w", c.name, zipPath, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return fmt.Errorf("finalise %q: %w", zipPath, err)
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
dir := filepath.Join(outputPath, commonName)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create %q: %w", dir, err)
|
||||
}
|
||||
for _, c := range contents {
|
||||
path := filepath.Join(dir, c.name)
|
||||
if err := os.WriteFile(path, []byte(c.body), c.mode); err != nil {
|
||||
return fmt.Errorf("write %q: %w", path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ssh subcommand
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func sshCmd() *cobra.Command {
|
||||
var (
|
||||
publicKey string
|
||||
principals []string
|
||||
ttl string
|
||||
jsonOut bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "ssh",
|
||||
Short: "Sign an SSH host public key via Vault",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.Load(cfgFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client, err := vault.New(cfg.Vault)
|
||||
if err != nil {
|
||||
return fmt.Errorf("vault auth: %w", err)
|
||||
}
|
||||
|
||||
// Flatten any comma-separated values passed as a single string
|
||||
var flatPrincipals []string
|
||||
for _, p := range principals {
|
||||
for _, part := range strings.Split(p, ",") {
|
||||
if s := strings.TrimSpace(part); s != "" {
|
||||
flatPrincipals = append(flatPrincipals, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
signed, err := ssh.SignHostCert(client, cfg.Vault.MountPoint, cfg.Vault.RoleName, publicKey, flatPrincipals, ttl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sign ssh cert: %w", err)
|
||||
}
|
||||
|
||||
if jsonOut {
|
||||
return json.NewEncoder(os.Stdout).Encode(signed)
|
||||
}
|
||||
|
||||
fmt.Print(signed.SignedKey)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&publicKey, "public-key", "", "SSH public key string (e.g. \"ssh-rsa AAAA...\")")
|
||||
cmd.Flags().StringSliceVar(&principals, "valid-principals", nil, "principals to embed in the certificate (comma-separated or repeated flag)")
|
||||
cmd.Flags().StringVar(&ttl, "ttl", "87600h", "certificate TTL (e.g. 87600h)")
|
||||
cmd.Flags().BoolVar(&jsonOut, "json", false, "print signed key as JSON instead of plain text")
|
||||
_ = cmd.MarkFlagRequired("public-key")
|
||||
_ = cmd.MarkFlagRequired("valid-principals")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
testCert = "-----BEGIN CERTIFICATE-----\nLEAF\n-----END CERTIFICATE-----"
|
||||
testKey = "-----BEGIN RSA PRIVATE KEY-----\nKEY\n-----END RSA PRIVATE KEY-----"
|
||||
testCA = "-----BEGIN CERTIFICATE-----\nCA\n-----END CERTIFICATE-----"
|
||||
)
|
||||
|
||||
type stubOptions struct {
|
||||
approleStatus int
|
||||
k8sStatus int
|
||||
issueStatus int
|
||||
issueDelay time.Duration
|
||||
}
|
||||
|
||||
type capture struct {
|
||||
authPath string
|
||||
authBody map[string]any
|
||||
issueBody map[string]any
|
||||
token string
|
||||
}
|
||||
|
||||
func newVaultStub(t *testing.T, opts stubOptions) (*httptest.Server, *capture) {
|
||||
t.Helper()
|
||||
got := &capture{}
|
||||
|
||||
decode := func(r *http.Request) map[string]any {
|
||||
var m map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&m); err != nil {
|
||||
t.Errorf("decode request body for %s: %v", r.URL.Path, err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
login := func(w http.ResponseWriter, r *http.Request, status int) {
|
||||
got.authPath = r.URL.Path
|
||||
got.authBody = decode(r)
|
||||
if status != http.StatusOK {
|
||||
w.WriteHeader(status)
|
||||
_, _ = fmt.Fprint(w, `{"errors":["permission denied"]}`)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, `{"auth":{"client_token":"s.testtoken"}}`)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v1/auth/approle/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
login(w, r, orOK(opts.approleStatus))
|
||||
})
|
||||
mux.HandleFunc("/v1/auth/k8s/au/syd1/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
login(w, r, orOK(opts.k8sStatus))
|
||||
})
|
||||
mux.HandleFunc("/v1/pki_int/issue/servers_default", func(w http.ResponseWriter, r *http.Request) {
|
||||
if opts.issueDelay > 0 {
|
||||
time.Sleep(opts.issueDelay)
|
||||
}
|
||||
got.token = r.Header.Get("X-Vault-Token")
|
||||
got.issueBody = decode(r)
|
||||
if s := orOK(opts.issueStatus); s != http.StatusOK {
|
||||
w.WriteHeader(s)
|
||||
_, _ = fmt.Fprint(w, `{"errors":["common name not allowed by this role"]}`)
|
||||
return
|
||||
}
|
||||
resp := map[string]any{"data": map[string]string{
|
||||
"certificate": testCert,
|
||||
"private_key": testKey,
|
||||
"issuing_ca": testCA,
|
||||
}}
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
srv := httptest.NewTLSServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, got
|
||||
}
|
||||
|
||||
func orOK(status int) int {
|
||||
if status == 0 {
|
||||
return http.StatusOK
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func writeConfig(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func approleConfig(t *testing.T, addr string, extra ...string) string {
|
||||
t.Helper()
|
||||
return writeConfig(t, "vault:\n"+
|
||||
" addr: "+addr+"\n"+
|
||||
" auth_method: approle\n"+
|
||||
" approle_path: approle\n"+
|
||||
" role_id: test-role-id\n"+
|
||||
" mount_point: pki_int\n"+
|
||||
" role_name: servers_default\n"+
|
||||
strings.Join(extra, ""))
|
||||
}
|
||||
|
||||
// The exact argv Puppet's generate() uses in profiles::pki::vault.
|
||||
func puppetArgs(cfgPath string) []string {
|
||||
return []string{
|
||||
"host.example.com",
|
||||
"--config", cfgPath,
|
||||
"--alt-names", "host,host.example.com",
|
||||
"--ip-sans", "127.0.0.1,10.0.0.5",
|
||||
"--expiry-days", "90",
|
||||
"--json",
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunJSONGolden(t *testing.T) {
|
||||
srv, got := newVaultStub(t, stubOptions{})
|
||||
cfg := approleConfig(t, srv.URL)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := run(puppetArgs(cfg), &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("exit code = %d, stderr = %q", code, stderr.String())
|
||||
}
|
||||
|
||||
want := `{"certificate":"-----BEGIN CERTIFICATE-----\nLEAF\n-----END CERTIFICATE-----",` +
|
||||
`"private_key":"-----BEGIN RSA PRIVATE KEY-----\nKEY\n-----END RSA PRIVATE KEY-----",` +
|
||||
`"full_chain":"-----BEGIN CERTIFICATE-----\nCA\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nLEAF\n-----END CERTIFICATE-----",` +
|
||||
`"ca_certificate":"-----BEGIN CERTIFICATE-----\nCA\n-----END CERTIFICATE-----"}` + "\n"
|
||||
if stdout.String() != want {
|
||||
t.Errorf("stdout mismatch\n got: %q\nwant: %q", stdout.String(), want)
|
||||
}
|
||||
|
||||
// The whole point of --json: parsejson() must be able to read it.
|
||||
var parsed map[string]string
|
||||
if err := json.Unmarshal(stdout.Bytes(), &parsed); err != nil {
|
||||
t.Fatalf("stdout is not valid JSON: %v", err)
|
||||
}
|
||||
if parsed["ca_certificate"] != testCA {
|
||||
t.Errorf("ca_certificate = %q, want issuing_ca", parsed["ca_certificate"])
|
||||
}
|
||||
if parsed["full_chain"] != testCA+"\n"+testCert {
|
||||
t.Errorf("full_chain = %q", parsed["full_chain"])
|
||||
}
|
||||
|
||||
if got.token != "s.testtoken" {
|
||||
t.Errorf("X-Vault-Token = %q", got.token)
|
||||
}
|
||||
if got.authBody["role_id"] != "test-role-id" {
|
||||
t.Errorf("approle body = %v", got.authBody)
|
||||
}
|
||||
if _, ok := got.authBody["secret_id"]; ok {
|
||||
t.Errorf("approle body must not carry secret_id: %v", got.authBody)
|
||||
}
|
||||
wantIssue := map[string]any{
|
||||
"common_name": "host.example.com",
|
||||
"alt_names": "host,host.example.com",
|
||||
"ip_sans": "127.0.0.1,10.0.0.5",
|
||||
"ttl": "90d",
|
||||
}
|
||||
if !reflect.DeepEqual(got.issueBody, wantIssue) {
|
||||
t.Errorf("issue body = %v, want %v", got.issueBody, wantIssue)
|
||||
}
|
||||
}
|
||||
|
||||
// generate() is invoked with combine: true, which dups stderr onto stdout at
|
||||
// spawn. Anything on stderr lands inside the JSON and breaks parsejson().
|
||||
func TestRunSuccessWritesNothingToStderr(t *testing.T) {
|
||||
srv, _ := newVaultStub(t, stubOptions{})
|
||||
cfg := approleConfig(t, srv.URL)
|
||||
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("pipe: %v", err)
|
||||
}
|
||||
orig := os.Stderr
|
||||
os.Stderr = w
|
||||
|
||||
var stdout bytes.Buffer
|
||||
code := run(puppetArgs(cfg), &stdout, os.Stderr)
|
||||
|
||||
os.Stderr = orig
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("close pipe: %v", err)
|
||||
}
|
||||
captured, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatalf("read pipe: %v", err)
|
||||
}
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d", code)
|
||||
}
|
||||
if len(captured) != 0 {
|
||||
t.Errorf("stderr must be empty on the success path, got %q", captured)
|
||||
}
|
||||
if !json.Valid(stdout.Bytes()) {
|
||||
t.Errorf("stdout is not valid JSON: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFailures(t *testing.T) {
|
||||
emptyJWT := filepath.Join(t.TempDir(), "empty-token")
|
||||
if err := os.WriteFile(emptyJWT, []byte(" \n"), 0o600); err != nil {
|
||||
t.Fatalf("write jwt: %v", err)
|
||||
}
|
||||
goodJWT := filepath.Join(t.TempDir(), "token")
|
||||
if err := os.WriteFile(goodJWT, []byte("jwt-value\n"), 0o600); err != nil {
|
||||
t.Fatalf("write jwt: %v", err)
|
||||
}
|
||||
|
||||
k8sConfig := func(t *testing.T, addr, jwtPath string) string {
|
||||
return writeConfig(t, "vault:\n"+
|
||||
" addr: "+addr+"\n"+
|
||||
" auth_method: kubernetes\n"+
|
||||
" k8s_mount: k8s/au/syd1\n"+
|
||||
" k8s_role: puppet_certmanager\n"+
|
||||
" jwt_path: "+jwtPath+"\n"+
|
||||
" mount_point: pki_int\n"+
|
||||
" role_name: servers_default\n")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
opts stubOptions
|
||||
config func(t *testing.T, addr string) string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "approle login rejected",
|
||||
opts: stubOptions{approleStatus: http.StatusForbidden},
|
||||
config: func(t *testing.T, addr string) string {
|
||||
return approleConfig(t, addr)
|
||||
},
|
||||
wantErr: "approle login",
|
||||
},
|
||||
{
|
||||
name: "kubernetes login rejected",
|
||||
opts: stubOptions{k8sStatus: http.StatusForbidden},
|
||||
config: func(t *testing.T, addr string) string {
|
||||
return k8sConfig(t, addr, goodJWT)
|
||||
},
|
||||
wantErr: "kubernetes login",
|
||||
},
|
||||
{
|
||||
name: "kubernetes jwt missing",
|
||||
config: func(t *testing.T, addr string) string {
|
||||
return k8sConfig(t, addr, filepath.Join(t.TempDir(), "absent"))
|
||||
},
|
||||
wantErr: "read service account token",
|
||||
},
|
||||
{
|
||||
name: "kubernetes jwt empty",
|
||||
config: func(t *testing.T, addr string) string {
|
||||
return k8sConfig(t, addr, emptyJWT)
|
||||
},
|
||||
wantErr: "is empty",
|
||||
},
|
||||
{
|
||||
name: "unknown auth method",
|
||||
config: func(t *testing.T, addr string) string {
|
||||
return writeConfig(t, "vault:\n"+
|
||||
" addr: "+addr+"\n"+
|
||||
" auth_method: ldap\n"+
|
||||
" mount_point: pki_int\n"+
|
||||
" role_name: servers_default\n")
|
||||
},
|
||||
wantErr: `unknown vault.auth_method "ldap"`,
|
||||
},
|
||||
{
|
||||
name: "issue rejected",
|
||||
opts: stubOptions{issueStatus: http.StatusBadRequest},
|
||||
config: func(t *testing.T, addr string) string {
|
||||
return approleConfig(t, addr)
|
||||
},
|
||||
wantErr: "vault returned 400",
|
||||
},
|
||||
{
|
||||
name: "vault times out",
|
||||
opts: stubOptions{issueDelay: 400 * time.Millisecond},
|
||||
config: func(t *testing.T, addr string) string {
|
||||
return approleConfig(t, addr, " timeout: 50ms\n")
|
||||
},
|
||||
wantErr: "POST pki_int/issue/servers_default",
|
||||
},
|
||||
{
|
||||
name: "missing config file",
|
||||
config: func(t *testing.T, addr string) string {
|
||||
return filepath.Join(t.TempDir(), "nope.yaml")
|
||||
},
|
||||
wantErr: "open config",
|
||||
},
|
||||
{
|
||||
name: "tls verification enabled rejects the self-signed stub",
|
||||
config: func(t *testing.T, addr string) string {
|
||||
return approleConfig(t, addr, " tls_skip_verify: false\n")
|
||||
},
|
||||
wantErr: "approle login",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv, _ := newVaultStub(t, tc.opts)
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(puppetArgs(tc.config(t, srv.URL)), &stdout, &stderr)
|
||||
|
||||
if code == 0 {
|
||||
t.Fatalf("exit code = 0, want non-zero (stdout %q)", stdout.String())
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("stdout must stay empty on failure, got %q", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), tc.wantErr) {
|
||||
t.Errorf("stderr = %q, want it to mention %q", stderr.String(), tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRequiresCommonName(t *testing.T) {
|
||||
srv, _ := newVaultStub(t, stubOptions{})
|
||||
cfg := approleConfig(t, srv.URL)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := run([]string{"--config", cfg, "--json"}, &stdout, &stderr); code == 0 {
|
||||
t.Fatal("exit code = 0, want non-zero")
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWritesFiles(t *testing.T) {
|
||||
srv, _ := newVaultStub(t, stubOptions{})
|
||||
out := t.TempDir()
|
||||
cfg := approleConfig(t, srv.URL, " output_path: "+out+"\n")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := run([]string{"host.example.com", "--config", cfg}, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("exit code != 0: %s", stderr.String())
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
"certificate.crt": testCert,
|
||||
"private.key": testKey,
|
||||
"full_chain.crt": testCA + "\n" + testCert,
|
||||
"ca_certificate.crt": testCA,
|
||||
}
|
||||
for name, body := range want {
|
||||
got, err := os.ReadFile(filepath.Join(out, "host.example.com", name))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
if string(got) != body {
|
||||
t.Errorf("%s = %q, want %q", name, got, body)
|
||||
}
|
||||
}
|
||||
|
||||
info, err := os.Stat(filepath.Join(out, "host.example.com", "private.key"))
|
||||
if err != nil {
|
||||
t.Fatalf("stat private.key: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("private.key mode = %v, want 0600", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCompress(t *testing.T) {
|
||||
srv, _ := newVaultStub(t, stubOptions{})
|
||||
out := t.TempDir()
|
||||
cfg := approleConfig(t, srv.URL, " output_path: "+out+"\n")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := run([]string{"host.example.com", "--config", cfg, "-c"}, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("exit code != 0: %s", stderr.String())
|
||||
}
|
||||
|
||||
zr, err := zip.OpenReader(filepath.Join(out, "host.example.com.zip"))
|
||||
if err != nil {
|
||||
t.Fatalf("open zip: %v", err)
|
||||
}
|
||||
defer func() { _ = zr.Close() }()
|
||||
|
||||
var names []string
|
||||
for _, f := range zr.File {
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
wantNames := []string{"certificate.crt", "private.key", "full_chain.crt", "ca_certificate.crt"}
|
||||
if !reflect.DeepEqual(names, wantNames) {
|
||||
t.Errorf("zip entries = %v, want %v", names, wantNames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseList(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"empty", "", nil},
|
||||
{"single", "host", []string{"host"}},
|
||||
{"puppet alt names", "host,host.example.com", []string{"host", "host.example.com"}},
|
||||
{"puppet ip sans", "127.0.0.1,10.0.0.5", []string{"127.0.0.1", "10.0.0.5"}},
|
||||
{"trailing comma", "host,", []string{"host"}},
|
||||
{"leading comma", ",host", []string{"host"}},
|
||||
{"double comma", "a,,b", []string{"a", "b"}},
|
||||
{"only commas", ",,,", nil},
|
||||
{"whitespace trimmed", " a , b ", []string{"a", "b"}},
|
||||
{"whitespace only token", "a, ,b", []string{"a", "b"}},
|
||||
{"tabs and newlines", "a,\tb\n", []string{"a", "b"}},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := parseList(tc.in); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("parseList(%q) = %#v, want %#v", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletionSubcommand(t *testing.T) {
|
||||
for _, shell := range []string{"bash", "zsh", "fish"} {
|
||||
t.Run(shell, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := run([]string{"completion", shell}, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("exit code = %d, stderr = %q", code, stderr.String())
|
||||
}
|
||||
if stdout.Len() == 0 {
|
||||
t.Error("completion script is empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user