16 Commits

Author SHA1 Message Date
benvin 2ba3e30bc3 Merge pull request 'Fail a bare invocation when stdout is not a terminal' (#4) from benvin/fix-invocation-guards into master
Reviewed-on: #4
2026-09-24 21:14:38 +10:00
unkin-agent d39ca1e3a6 Rewrap the isTerminal comment
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/integration Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-09-22 23:49:34 +10:00
unkin-agent 0860da03c4 Document the untagged hermetic-env helper
ci/woodpecker/pr/build Pipeline was canceled
ci/woodpecker/pr/integration Pipeline was canceled
ci/woodpecker/pr/pre-commit Pipeline was canceled
ci/woodpecker/pr/test Pipeline was canceled
2026-09-22 23:49:06 +10:00
unkin-agent 173a82f29e Pin x/term v0.45.0 and x/sys v0.47.0
Newest pair still declaring go 1.25.0; v0.46.0/v0.48.0 declare
go 1.26.0 and break the golang:1.25 CI images.
2026-09-22 23:48:59 +10:00
unkin-agent ddd93a8b71 Assert the hermetic env replaces the ambient one, not extends it
The shape test passed on `return os.Environ()`; only a Vault-backed
test caught it. Move the helper and its test out from behind the
integration tag and reject any entry outside PATH/HOME/XDG_CONFIG_HOME.
2026-09-22 23:48:59 +10:00
unkin-agent 21990d9297 Pass an explicit empty argv so cobra cannot reach os.Args
A nil argv lets cobra fall back to os.Args[1:], which under
`go test -args foo` turns the bare-invocation cases into
`certmanager foo` and resolves /opt/certmanager/config.yaml. Assert
the bare-invocation error on stderr so the fallback cannot hide.

Puppet gives the child a temp file as fd 1, not a pipe; say so.
2026-09-22 23:48:59 +10:00
unkin-agent b0099991c0 Poison both config legs the hermetic env has to strip
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/integration Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-09-22 23:29:32 +10:00
unkin-agent c6c3f6a092 Pin the invocation gate against every kind of stdout 2026-09-22 23:29:32 +10:00
unkin-agent 30eee88d95 Detect a terminal with an ioctl, not a character-device stat
/dev/null is a character device, and so is the /dev/null the Go runtime
reopens onto a closed fd 1, so a bare invocation printed help and exited
zero for both -- the two shapes a machine caller is most likely to hand
the binary.
2026-09-22 23:29:32 +10:00
unkin-agent 5a7fea56e2 Document the interactive seam in AGENTS.md
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/integration Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-09-22 22:58:52 +10:00
unkin-agent 4ff55f052a Fail a bare invocation when stdout is not a terminal
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/integration Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline failed
Puppet's generate() reads stdout through a pipe with combine: true, so help
text on a zero exit lands in the catalog. Help now requires an interactive
stdout; a piped one exits non-zero with nothing on stdout.

- gate the no-args help path on an isTerminal(stdout) check
- reject an explicitly empty --config instead of resolving it
- ignore a relative XDG_CONFIG_HOME, per the XDG spec
- give the integration subprocess a hermetic HOME/XDG_CONFIG_HOME
- pass []string{} to run(), so cobra cannot fall back to os.Args
2026-09-22 22:50:05 +10:00
benvin f7c8ca5fba Merge pull request 'Resolve the config path; print help on a bare invocation' (#3) from benvin/legacy-invocation into master
Reviewed-on: #3
2026-09-22 22:41:09 +10:00
unkin-agent 6042499e25 Resolve the config path; print help on a bare invocation
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/integration Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Fall back to $XDG_CONFIG_HOME and $HOME/.config before the baked-in
/opt/certmanager/config.yaml, and stop failing when called with no args.
2026-09-22 22:34:51 +10:00
benvin 72337bf0bb Merge pull request 'Source the CA from /ca_chain, not from issuance' (#2) from benvin/ca-chain-endpoint into master
ci/woodpecker/tag/release Pipeline was successful
Reviewed-on: #2
2026-09-19 14:36:34 +10:00
unkin-agent 4b2d020c86 Bound the unreachable-vault test to 200ms
ci/woodpecker/pr/integration Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
The default 30s Vault timeout outlasts the pre-commit hook's
go test -timeout 30s, so a slow dial fails the whole package.
2026-09-19 13:32:52 +10:00
unkin-agent b0aad96673 Source the CA from /ca_chain, not from issuance
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/integration Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline failed
Deriving ca_certificate from an issue response welds CA distribution to
per-node renewal, so a rotation takes ~90 days to reach the estate, and
issuing_ca is the intermediate alone, which strict path builders reject
as a CAfile.

- add a `ca` subcommand printing {mount}/ca_chain as PEM, unauthenticated
- drop ca_certificate from the issue document and its file output
- order full_chain leaf first
- require only vault.addr for the CA path via config.LoadCA
2026-09-19 13:27:47 +10:00
17 changed files with 1944 additions and 100 deletions
+39 -8
View File
@@ -31,8 +31,8 @@ cmd/certmanager/main.go # CLI (flattened: the positional arg is the comman
cmd/certmanager/main_test.go # end-to-end tests against an httptest Vault stub
test/integration/ # end-to-end tests against a real Vault dev server
internal/config/ # YAML config: parsing, defaults, validation
internal/pki/ # issue request/response + the JSON output shape
internal/vault/ # authenticated HTTP client (approle, kubernetes)
internal/pki/ # issue + ca_chain requests and the output shapes
internal/vault/ # HTTP client: approle, kubernetes, unauthenticated
Makefile # build / test / lint / completions / version bump
.goreleaser.yaml # single release driver: binaries + RPM + Gitea release
scripts/integration-test.sh # runs the integration test against a Vault dev server
@@ -44,9 +44,21 @@ dist/ # build output: binaries, RPM, checksums (not comm
- **`run(args, stdout, stderr) int`** is the testable entry point; `main()` only
wires it to the real streams and `os.Exit`. Cobra runs with `SilenceUsage` and
`SilenceErrors` so nothing reaches stderr unless the run failed.
`SilenceErrors` so nothing reaches stderr unless the run failed. `run` derives
interactivity from `isTerminal(stdout)` and hands it to `runWith`, which tests
call directly to pin either side. `isTerminal` is a terminal ioctl
(`x/term.IsTerminal`), not a character-device stat: `/dev/null` and the
`/dev/null` the runtime reopens onto a closed fd 1 are character devices, and
a stat would hand both of them the help-and-exit-zero path.
- **Flat CLI**: the root command takes `common_name` positionally
(`cobra.ExactArgs(1)`). Only `completion` and `help` are subcommands.
(`cobra.MaximumNArgs(1)`); no args prints help only when stdout is a terminal
and otherwise fails, args-less flags fail. `ca`, `completion` and `help` are
the only subcommands; `--config` is persistent so `ca` shares it, and an
explicitly empty `--config` is rejected.
- **`ca` is unauthenticated**: `GET {addr}/v1/{mount}/ca_chain` sends no
`X-Vault-Token` and loads the config through `config.LoadCA`, which requires
only `vault.addr`. The body is parsed as PEM, never sniffed — Vault labels it
`application/pkix-cert`. It prints raw PEM: Puppet uses it as file content.
- **`parseList`** splits comma lists, trims whitespace, drops empty tokens.
- **`config.Load`** applies defaults (`auth_method: approle`, `jwt_path`) then
`Validate()`, which names the offending key and rejects an unknown
@@ -54,13 +66,20 @@ dist/ # build output: binaries, RPM, checksums (not comm
- **`vault.New`** authenticates once and never falls back between auth methods.
Every request is bounded by `vault.timeout` (default 30s) — the Python set no
timeout, so a black-holed Vault hung the whole catalog compile.
- **`config.defaultPath`** is baked in at build time via
`-X ...internal/config.defaultPath=` (`make build CONFIG_PATH=...`).
- **`config.Resolve`** picks the config: `--config`, then
`$XDG_CONFIG_HOME/certmanager/config.yaml`, then
`$HOME/.config/certmanager/config.yaml` (both only if the file exists), then
`config.FallbackPath()` — baked in at build time via
`-X ...internal/config.defaultPath=` (`make build CONFIG_PATH=...`). Under
`generate()` the puppet user has no usable home, so the baked path is what
resolves.
## Deliberate deviations from the Python script
- Emits `ca_certificate`; the Python did not, so Puppet's
`/etc/pki/tls/vault/ca_certificate.crt` was silently never populated.
- The CA is served by `certmanager ca` from the mount's `/ca_chain`, not welded
to issuance. `issuing_ca` is the intermediate only, and deriving the CA from
an issue response would pin CA rotation to each node's 90 day renewal.
- `full_chain` is leaf first; the Python put the issuer first.
- `output_path` is read from under `vault:` where it is actually written. The
Python declared it at the top level but read it from the unwrapped `vault`
dict, so it never resolved.
@@ -91,6 +110,18 @@ four JSON keys, the parsed X.509 CN/SANs/lifetime, and that a refused name
exits non-zero with empty stdout. Dev mode is plain HTTP, so it does not
exercise the TLS path.
`hermeticEnv`, the subprocess environment those tests share, sits outside the
tag in `hermetic_env_test.go` so a plain `go test ./...` guards it. It replaces
the ambient environment rather than extending it; its test asserts exactly
that, because a helper that leaked `HOME` would point the binary at a real
Vault and only the tagged tests would notice.
`TestBareInvocationHelpsOnlyATerminal` drives the built binary once per kind of
fd 1 — pipe, regular file, unix socket, `/dev/null`, a closed fd 1 and a pty
allocated from `/dev/ptmx` — and only the pty may exit zero with help on
stdout. The pty is what pins the true branch: CI containers have no
controlling terminal, so a test that reached for `/dev/tty` would skip.
## Gotchas
- Changing the JSON key set or key order breaks `parsejson()` in
+84 -19
View File
@@ -19,9 +19,21 @@ failure path exits non-zero.
## Usage
```
certmanager <common_name> [flags]
certmanager <common_name> [flags] # issue a certificate
certmanager ca [flags] # print the CA chain
certmanager # print help (interactive terminals only)
```
The common name is positional, with no `pki` subcommand in front of it — that
is the form Puppet calls, and it is the only issuing form. `ca`, `completion`
and `help` are the subcommands; every other first argument is a common name.
A bare invocation prints help and exits zero only when stdout is a terminal —
a pipe, a file, a socket, `/dev/null` and a closed fd 1 all exit non-zero with
nothing on stdout. Puppet's `generate()` collects stdout through a temp file,
so there a missing common name fails the compile rather than feeding help text
into the catalog.
| Flag | Short | Type | Default | Description |
|---|---|---|---|---|
| `--alt-names` | `-a` | comma list | `""` | DNS subject alternative names |
@@ -29,7 +41,13 @@ certmanager <common_name> [flags]
| `--expiry-days` | `-e` | int | `365` | Certificate validity in days (`ttl: <n>d`) |
| `--compress` | `-c` | bool | `false` | Write a zip instead of a directory |
| `--json` | | bool | `false` | Print the certificate data as JSON |
| `--config` | | path | `/opt/certmanager/config.yaml` | Config file location |
| `--config` | | path | resolved (see below) | Config file location |
`ca` takes `--config` plus:
| Flag | Short | Type | Default | Description |
|---|---|---|---|---|
| `--mount` | `-m` | string | `vault.mount_point` | PKI mount to read |
Comma lists are split on `,`, whitespace-trimmed, and empty tokens dropped.
@@ -39,15 +57,15 @@ Comma lists are split on `,`, whitespace-trimmed, and empty tokens dropped.
{
"certificate": "<PEM leaf>",
"private_key": "<PEM key>",
"full_chain": "<issuing_ca PEM>\n<certificate PEM>",
"ca_certificate": "<issuing_ca PEM>"
"full_chain": "<certificate PEM>\n<issuing_ca PEM>"
}
```
`ca_certificate` is new. `profiles::pki::vault` writes
`$cert_data['ca_certificate']` to `/etc/pki/tls/vault/ca_certificate.crt`, but
the Python never emitted the key, so Puppet saw `undef` and the file was never
populated.
`full_chain` is leaf first, the order every TLS server expects.
The CA is not part of this document. It is estate-wide and public, so binding
it to a node's issuance would pin CA distribution to that node's renewal cycle;
`certmanager ca` fetches it instead.
### File output
@@ -58,27 +76,70 @@ Without `--json` the certificate is written under `vault.output_path`
|---|---|
| `<output_path>/<common_name>/certificate.crt` | Signed certificate |
| `<output_path>/<common_name>/private.key` | Private key (mode 0600) |
| `<output_path>/<common_name>/full_chain.crt` | Issuing CA + certificate |
| `<output_path>/<common_name>/ca_certificate.crt` | Issuing CA certificate |
| `<output_path>/<common_name>/full_chain.crt` | Certificate + issuing CA |
With `-c` the same four files are written into
With `-c` the same three files are written into
`<output_path>/<common_name>.zip`.
## The `ca` subcommand
```
certmanager ca [--mount <pki_mount>] [--config <path>]
```
Prints the mount's CA chain to stdout as raw PEM — not JSON, because Puppet
uses it as file content directly:
```puppet
file { '/etc/pki/tls/vault/ca_certificate.crt':
content => generate('/usr/local/bin/certmanager', 'ca'),
}
```
It reads `GET {addr}/v1/{mount}/ca_chain` unauthenticated: no AppRole login, no
Kubernetes login, no token header. A config carrying only `addr` and
`mount_point` is enough, and `--mount` overrides the configured mount.
`/ca_chain` returns the intermediate **and** the root, unlike `issuing_ca` or
`/ca/pem`, which return the intermediate alone. An intermediate-only `CAfile`
is not self-sufficient: strict path builders that require a chain to a
self-signed root reject it.
The endpoint advertises `application/pkix-cert` while serving PEM, so the body
is parsed rather than sniffed. A non-200, an empty body or anything that is not
a PEM certificate chain exits non-zero with the cause on stderr and nothing on
stdout.
## Configuration
YAML, everything nested under `vault:`. The path is baked in at build time
(`make build CONFIG_PATH=...`) and overridable per-invocation with `--config`.
YAML, everything nested under `vault:`.
### Config resolution order
1. `--config <path>`, which always wins; an empty `--config ''` is an error,
not a request to resolve;
2. `$XDG_CONFIG_HOME/certmanager/config.yaml`, if `$XDG_CONFIG_HOME` is
absolute and the file exists;
3. `$HOME/.config/certmanager/config.yaml`, if the file exists;
4. `/opt/certmanager/config.yaml`, the path baked in at build time
(`make build CONFIG_PATH=...`).
Steps 2 and 3 are skipped unless the file is really there, so the last step is
what Puppet gets: `generate()` runs the binary as the `puppet` user with no
usable home directory, and `profiles::helpers::certmanager` writes the config
to `/opt/certmanager/config.yaml` on managed nodes. The k8s puppetserver
compilers mount it at the same path.
| Key | Required | Default | Notes |
|---|---|---|---|
| `addr` | yes | | e.g. `https://vault.service.consul:8200` |
| `auth_method` | no | `approle` | `approle` or `kubernetes` |
| `mount_point` | yes | | e.g. `pki_int` |
| `role_name` | yes | | e.g. `servers_default` |
| `role_id` | iff `approle` | | |
| `approle_path` | iff `approle` | | e.g. `approle` |
| `k8s_mount` | iff `kubernetes` | | e.g. `k8s/au/syd1` |
| `k8s_role` | iff `kubernetes` | | e.g. `puppet_certmanager` |
| `role_name` | iff issuing | | e.g. `servers_default` |
| `role_id` | iff `approle` and issuing | | |
| `approle_path` | iff `approle` and issuing | | e.g. `approle` |
| `k8s_mount` | iff `kubernetes` and issuing | | e.g. `k8s/au/syd1` |
| `k8s_role` | iff `kubernetes` and issuing | | e.g. `puppet_certmanager` |
| `jwt_path` | no | `/var/run/secrets/kubernetes.io/serviceaccount/token` | |
| `output_path` | no | `.` | Non-JSON output directory |
| `tls_skip_verify` | no | `true` | Set `false` once every node trusts the CA |
@@ -95,6 +156,8 @@ vault:
output_path: /tmp/certmanager
```
`ca` requires only `addr` and a mount; every auth key above is optional for it.
AppRole logs in with `role_id` only — the Vault role sets
`bind_secret_id: false`. Kubernetes reads the JWT from `jwt_path` and posts it
with `k8s_role`. A missing, unreadable or empty JWT, a rejected login, or an
@@ -115,7 +178,9 @@ make release-snapshot # goreleaser: binaries, tar.gz and RPM into dist/
`make integration-test` starts a `hashicorp/vault` dev container with
docker/podman, provisions a PKI mount and an approle role, then runs the built
binary with the exact argv Puppet uses and asserts on the issued certificate.
binary with the exact argv Puppet uses and asserts on the issued certificate. A
root mount signs an intermediate so the CA chain under test is the two
certificate shape production serves.
Point it at an existing dev server with `CERTMANAGER_IT_VAULT_ADDR`
(`VAULT_ADDR` is deliberately ignored — the test writes to the PKI mount).
+101 -6
View File
@@ -10,6 +10,7 @@ import (
"strings"
"github.com/spf13/cobra"
"golang.org/x/term"
"git.unkin.net/unkin/certmanager/internal/config"
"git.unkin.net/unkin/certmanager/internal/pki"
@@ -26,7 +27,11 @@ func main() {
// 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()
return runWith(args, stdout, stderr, isTerminal(stdout))
}
func runWith(args []string, stdout, stderr io.Writer, interactive bool) int {
cmd := rootCmd(interactive)
cmd.SetArgs(args)
cmd.SetOut(stdout)
cmd.SetErr(stderr)
@@ -38,7 +43,29 @@ func run(args []string, stdout, stderr io.Writer) int {
return 0
}
func rootCmd() *cobra.Command {
// isTerminal reports whether w is a terminal. Puppet hands the child a temp
// file as fd 1, never a terminal, so anything else is a machine caller. The
// check is a terminal ioctl, not a character-device stat: /dev/null is a
// character device, and so is the /dev/null the Go runtime reopens onto a
// closed fd 1 at startup.
func isTerminal(w io.Writer) bool {
f, ok := w.(*os.File)
if !ok {
return false
}
return term.IsTerminal(int(f.Fd()))
}
// resolveConfig turns the --config value into a path. An explicitly empty
// --config is a malformed call, not a request to resolve.
func resolveConfig(cmd *cobra.Command, explicit string) (string, error) {
if explicit == "" && cmd.Flags().Changed("config") {
return "", fmt.Errorf("--config requires a path")
}
return config.Resolve(explicit), nil
}
func rootCmd(interactive bool) *cobra.Command {
var (
cfgFile string
altNames string
@@ -52,11 +79,24 @@ func rootCmd() *cobra.Command {
Use: "certmanager <common_name>",
Short: "Issue a PKI certificate from HashiCorp Vault",
Version: version,
Args: cobra.ExactArgs(1),
Args: cobra.MaximumNArgs(1),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(cfgFile)
if len(args) == 0 {
// Help only for a human at a terminal: under generate() an
// argv that lost its common name must fail the compile.
if interactive && cmd.Flags().NFlag() == 0 {
return cmd.Help()
}
return fmt.Errorf("a common name is required")
}
path, err := resolveConfig(cmd, cfgFile)
if err != nil {
return err
}
cfg, err := config.Load(path)
if err != nil {
return err
}
@@ -79,13 +119,69 @@ func rootCmd() *cobra.Command {
},
}
cmd.Flags().StringVar(&cfgFile, "config", config.DefaultPath(), "path to config.yaml")
// Persistent so the ca subcommand shares it; empty means "resolve", which
// keeps the resolution order out of the usage text and out of --help.
cmd.PersistentFlags().StringVar(&cfgFile, "config", "",
"path to config.yaml (default: $XDG_CONFIG_HOME or ~/.config, else "+config.FallbackPath()+")")
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")
cmd.AddCommand(caCmd(&cfgFile))
return cmd
}
// caCmd prints the mount's CA chain. Distributing the CA this way decouples it
// from certificate issuance, so a CA rotation reaches every node on the next
// catalog compile instead of waiting out a 90 day renewal cycle.
func caCmd(cfgFile *string) *cobra.Command {
var mount string
cmd := &cobra.Command{
Use: "ca",
Short: "Print the PKI mount's CA chain (intermediate and root) as PEM",
Args: cobra.NoArgs,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, _ []string) error {
path, err := resolveConfig(cmd, *cfgFile)
if err != nil {
return err
}
cfg, err := config.LoadCA(path)
if err != nil {
return err
}
mountPoint := cfg.Vault.MountPoint
if mount != "" {
mountPoint = mount
}
if mountPoint == "" {
return fmt.Errorf("vault.mount_point is required (or pass --mount)")
}
client, err := vault.NewUnauthenticated(cfg.Vault)
if err != nil {
return err
}
// The chain is fully validated before a byte reaches stdout: a
// half-written document would break the catalog compile.
chain, err := pki.FetchCAChain(client, mountPoint)
if err != nil {
return err
}
_, err = io.WriteString(cmd.OutOrStdout(), chain)
return err
},
}
cmd.Flags().StringVarP(&mount, "mount", "m", "", "PKI mount to read (default: vault.mount_point)")
return cmd
}
@@ -123,7 +219,6 @@ func writeFiles(outputPath, commonName string, compress bool, cert *pki.Certific
{"certificate.crt", cert.Certificate, 0o644},
{"private.key", cert.PrivateKey, 0o600},
{"full_chain.crt", cert.FullChain, 0o644},
{"ca_certificate.crt", cert.CACertificate, 0o644},
}
if compress {
+569 -12
View File
@@ -3,9 +3,17 @@ package main
import (
"archive/zip"
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"math/big"
"net"
"net/http"
"net/http/httptest"
"os"
@@ -14,6 +22,8 @@ import (
"strings"
"testing"
"time"
"git.unkin.net/unkin/certmanager/internal/config"
)
const (
@@ -27,6 +37,12 @@ type stubOptions struct {
k8sStatus int
issueStatus int
issueDelay time.Duration
caStatus int
// caBody overrides the CA chain response; caBodySet distinguishes an
// intentional empty body from "unset".
caBody string
caBodySet bool
}
type capture struct {
@@ -34,11 +50,50 @@ type capture struct {
authBody map[string]any
issueBody map[string]any
token string
caPath string
caRequested bool
// caToken records the header verbatim; the CA endpoint must be called
// without one.
caToken string
caTokenSet bool
// chain is the PEM the stub serves from /ca_chain.
chain string
}
// caChainPEM is a real two certificate PEM chain, since the ca path parses what
// Vault returns rather than trusting the content type.
func caChainPEM(t *testing.T) string {
t.Helper()
return selfSignedPEM(t, "test intermediate") + selfSignedPEM(t, "test root")
}
func selfSignedPEM(t *testing.T, cn string) string {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: cn},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
IsCA: true,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageCertSign,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("create certificate: %v", err)
}
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
}
func newVaultStub(t *testing.T, opts stubOptions) (*httptest.Server, *capture) {
t.Helper()
got := &capture{}
defaultChain := caChainPEM(t)
got := &capture{chain: defaultChain}
decode := func(r *http.Request) map[string]any {
var m map[string]any
@@ -83,6 +138,26 @@ func newVaultStub(t *testing.T, opts stubOptions) (*httptest.Server, *capture) {
}}
_ = json.NewEncoder(w).Encode(resp)
})
mux.HandleFunc("/v1/pki_int/ca_chain", func(w http.ResponseWriter, r *http.Request) {
got.caRequested = true
got.caPath = r.URL.Path
got.caToken = r.Header.Get("X-Vault-Token")
_, got.caTokenSet = r.Header["X-Vault-Token"]
if s := orOK(opts.caStatus); s != http.StatusOK {
w.WriteHeader(s)
_, _ = fmt.Fprint(w, `{"errors":["unsupported path"]}`)
return
}
body := opts.caBody
if !opts.caBodySet {
body = defaultChain
}
// Vault advertises pkix-cert while serving PEM; the stub does too, so
// nothing downstream may sniff the content type.
w.Header().Set("Content-Type", "application/pkix-cert")
_, _ = io.WriteString(w, body)
})
srv := httptest.NewTLSServer(mux)
t.Cleanup(srv.Close)
@@ -140,8 +215,7 @@ func TestRunJSONGolden(t *testing.T) {
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"
`"full_chain":"-----BEGIN CERTIFICATE-----\nLEAF\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nCA\n-----END CERTIFICATE-----"}` + "\n"
if stdout.String() != want {
t.Errorf("stdout mismatch\n got: %q\nwant: %q", stdout.String(), want)
}
@@ -151,11 +225,18 @@ func TestRunJSONGolden(t *testing.T) {
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 _, ok := parsed["ca_certificate"]; ok {
t.Error("ca_certificate must not be emitted; the CA comes from the ca subcommand")
}
if parsed["full_chain"] != testCA+"\n"+testCert {
t.Errorf("full_chain = %q", parsed["full_chain"])
if len(parsed) != 3 {
t.Errorf("document has %d keys, want exactly 3: %v", len(parsed), parsed)
}
// Leaf first, issuer after -- asserted as an ordering, not a membership.
if parsed["full_chain"] != testCert+"\n"+testCA {
t.Errorf("full_chain = %q, want leaf then issuing CA", parsed["full_chain"])
}
if strings.Index(parsed["full_chain"], "LEAF") > strings.Index(parsed["full_chain"], "\nCA\n") {
t.Errorf("full_chain puts the CA ahead of the leaf: %q", parsed["full_chain"])
}
if got.token != "s.testtoken" {
@@ -357,10 +438,12 @@ func TestRunWritesFiles(t *testing.T) {
}
want := map[string]string{
"certificate.crt": testCert,
"private.key": testKey,
"full_chain.crt": testCA + "\n" + testCert,
"ca_certificate.crt": testCA,
"certificate.crt": testCert,
"private.key": testKey,
"full_chain.crt": testCert + "\n" + testCA,
}
if _, err := os.Stat(filepath.Join(out, "host.example.com", "ca_certificate.crt")); !os.IsNotExist(err) {
t.Errorf("ca_certificate.crt must no longer be written by the issue path (stat err = %v)", err)
}
for name, body := range want {
got, err := os.ReadFile(filepath.Join(out, "host.example.com", name))
@@ -401,7 +484,7 @@ func TestRunCompress(t *testing.T) {
for _, f := range zr.File {
names = append(names, f.Name)
}
wantNames := []string{"certificate.crt", "private.key", "full_chain.crt", "ca_certificate.crt"}
wantNames := []string{"certificate.crt", "private.key", "full_chain.crt"}
if !reflect.DeepEqual(names, wantNames) {
t.Errorf("zip entries = %v, want %v", names, wantNames)
}
@@ -448,3 +531,477 @@ func TestCompletionSubcommand(t *testing.T) {
})
}
}
// authlessConfig carries only addr and mount_point: the CA chain endpoint is
// public, so the ca subcommand must not need auth keys to be present.
func authlessConfig(t *testing.T, addr string, extra ...string) string {
t.Helper()
return writeConfig(t, "vault:\n"+
" addr: "+addr+"\n"+
" mount_point: pki_int\n"+
strings.Join(extra, ""))
}
func TestRunCAPrintsChain(t *testing.T) {
srv, got := newVaultStub(t, stubOptions{})
cfg := authlessConfig(t, srv.URL)
var stdout, stderr bytes.Buffer
if code := run([]string{"ca", "--config", cfg}, &stdout, &stderr); code != 0 {
t.Fatalf("exit code = %d, stderr = %q", code, stderr.String())
}
if got.caPath != "/v1/pki_int/ca_chain" {
t.Errorf("requested path = %q, want /v1/pki_int/ca_chain", got.caPath)
}
// Unauthenticated: the header must be absent, not merely empty.
if got.caTokenSet {
t.Errorf("X-Vault-Token must not be sent to the CA endpoint, got %q", got.caToken)
}
if got.issueBody != nil {
t.Error("the ca subcommand must not issue a certificate")
}
want := strings.TrimSpace(got.chain) + "\n"
if stdout.String() != want {
t.Errorf("stdout = %q, want the PEM chain %q", stdout.String(), want)
}
if n := strings.Count(stdout.String(), "-----BEGIN CERTIFICATE-----"); n != 2 {
t.Errorf("stdout carries %d certificates, want 2", n)
}
if stderr.Len() != 0 {
t.Errorf("stderr = %q, want empty", stderr.String())
}
}
// "ca" is the subcommand, never a common name to issue for.
func TestRunCAIsNotTreatedAsACommonName(t *testing.T) {
srv, got := newVaultStub(t, stubOptions{})
cfg := approleConfig(t, srv.URL)
var stdout, stderr bytes.Buffer
if code := run([]string{"ca", "--config", cfg}, &stdout, &stderr); code != 0 {
t.Fatalf("exit code = %d, stderr = %q", code, stderr.String())
}
if !got.caRequested {
t.Error("the CA chain endpoint was never called")
}
if got.issueBody != nil {
t.Errorf("issuance was attempted for common name %v", got.issueBody["common_name"])
}
if got.authPath != "" {
t.Errorf("the ca subcommand logged in at %q; it must not authenticate", got.authPath)
}
}
// The positional issue form must keep parsing unchanged now that a subcommand
// sits alongside it.
func TestRunPositionalFormStillParses(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())
}
if got.issueBody["common_name"] != "host.example.com" {
t.Errorf("common_name = %v", got.issueBody["common_name"])
}
if got.caRequested {
t.Error("the issue path must not call the CA chain endpoint")
}
}
func TestRunCAMountFlagOverridesConfig(t *testing.T) {
srv, got := newVaultStub(t, stubOptions{})
cfg := writeConfig(t, "vault:\n addr: "+srv.URL+"\n")
var stdout, stderr bytes.Buffer
if code := run([]string{"ca", "--config", cfg, "--mount", "pki_int"}, &stdout, &stderr); code != 0 {
t.Fatalf("exit code = %d, stderr = %q", code, stderr.String())
}
if got.caPath != "/v1/pki_int/ca_chain" {
t.Errorf("requested path = %q", got.caPath)
}
}
func TestRunCASuccessWritesNothingToStderr(t *testing.T) {
srv, _ := newVaultStub(t, stubOptions{})
cfg := authlessConfig(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([]string{"ca", "--config", 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 byte-empty on the ca success path, got %q", captured)
}
if stdout.Len() == 0 {
t.Error("stdout is empty")
}
}
func TestRunCAFailures(t *testing.T) {
tests := []struct {
name string
opts stubOptions
config func(t *testing.T, addr string) string
args func(cfg string) []string
wantErr string
}{
{
name: "vault rejects the request",
opts: stubOptions{caStatus: http.StatusForbidden},
config: func(t *testing.T, addr string) string { return authlessConfig(t, addr) },
wantErr: "vault returned 403",
},
{
name: "mount does not exist",
opts: stubOptions{caStatus: http.StatusNotFound},
config: func(t *testing.T, addr string) string { return authlessConfig(t, addr) },
wantErr: "vault returned 404",
},
{
name: "empty body",
opts: stubOptions{caBody: "", caBodySet: true},
config: func(t *testing.T, addr string) string { return authlessConfig(t, addr) },
wantErr: "empty CA chain",
},
{
name: "whitespace only body",
opts: stubOptions{caBody: "\n \n", caBodySet: true},
config: func(t *testing.T, addr string) string { return authlessConfig(t, addr) },
wantErr: "empty CA chain",
},
{
name: "body is not PEM",
opts: stubOptions{caBody: "<html>nope</html>", caBodySet: true},
config: func(t *testing.T, addr string) string { return authlessConfig(t, addr) },
wantErr: "not a PEM certificate chain",
},
{
name: "PEM block is not a certificate",
opts: stubOptions{caBody: "-----BEGIN PUBLIC KEY-----\nAAAA\n-----END PUBLIC KEY-----\n", caBodySet: true},
config: func(t *testing.T, addr string) string { return authlessConfig(t, addr) },
wantErr: `not a CERTIFICATE`,
},
{
name: "PEM certificate is corrupt",
opts: stubOptions{caBody: "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n", caBodySet: true},
config: func(t *testing.T, addr string) string { return authlessConfig(t, addr) },
wantErr: "parse certificate 1",
},
{
name: "missing config file",
config: func(t *testing.T, _ string) string {
return filepath.Join(t.TempDir(), "nope.yaml")
},
wantErr: "open config",
},
{
name: "config without addr",
config: func(t *testing.T, _ string) string {
return writeConfig(t, "vault:\n mount_point: pki_int\n")
},
wantErr: "vault.addr is required",
},
{
name: "no mount anywhere",
config: func(t *testing.T, addr string) string {
return writeConfig(t, "vault:\n addr: "+addr+"\n")
},
wantErr: "vault.mount_point is required",
},
{
name: "vault is unreachable",
config: func(t *testing.T, _ string) string {
return authlessConfig(t, "https://127.0.0.1:1", " timeout: 200ms\n")
},
wantErr: "GET pki_int/ca_chain",
},
{
name: "ca takes no positional arguments",
config: func(t *testing.T, addr string) string {
return authlessConfig(t, addr)
},
args: func(cfg string) []string { return []string{"ca", "extra", "--config", cfg} },
wantErr: "unknown command",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
srv, _ := newVaultStub(t, tc.opts)
cfg := tc.config(t, srv.URL)
args := []string{"ca", "--config", cfg}
if tc.args != nil {
args = tc.args(cfg)
}
var stdout, stderr bytes.Buffer
code := run(args, &stdout, &stderr)
if code == 0 {
t.Fatalf("exit code = 0, want non-zero (stdout %q)", stdout.String())
}
// A half-written PEM would be spliced into the catalog.
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)
}
})
}
}
// A bare invocation at a terminal is a human asking what this is: print help
// and exit zero rather than failing on the missing positional.
func TestRunNoArgsOnATerminalPrintsHelp(t *testing.T) {
var stdout, stderr bytes.Buffer
if code := runWith([]string{}, &stdout, &stderr, true); code != 0 {
t.Fatalf("exit code = %d, stderr = %q", code, stderr.String())
}
if !strings.Contains(stdout.String(), "Usage:") {
t.Errorf("stdout = %q, want the help text", stdout.String())
}
if !strings.Contains(stdout.String(), "certmanager <common_name>") {
t.Errorf("help does not document the positional form: %q", stdout.String())
}
if stderr.Len() != 0 {
t.Errorf("stderr = %q, want empty", stderr.String())
}
}
// generate() splices stderr into stdout and reads the result as JSON, so an
// argv that lost its common name must fail loudly with nothing on stdout.
func TestRunNoArgsWithoutATerminalFails(t *testing.T) {
var stdout, stderr bytes.Buffer
code := runWith([]string{}, &stdout, &stderr, false)
if code == 0 {
t.Fatalf("exit code = 0, want non-zero (stdout %q)", stdout.String())
}
if stdout.Len() != 0 {
t.Errorf("stdout = %q, want empty", stdout.String())
}
if !strings.Contains(stderr.String(), "a common name is required") {
t.Errorf("stderr = %q, want it to name the missing common name", stderr.String())
}
}
// run() decides interactivity from the real stdout, so a piped stdout must not
// take the help path.
func TestRunNoArgsPipedStdoutFails(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
defer func() { _ = r.Close() }()
var stderr bytes.Buffer
code := run([]string{}, w, &stderr)
if err := w.Close(); err != nil {
t.Fatalf("close: %v", err)
}
out, err := io.ReadAll(r)
if err != nil {
t.Fatalf("read: %v", err)
}
if code == 0 {
t.Fatalf("exit code = 0, want non-zero (stdout %q)", out)
}
if len(out) != 0 {
t.Errorf("stdout = %q, want empty", out)
}
}
// Every stdout a machine caller can hand the binary must read as
// not-a-terminal. /dev/null is the trap: it is a character device, so a stat of
// the file mode calls it a terminal and lets help escape with exit zero.
func TestIsTerminalRejectsNonTerminals(t *testing.T) {
var buf bytes.Buffer
if isTerminal(&buf) {
t.Error("a bytes.Buffer is not a terminal")
}
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
t.Cleanup(func() { _ = r.Close(); _ = w.Close() })
regular, err := os.Create(filepath.Join(t.TempDir(), "stdout"))
if err != nil {
t.Fatalf("create: %v", err)
}
t.Cleanup(func() { _ = regular.Close() })
devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
if err != nil {
t.Fatalf("open %s: %v", os.DevNull, err)
}
t.Cleanup(func() { _ = devNull.Close() })
for name, f := range map[string]*os.File{
"pipe": w,
"regular file": regular,
os.DevNull: devNull,
"unix socket": unixSocket(t),
} {
if isTerminal(f) {
t.Errorf("%s is not a terminal", name)
}
// An explicit empty argv, never nil: cobra falls back to os.Args[1:]
// for a nil one, which under `go test -args foo` would turn this into
// `certmanager foo` and issue against whatever Vault the host's
// config resolves to. The stderr assertion is what catches that.
var stderr bytes.Buffer
code := run([]string{}, f, &stderr)
if code == 0 {
t.Errorf("%s: bare invocation exit code = 0, want non-zero", name)
}
if want := "a common name is required"; !strings.Contains(stderr.String(), want) {
t.Errorf("%s: stderr = %q, want it to contain %q", name, stderr.String(), want)
}
}
}
// unixSocket returns a connected unix socket as an *os.File: the stdout a
// caller gets when it wires the binary straight into a service.
func unixSocket(t *testing.T) *os.File {
t.Helper()
path := filepath.Join(t.TempDir(), "s")
ln, err := net.Listen("unix", path)
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { _ = ln.Close() })
go func() {
conn, err := ln.Accept()
if err == nil {
_, _ = io.Copy(io.Discard, conn)
_ = conn.Close()
}
}()
conn, err := net.Dial("unix", path)
if err != nil {
t.Fatalf("dial: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
f, err := conn.(*net.UnixConn).File()
if err != nil {
t.Fatalf("socket file: %v", err)
}
t.Cleanup(func() { _ = f.Close() })
return f
}
// --config with an empty value is a malformed call, not a request to resolve.
func TestRunRejectsEmptyConfigFlag(t *testing.T) {
srv, _ := newVaultStub(t, stubOptions{})
seedXDGConfig(t, srv.URL)
for _, args := range [][]string{
{"host.example.com", "--config", "", "--json"},
{"ca", "--config", ""},
} {
var stdout, stderr bytes.Buffer
code := run(args, &stdout, &stderr)
if code == 0 {
t.Fatalf("%v: exit code = 0, want non-zero (stdout %q)", args, stdout.String())
}
if stdout.Len() != 0 {
t.Errorf("%v: stdout = %q, want empty", args, stdout.String())
}
if !strings.Contains(stderr.String(), "--config requires a path") {
t.Errorf("%v: stderr = %q", args, stderr.String())
}
}
}
// seedXDGConfig writes a config where DefaultPath() looks first.
func seedXDGConfig(t *testing.T, addr string) {
t.Helper()
xdg := t.TempDir()
dir := filepath.Join(xdg, "certmanager")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
body := "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"
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(body), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
t.Setenv("XDG_CONFIG_HOME", xdg)
t.Setenv("HOME", t.TempDir())
}
// Puppet passes no --config, so the resolved path has to be used.
func TestRunResolvesConfigWithoutFlag(t *testing.T) {
srv, got := newVaultStub(t, stubOptions{})
seedXDGConfig(t, srv.URL)
var stdout, stderr bytes.Buffer
code := run([]string{"host.example.com", "--alt-names", "host", "--expiry-days", "90", "--json"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, stderr = %q", code, stderr.String())
}
if got.issueBody["common_name"] != "host.example.com" {
t.Errorf("common_name = %v", got.issueBody["common_name"])
}
if stderr.Len() != 0 {
t.Errorf("stderr = %q, want empty", stderr.String())
}
}
// The ca subcommand shares the same resolution.
func TestRunCAResolvesConfigWithoutFlag(t *testing.T) {
srv, got := newVaultStub(t, stubOptions{})
seedXDGConfig(t, srv.URL)
var stdout, stderr bytes.Buffer
if code := run([]string{"ca"}, &stdout, &stderr); code != 0 {
t.Fatalf("exit code = %d, stderr = %q", code, stderr.String())
}
if !got.caRequested {
t.Error("the CA chain endpoint was never called")
}
}
// With no config anywhere the run fails naming the baked-in path, not a
// home-relative one that could never exist under generate().
func TestRunWithoutAnyConfigNamesTheBakedPath(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("HOME", t.TempDir())
var stdout, stderr bytes.Buffer
if code := run([]string{"host.example.com", "--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())
}
if !strings.Contains(stderr.String(), config.FallbackPath()) {
t.Errorf("stderr = %q, want it to name %q", stderr.String(), config.FallbackPath())
}
}
+25
View File
@@ -0,0 +1,25 @@
//go:build linux
package main
import (
"os"
"syscall"
"testing"
)
// The help-and-exit-zero branch is the whole point of the gate, and no CI
// container has a controlling terminal to reach it through /dev/tty. A pty
// allocated from /dev/ptmx is a real terminal, so the true branch is pinned
// here instead of skipped.
func TestIsTerminalDetectsAPTY(t *testing.T) {
pty, err := os.OpenFile("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY, 0)
if err != nil {
t.Fatalf("open /dev/ptmx: %v", err)
}
t.Cleanup(func() { _ = pty.Close() })
if !isTerminal(pty) {
t.Error("a pty is a terminal")
}
}
+2
View File
@@ -4,6 +4,8 @@ go 1.25.7
require (
github.com/spf13/cobra v1.10.2
golang.org/x/sys v0.47.0
golang.org/x/term v0.45.0
gopkg.in/yaml.v3 v3.0.1
)
+4
View File
@@ -7,6 +7,10 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+76 -6
View File
@@ -3,6 +3,7 @@ package config
import (
"fmt"
"os"
"path/filepath"
"time"
"gopkg.in/yaml.v3"
@@ -28,8 +29,44 @@ const DefaultTimeout = 30 * time.Second
// -X git.unkin.net/unkin/certmanager/internal/config.defaultPath=/some/path
var defaultPath = "/opt/certmanager/config.yaml"
// DefaultPath returns the config path baked in at build time.
func DefaultPath() string { return defaultPath }
// FallbackPath returns the config path baked in at build time. Puppet writes
// the config there, and it is the only path that resolves under generate(),
// which runs as the puppet user with no usable home directory.
func FallbackPath() string { return defaultPath }
// DefaultPath resolves the config to use when --config was not given:
// $XDG_CONFIG_HOME/certmanager/config.yaml, then
// $HOME/.config/certmanager/config.yaml, then FallbackPath(). The first two
// are only taken when the file is actually there, so an unset or unusable
// HOME lands on the Puppet-managed path instead of a path that cannot exist.
func DefaultPath() string {
// The XDG spec requires a relative XDG_CONFIG_HOME to be ignored.
if base := os.Getenv("XDG_CONFIG_HOME"); filepath.IsAbs(base) {
if p := filepath.Join(base, "certmanager", "config.yaml"); isFile(p) {
return p
}
}
if home, err := os.UserHomeDir(); err == nil && home != "" {
if p := filepath.Join(home, ".config", "certmanager", "config.yaml"); isFile(p) {
return p
}
}
return defaultPath
}
// Resolve picks the config path for a run: a non-empty explicit --config
// always wins, an empty one means "resolve".
func Resolve(explicit string) string {
if explicit != "" {
return explicit
}
return DefaultPath()
}
func isFile(path string) bool {
info, err := os.Stat(path)
return err == nil && info.Mode().IsRegular()
}
// Config is the top-level configuration structure.
type Config struct {
@@ -85,6 +122,31 @@ func (v VaultConfig) RequestTimeout() (time.Duration, error) {
// Load reads, parses, defaults and validates the config file at path.
func Load(path string) (*Config, error) {
cfg, err := parse(path)
if err != nil {
return nil, err
}
if err := cfg.Vault.Validate(); err != nil {
return nil, fmt.Errorf("invalid config %q: %w", path, err)
}
return cfg, nil
}
// LoadCA reads the config for the CA chain path. That endpoint is public, so
// only vault.addr is required: a config carrying no auth method, no role and no
// credentials still loads.
func LoadCA(path string) (*Config, error) {
cfg, err := parse(path)
if err != nil {
return nil, err
}
if err := cfg.Vault.ValidateCA(); err != nil {
return nil, fmt.Errorf("invalid config %q: %w", path, err)
}
return cfg, nil
}
func parse(path string) (*Config, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open config %q: %w", path, err)
@@ -102,13 +164,21 @@ func Load(path string) (*Config, error) {
if cfg.Vault.JWTPath == "" {
cfg.Vault.JWTPath = DefaultJWTPath
}
if err := cfg.Vault.Validate(); err != nil {
return nil, fmt.Errorf("invalid config %q: %w", path, err)
}
return &cfg, nil
}
// ValidateCA rejects a config that could not reach the CA chain endpoint. It
// deliberately ignores every auth key: the endpoint is unauthenticated.
func (v VaultConfig) ValidateCA() error {
if v.Addr == "" {
return fmt.Errorf("vault.addr is required")
}
if _, err := v.RequestTimeout(); err != nil {
return err
}
return nil
}
// Validate rejects a config that could not produce a certificate, naming the
// key at fault. It never falls back from one auth method to another.
func (v VaultConfig) Validate() error {
+168 -3
View File
@@ -120,8 +120,173 @@ func TestLoadMissingFile(t *testing.T) {
}
}
func TestDefaultPath(t *testing.T) {
if got := DefaultPath(); got != "/opt/certmanager/config.yaml" {
t.Errorf("DefaultPath() = %q, want the Puppet-managed path", got)
func TestFallbackPath(t *testing.T) {
if got := FallbackPath(); got != "/opt/certmanager/config.yaml" {
t.Errorf("FallbackPath() = %q, want the Puppet-managed path", got)
}
}
// seedConfig drops a config file at dir/certmanager/config.yaml and returns it.
func seedConfig(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "certmanager", "config.yaml")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, []byte(approleBody), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
return path
}
func TestDefaultPathPrefersXDG(t *testing.T) {
xdg := t.TempDir()
home := t.TempDir()
want := seedConfig(t, xdg)
seedConfig(t, filepath.Join(home, ".config"))
t.Setenv("XDG_CONFIG_HOME", xdg)
t.Setenv("HOME", home)
if got := DefaultPath(); got != want {
t.Errorf("DefaultPath() = %q, want the XDG config %q", got, want)
}
}
func TestDefaultPathFallsBackToHome(t *testing.T) {
home := t.TempDir()
want := seedConfig(t, filepath.Join(home, ".config"))
t.Setenv("XDG_CONFIG_HOME", "")
t.Setenv("HOME", home)
if got := DefaultPath(); got != want {
t.Errorf("DefaultPath() = %q, want the home config %q", got, want)
}
}
// XDG pointing somewhere without a config must not shadow a usable home one.
func TestDefaultPathSkipsXDGWithoutAConfig(t *testing.T) {
home := t.TempDir()
want := seedConfig(t, filepath.Join(home, ".config"))
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("HOME", home)
if got := DefaultPath(); got != want {
t.Errorf("DefaultPath() = %q, want the home config %q", got, want)
}
}
// What generate() sees: the puppet user has no usable home, so neither lookup
// yields a file and the baked-in path has to win.
func TestDefaultPathFallsBackToBakedPath(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", "")
t.Setenv("HOME", "")
if got := DefaultPath(); got != FallbackPath() {
t.Errorf("DefaultPath() = %q, want %q", got, FallbackPath())
}
}
func TestDefaultPathIgnoresADirectory(t *testing.T) {
xdg := t.TempDir()
if err := os.MkdirAll(filepath.Join(xdg, "certmanager", "config.yaml"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
t.Setenv("XDG_CONFIG_HOME", xdg)
t.Setenv("HOME", t.TempDir())
if got := DefaultPath(); got != FallbackPath() {
t.Errorf("DefaultPath() = %q, want %q", got, FallbackPath())
}
}
func TestResolvePrefersExplicitPath(t *testing.T) {
xdg := t.TempDir()
resolved := seedConfig(t, xdg)
t.Setenv("XDG_CONFIG_HOME", xdg)
t.Setenv("HOME", t.TempDir())
explicit := writeConfig(t, approleBody)
if got := Resolve(explicit); got != explicit {
t.Errorf("Resolve(%q) = %q, want the explicit path", explicit, got)
}
if got := Resolve(""); got != resolved {
t.Errorf("Resolve(\"\") = %q, want the resolved path %q", got, resolved)
}
}
// The XDG spec says a non-absolute XDG_CONFIG_HOME must be ignored, so a
// config sitting under the working directory cannot be picked up.
func TestDefaultPathIgnoresRelativeXDG(t *testing.T) {
wd := t.TempDir()
seedConfig(t, filepath.Join(wd, "xdg"))
t.Chdir(wd)
t.Setenv("XDG_CONFIG_HOME", "xdg")
t.Setenv("HOME", t.TempDir())
if got := DefaultPath(); got != FallbackPath() {
t.Errorf("DefaultPath() = %q, want %q", got, FallbackPath())
}
}
// The CA chain endpoint is unauthenticated, so a config with nothing but addr
// and mount_point has to load.
func TestLoadCAWithoutCredentials(t *testing.T) {
cfg, err := LoadCA(writeConfig(t, "vault:\n addr: https://vault.service.consul:8200\n mount_point: pki_int\n"))
if err != nil {
t.Fatalf("LoadCA: %v", err)
}
if cfg.Vault.Addr != "https://vault.service.consul:8200" {
t.Errorf("addr = %q", cfg.Vault.Addr)
}
if cfg.Vault.MountPoint != "pki_int" {
t.Errorf("mount_point = %q", cfg.Vault.MountPoint)
}
if cfg.Vault.RoleID != "" || cfg.Vault.RoleName != "" {
t.Errorf("LoadCA invented credentials: %+v", cfg.Vault)
}
}
// Load, by contrast, still demands everything issuance needs.
func TestLoadRejectsWhatLoadCAAccepts(t *testing.T) {
path := writeConfig(t, "vault:\n addr: https://vault.service.consul:8200\n mount_point: pki_int\n")
if _, err := Load(path); err == nil {
t.Fatal("Load accepted a config with no role_name or credentials")
}
}
func TestLoadCAErrors(t *testing.T) {
tests := []struct {
name string
body string
wantErr string
}{
{"no addr", "vault:\n mount_point: pki_int\n", "vault.addr is required"},
{"bad timeout", "vault:\n addr: https://v:8200\n timeout: soon\n", "not a valid duration"},
{"negative timeout", "vault:\n addr: https://v:8200\n timeout: -5s\n", "must be positive"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := LoadCA(writeConfig(t, tc.body))
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Errorf("error = %v, want it to mention %q", err, tc.wantErr)
}
})
}
}
func TestLoadCAMissingFile(t *testing.T) {
_, err := LoadCA(filepath.Join(t.TempDir(), "absent.yaml"))
if err == nil || !strings.Contains(err.Error(), "open config") {
t.Errorf("error = %v, want an open failure", err)
}
}
// An unknown auth_method is fatal for issuance but irrelevant to the CA path.
func TestLoadCAIgnoresAuthMethod(t *testing.T) {
if _, err := LoadCA(writeConfig(t, "vault:\n addr: https://v:8200\n auth_method: ldap\n mount_point: pki_int\n")); err != nil {
t.Errorf("LoadCA: %v", err)
}
}
+57 -8
View File
@@ -1,6 +1,8 @@
package pki
import (
"crypto/x509"
"encoding/pem"
"fmt"
"strings"
@@ -10,10 +12,9 @@ import (
// CertificateResponse is the JSON document printed with --json and consumed by
// parsejson() in profiles::pki::vault. Field order fixes the key order.
type CertificateResponse struct {
Certificate string `json:"certificate"`
PrivateKey string `json:"private_key"`
FullChain string `json:"full_chain"`
CACertificate string `json:"ca_certificate"`
Certificate string `json:"certificate"`
PrivateKey string `json:"private_key"`
FullChain string `json:"full_chain"`
}
type issueRequest struct {
@@ -48,10 +49,58 @@ func IssueCert(client *vault.Client, mountPoint, roleName, commonName string, al
return nil, fmt.Errorf("vault returned no certificate data for %q", commonName)
}
// Leaf first: every TLS server that reads full_chain.crt expects the end
// entity certificate ahead of the issuers.
return &CertificateResponse{
Certificate: resp.Data.Certificate,
PrivateKey: resp.Data.PrivateKey,
FullChain: resp.Data.IssuingCA + "\n" + resp.Data.Certificate,
CACertificate: resp.Data.IssuingCA,
Certificate: resp.Data.Certificate,
PrivateKey: resp.Data.PrivateKey,
FullChain: resp.Data.Certificate + "\n" + resp.Data.IssuingCA,
}, nil
}
// FetchCAChain returns the mount's CA chain as PEM. /ca_chain is public and
// estate-wide: it needs no token, no role and no per-node context, which is why
// it is fetched here rather than derived from an issue response. It carries the
// root as well as the intermediate, so the result stands alone as a CAfile for
// path builders that insist on a self-signed anchor.
func FetchCAChain(client *vault.Client, mountPoint string) (string, error) {
path := fmt.Sprintf("%s/ca_chain", mountPoint)
body, err := client.Get(path)
if err != nil {
return "", err
}
// The endpoint advertises application/pkix-cert while serving PEM, so the
// body is parsed rather than sniffed.
chain := strings.TrimSpace(string(body))
if chain == "" {
return "", fmt.Errorf("GET %s: vault returned an empty CA chain", path)
}
n, err := countCertificates(chain)
if err != nil {
return "", fmt.Errorf("GET %s: %w", path, err)
}
if n == 0 {
return "", fmt.Errorf("GET %s: response is not a PEM certificate chain", path)
}
return chain + "\n", nil
}
func countCertificates(chain string) (int, error) {
rest := []byte(chain)
count := 0
for {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
return count, nil
}
if block.Type != "CERTIFICATE" {
return 0, fmt.Errorf("PEM block %d is a %q, not a CERTIFICATE", count+1, block.Type)
}
if _, err := x509.ParseCertificate(block.Bytes); err != nil {
return 0, fmt.Errorf("parse certificate %d: %w", count+1, err)
}
count++
}
}
+168 -9
View File
@@ -1,13 +1,22 @@
package pki_test
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"math/big"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
"git.unkin.net/unkin/certmanager/internal/config"
"git.unkin.net/unkin/certmanager/internal/pki"
@@ -68,14 +77,17 @@ func TestIssueCert(t *testing.T) {
}
want := &pki.CertificateResponse{
Certificate: "CERT",
PrivateKey: "KEY",
FullChain: "CA\nCERT",
CACertificate: "CA",
Certificate: "CERT",
PrivateKey: "KEY",
FullChain: "CERT\nCA",
}
if !reflect.DeepEqual(got, want) {
t.Errorf("IssueCert = %+v, want %+v", got, want)
}
// Leaf first: asserted as an order, not as membership.
if got.FullChain != got.Certificate+"\n"+"CA" {
t.Errorf("full_chain = %q, want the leaf ahead of the issuing CA", got.FullChain)
}
}
func TestIssueCertSendsEmptySANFields(t *testing.T) {
@@ -124,16 +136,163 @@ func TestIssueCertEmptyPayload(t *testing.T) {
func TestCertificateResponseJSONKeyOrder(t *testing.T) {
b, err := json.Marshal(&pki.CertificateResponse{
Certificate: "CERT",
PrivateKey: "KEY",
FullChain: "CA\nCERT",
CACertificate: "CA",
Certificate: "CERT",
PrivateKey: "KEY",
FullChain: "CERT\nCA",
})
if err != nil {
t.Fatalf("Marshal: %v", err)
}
want := `{"certificate":"CERT","private_key":"KEY","full_chain":"CA\nCERT","ca_certificate":"CA"}`
want := `{"certificate":"CERT","private_key":"KEY","full_chain":"CERT\nCA"}`
if string(b) != want {
t.Errorf("JSON = %s, want %s", b, want)
}
}
// newCAServer serves the CA chain endpoint and records what the client sent.
func newCAServer(t *testing.T, h http.HandlerFunc) (*vault.Client, *caCapture) {
t.Helper()
got := &caCapture{}
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got.method = r.Method
got.path = r.URL.Path
_, got.tokenSet = r.Header["X-Vault-Token"]
if strings.HasSuffix(r.URL.Path, "/login") {
got.loggedIn = true
}
h(w, r)
}))
t.Cleanup(srv.Close)
c, err := vault.NewUnauthenticated(config.VaultConfig{Addr: srv.URL})
if err != nil {
t.Fatalf("vault.NewUnauthenticated: %v", err)
}
return c, got
}
type caCapture struct {
method string
path string
tokenSet bool
loggedIn bool
}
func testChainPEM(t *testing.T) string {
t.Helper()
return testCertPEM(t, "intermediate") + testCertPEM(t, "root")
}
func testCertPEM(t *testing.T, cn string) string {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: cn},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
IsCA: true,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageCertSign,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("create certificate: %v", err)
}
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
}
func TestFetchCAChain(t *testing.T) {
chain := testChainPEM(t)
c, got := newCAServer(t, func(w http.ResponseWriter, _ *http.Request) {
// Vault labels the PEM body application/pkix-cert.
w.Header().Set("Content-Type", "application/pkix-cert")
_, _ = io.WriteString(w, chain)
})
out, err := pki.FetchCAChain(c, "pki_int")
if err != nil {
t.Fatalf("FetchCAChain: %v", err)
}
if got.method != http.MethodGet {
t.Errorf("method = %q, want GET", got.method)
}
if got.path != "/v1/pki_int/ca_chain" {
t.Errorf("path = %q", got.path)
}
if got.tokenSet {
t.Error("X-Vault-Token must not be sent to the CA chain endpoint")
}
if got.loggedIn {
t.Error("the CA chain path must not authenticate")
}
if out != strings.TrimSpace(chain)+"\n" {
t.Errorf("chain = %q, want %q", out, chain)
}
if n := strings.Count(out, "-----BEGIN CERTIFICATE-----"); n != 2 {
t.Errorf("chain carries %d certificates, want 2", n)
}
}
func TestFetchCAChainFailures(t *testing.T) {
tests := []struct {
name string
handler http.HandlerFunc
wantErr string
}{
{
name: "non-200",
handler: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = io.WriteString(w, `{"errors":["permission denied"]}`)
},
wantErr: "vault returned 403",
},
{
name: "empty body",
handler: func(http.ResponseWriter, *http.Request) {},
wantErr: "empty CA chain",
},
{
name: "not PEM",
handler: func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, "definitely not a certificate")
},
wantErr: "not a PEM certificate chain",
},
{
name: "wrong PEM type",
handler: func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, "-----BEGIN PUBLIC KEY-----\nAAAA\n-----END PUBLIC KEY-----\n")
},
wantErr: "not a CERTIFICATE",
},
{
name: "corrupt certificate",
handler: func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n")
},
wantErr: "parse certificate 1",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
c, _ := newCAServer(t, tc.handler)
out, err := pki.FetchCAChain(c, "pki_int")
if err == nil {
t.Fatalf("FetchCAChain succeeded, want an error (got %q)", out)
}
if out != "" {
t.Errorf("a failing FetchCAChain must return no chain, got %q", out)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Errorf("error = %v, want it to mention %q", err, tc.wantErr)
}
})
}
}
+59 -12
View File
@@ -17,6 +17,9 @@ import (
// maxErrorBody caps how much of a Vault error response is quoted back.
const maxErrorBody = 2048
// maxBody caps a GET response; the CA chain is a handful of kilobytes.
const maxBody = 1 << 20
// Client is an authenticated Vault HTTP client.
type Client struct {
addr string
@@ -28,22 +31,11 @@ type Client struct {
// Client. An unknown method, or a failure of the selected method, is an error:
// there is no fallback between methods.
func New(cfg config.VaultConfig) (*Client, error) {
timeout, err := cfg.RequestTimeout()
c, err := newClient(cfg)
if err != nil {
return nil, err
}
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.SkipTLSVerify()}, //nolint:gosec // defaults to the Python script's verify=False; flip vault.tls_skip_verify to enable
TLSHandshakeTimeout: timeout,
ResponseHeaderTimeout: timeout,
}
c := &Client{
addr: strings.TrimRight(cfg.Addr, "/"),
http: &http.Client{Transport: transport, Timeout: timeout},
}
switch cfg.AuthMethod {
case config.AuthMethodAppRole:
err = c.loginAppRole(cfg)
@@ -59,6 +51,31 @@ func New(cfg config.VaultConfig) (*Client, error) {
return c, nil
}
// NewUnauthenticated returns a Client that performs no login and holds no
// token. The PKI CA endpoints are public, so the CA chain is reachable from a
// config that carries no credentials at all.
func NewUnauthenticated(cfg config.VaultConfig) (*Client, error) {
return newClient(cfg)
}
func newClient(cfg config.VaultConfig) (*Client, error) {
timeout, err := cfg.RequestTimeout()
if err != nil {
return nil, err
}
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.SkipTLSVerify()}, //nolint:gosec // defaults to the Python script's verify=False; flip vault.tls_skip_verify to enable
TLSHandshakeTimeout: timeout,
ResponseHeaderTimeout: timeout,
}
return &Client{
addr: strings.TrimRight(cfg.Addr, "/"),
http: &http.Client{Transport: transport, Timeout: timeout},
}, nil
}
func (c *Client) loginAppRole(cfg config.VaultConfig) error {
// The Vault role sets bind_secret_id: false, so role_id alone logs in.
payload := map[string]string{"role_id": cfg.RoleID}
@@ -105,6 +122,36 @@ func (c *Client) fetchToken(authPath string, payload any) (string, error) {
return result.Auth.ClientToken, nil
}
// Get sends a GET to a Vault path and returns the raw body undecoded. The
// X-Vault-Token header is omitted entirely when the client holds no token.
func (c *Client) Get(path string) ([]byte, error) {
url := fmt.Sprintf("%s/v1/%s", c.addr, path)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if c.token != "" {
req.Header.Set("X-Vault-Token", c.token)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("GET %s: %w", path, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
detail, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody))
return nil, fmt.Errorf("GET %s: vault returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(detail)))
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
if err != nil {
return nil, fmt.Errorf("GET %s: read response: %w", path, err)
}
return body, nil
}
// Post sends an authenticated POST to a Vault path and decodes the response.
func (c *Client) Post(path string, payload, out any) error {
return c.post(path, c.token, payload, out)
+67
View File
@@ -259,3 +259,70 @@ func TestTLSVerificationCanBeEnabled(t *testing.T) {
t.Fatal("New succeeded against a self-signed server with verification enabled")
}
}
func TestNewUnauthenticatedDoesNotLogIn(t *testing.T) {
var paths []string
srv := newServer(t, func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
if _, ok := r.Header["X-Vault-Token"]; ok {
t.Errorf("X-Vault-Token sent to %s", r.URL.Path)
}
_, _ = fmt.Fprint(w, "PEM")
})
// No auth_method, no role_id, no JWT path: the CA chain endpoint is public.
c, err := vault.NewUnauthenticated(config.VaultConfig{Addr: srv.URL})
if err != nil {
t.Fatalf("NewUnauthenticated: %v", err)
}
body, err := c.Get("pki_int/ca_chain")
if err != nil {
t.Fatalf("Get: %v", err)
}
if string(body) != "PEM" {
t.Errorf("body = %q", body)
}
if len(paths) != 1 || paths[0] != "/v1/pki_int/ca_chain" {
t.Errorf("requests = %v, want a single GET of the chain", paths)
}
}
func TestGetSendsTheTokenWhenAuthenticated(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, "PEM")
})
c, err := vault.New(baseConfig(srv.URL))
if err != nil {
t.Fatalf("New: %v", err)
}
if _, err := c.Get("pki_int/ca_chain"); err != nil {
t.Fatalf("Get: %v", err)
}
if token != "s.token" {
t.Errorf("X-Vault-Token = %q", token)
}
}
func TestGetSurfacesTheStatusAndBody(t *testing.T) {
srv := newServer(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = fmt.Fprint(w, `{"errors":["unsupported path"]}`)
})
c, err := vault.NewUnauthenticated(config.VaultConfig{Addr: srv.URL})
if err != nil {
t.Fatalf("NewUnauthenticated: %v", err)
}
if _, err := c.Get("pki_int/ca_chain"); err == nil ||
!strings.Contains(err.Error(), "vault returned 404") ||
!strings.Contains(err.Error(), "unsupported path") {
t.Errorf("error = %v, want the status and Vault's message", err)
}
}
+276 -14
View File
@@ -19,12 +19,18 @@ import (
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"
)
const (
pkiMount = "pki"
// Dev-mode Vault has no PKI of its own, and a single root mount would make
// the CA chain one self-signed certificate -- which would not exercise the
// intermediate + root shape production serves. So a root mount signs an
// intermediate, and certificates are issued from the intermediate.
rootMount = "pki_root"
pkiMount = "pki_int"
pkiRole = "puppet"
approlePath = "approle"
approleRole = "certmanager"
@@ -123,18 +129,59 @@ func vaultReq(t *testing.T, method, path string, payload any, okErrors ...string
return out
}
// setupVault provisions the PKI mount, issuing role, policy and approle role,
// returning the role_id certmanager will authenticate with.
// setupVault provisions the mounts, issuing role, policy and approle role,
// returning the role_id certmanager will authenticate with. Provisioning runs
// once per process: re-signing the intermediate would add issuers and make the
// mount's CA chain non-deterministic.
func setupVault(t *testing.T) string {
t.Helper()
setupOnce.Do(func() { setupRoleID = provisionVault(t) })
if setupRoleID == "" {
t.Fatal("vault provisioning failed")
}
return setupRoleID
}
var (
setupOnce sync.Once
setupRoleID string
)
func provisionVault(t *testing.T) string {
t.Helper()
vaultReq(t, http.MethodPost, "sys/mounts/"+rootMount,
map[string]any{"type": "pki", "config": map[string]any{"max_lease_ttl": "87600h"}},
"path is already in use")
vaultReq(t, http.MethodPost, "sys/mounts/"+pkiMount,
map[string]any{"type": "pki", "config": map[string]any{"max_lease_ttl": "87600h"}},
"path is already in use")
vaultReq(t, http.MethodPost, pkiMount+"/root/generate/internal", map[string]any{
"common_name": certDomain + " integration CA",
root := vaultReq(t, http.MethodPost, rootMount+"/root/generate/internal", map[string]any{
"common_name": certDomain + " integration root CA",
"ttl": "87600h",
"key_type": "rsa",
"key_bits": 2048,
})
rootPEM := dataString(t, root, "certificate")
csr := vaultReq(t, http.MethodPost, pkiMount+"/intermediate/generate/internal", map[string]any{
"common_name": certDomain + " integration intermediate CA",
"key_type": "rsa",
"key_bits": 2048,
})
signed := vaultReq(t, http.MethodPost, rootMount+"/root/sign-intermediate", map[string]any{
"csr": dataString(t, csr, "csr"),
"common_name": certDomain + " integration intermediate CA",
"ttl": "43800h",
"format": "pem",
})
// Appending the root to the signed intermediate is what makes /ca_chain
// serve both certificates.
vaultReq(t, http.MethodPost, pkiMount+"/intermediate/set-signed", map[string]any{
"certificate": dataString(t, signed, "certificate") + "\n" + rootPEM,
})
vaultReq(t, http.MethodPost, pkiMount+"/roles/"+pkiRole, map[string]any{
@@ -172,6 +219,16 @@ func setupVault(t *testing.T) string {
return roleID
}
func dataString(t *testing.T, resp map[string]any, key string) string {
t.Helper()
data, _ := resp["data"].(map[string]any)
v, _ := data[key].(string)
if v == "" {
t.Fatalf("vault returned no data.%s", key)
}
return v
}
// buildBinary compiles certmanager with the config path baked in, so the test
// can invoke it with exactly the argv Puppet's generate() uses -- no --config.
func buildBinary(t *testing.T, dir, configPath string) string {
@@ -218,6 +275,7 @@ func writeConfig(t *testing.T, dir, roleID string) string {
func runCertmanager(t *testing.T, bin string, args ...string) (int, string, string) {
t.Helper()
cmd := exec.Command(bin, args...)
cmd.Env = hermeticEnv(t)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
@@ -263,11 +321,17 @@ func TestIssueCertificateAgainstVault(t *testing.T) {
if err := json.Unmarshal([]byte(stdout), &doc); err != nil {
t.Fatalf("stdout is not JSON: %v\n%s", err, stdout)
}
for _, key := range []string{"certificate", "private_key", "full_chain", "ca_certificate"} {
for _, key := range []string{"certificate", "private_key", "full_chain"} {
if doc[key] == "" {
t.Errorf("JSON key %q is missing or empty", key)
}
}
if _, ok := doc["ca_certificate"]; ok {
t.Error("ca_certificate must not be emitted; the CA comes from the ca subcommand")
}
if len(doc) != 3 {
t.Errorf("document has %d keys, want exactly 3: %v", len(doc), keysOf(doc))
}
cert := parseCert(t, doc["certificate"])
if cert.Subject.CommonName != commonName {
@@ -290,16 +354,214 @@ func TestIssueCertificateAgainstVault(t *testing.T) {
t.Errorf("certificate lifetime %v, want ~%dd", lifetime, expiryDays)
}
ca := parseCert(t, doc["ca_certificate"])
if !ca.IsCA {
t.Errorf("ca_certificate is not a CA certificate")
// full_chain is leaf first: asserted as an order, not as membership.
chain := parseChain(t, doc["full_chain"])
if len(chain) != 2 {
t.Fatalf("full_chain carries %d certificates, want leaf + issuing CA", len(chain))
}
if err := cert.CheckSignatureFrom(ca); err != nil {
t.Errorf("certificate is not signed by ca_certificate: %v", err)
if !chain[0].Equal(cert) {
t.Errorf("full_chain starts with %q, want the leaf %q", chain[0].Subject.CommonName, commonName)
}
if !strings.Contains(doc["full_chain"], strings.TrimSpace(doc["certificate"])) ||
!strings.Contains(doc["full_chain"], strings.TrimSpace(doc["ca_certificate"])) {
t.Errorf("full_chain does not contain both the leaf and the issuing CA")
if !chain[1].IsCA {
t.Errorf("full_chain[1] (%q) is not a CA certificate", chain[1].Subject.CommonName)
}
if err := chain[0].CheckSignatureFrom(chain[1]); err != nil {
t.Errorf("full_chain is not leaf-then-issuer: %v", err)
}
}
// TestCAChainAgainstVault is the reason the CA no longer rides along with
// issuance: this runs with no credentials at all.
func TestCAChainAgainstVault(t *testing.T) {
waitForVault(t)
roleID := setupVault(t)
dir := t.TempDir()
cfg := writeConfig(t, dir, roleID)
bin := buildBinary(t, dir, cfg)
code, stdout, stderr := runCertmanager(t, bin, "ca")
if code != 0 {
t.Fatalf("exit code %d, stderr: %s", code, stderr)
}
if stderr != "" {
t.Errorf("stderr must be empty on success, got %q", stderr)
}
// Puppet reads this through generate() too, so it must be the PEM and
// nothing else.
if !strings.HasPrefix(stdout, "-----BEGIN CERTIFICATE-----") {
t.Fatalf("stdout is not a PEM document: %q", stdout)
}
chain := parseChain(t, stdout)
if len(chain) != 2 {
t.Fatalf("CA chain carries %d certificates, want intermediate + root", len(chain))
}
intermediate, root := chain[0], chain[1]
if !intermediate.IsCA || !root.IsCA {
t.Errorf("both chain entries must be CA certificates: %v, %v", intermediate.IsCA, root.IsCA)
}
// The root anchors the chain: self-issued and self-signed. issuing_ca alone
// would not be, which is what breaks strict path builders.
if root.Subject.String() != root.Issuer.String() {
t.Errorf("chain[1] subject %q != issuer %q, so it is not the root", root.Subject, root.Issuer)
}
if err := root.CheckSignatureFrom(root); err != nil {
t.Errorf("chain[1] is not self-signed: %v", err)
}
if intermediate.Subject.String() == intermediate.Issuer.String() {
t.Errorf("chain[0] %q is self-issued; expected the intermediate", intermediate.Subject)
}
// Issue a leaf and verify it against the chain the ca subcommand returned.
_, issued, stderr := runCertmanager(t, bin,
commonName,
"--alt-names", shortName+","+altName,
"--ip-sans", ipSAN1+","+ipSAN2,
"--expiry-days", fmt.Sprint(expiryDays),
"--json",
)
var doc map[string]string
if err := json.Unmarshal([]byte(issued), &doc); err != nil {
t.Fatalf("issue stdout is not JSON: %v (stderr %q)", err, stderr)
}
leaf := parseCert(t, doc["certificate"])
if err := leaf.CheckSignatureFrom(intermediate); err != nil {
t.Errorf("chain[0] did not sign the issued leaf: %v", err)
}
// The openssl verify equivalent: anchor on the root, bridge with the
// intermediate, both taken from the ca output alone.
roots := x509.NewCertPool()
roots.AddCert(root)
inters := x509.NewCertPool()
inters.AddCert(intermediate)
if _, err := leaf.Verify(x509.VerifyOptions{
Roots: roots,
Intermediates: inters,
DNSName: commonName,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
}); err != nil {
t.Errorf("leaf does not validate against the ca chain: %v", err)
}
}
// The CA chain endpoint is public, so a config with no auth keys must work.
func TestCAChainNeedsNoCredentials(t *testing.T) {
waitForVault(t)
roleID := setupVault(t)
dir := t.TempDir()
bin := buildBinary(t, dir, writeConfig(t, dir, roleID))
authless := filepath.Join(dir, "authless.yaml")
body := fmt.Sprintf("vault:\n addr: %s\n mount_point: %s\n", vaultAddr(), pkiMount)
if err := os.WriteFile(authless, []byte(body), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
code, stdout, stderr := runCertmanager(t, bin, "ca", "--config", authless)
if code != 0 {
t.Fatalf("exit code %d, stderr: %s", code, stderr)
}
if stderr != "" {
t.Errorf("stderr must be empty on success, got %q", stderr)
}
if len(parseChain(t, stdout)) != 2 {
t.Errorf("credential-free ca output is not the two certificate chain: %q", stdout)
}
}
// A developer with a real ~/.config/certmanager/config.yaml must not have it
// leak into the subprocess, which would run the suite against their Vault.
func TestIgnoresTheDevelopersConfig(t *testing.T) {
waitForVault(t)
roleID := setupVault(t)
dir := t.TempDir()
bin := buildBinary(t, dir, writeConfig(t, dir, roleID))
// Both legs of the resolution order get a config, so either one leaking
// into the subprocess points it at an unreachable Vault and fails the run.
poisoned := t.TempDir()
body := "vault:\n addr: http://127.0.0.1:1\n mount_point: nope\n role_name: nope\n" +
" approle_path: approle\n role_id: nope\n"
for _, cfgDir := range []string{
filepath.Join(poisoned, "certmanager"),
filepath.Join(poisoned, ".config", "certmanager"),
} {
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(cfgDir, "config.yaml"), []byte(body), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
}
t.Setenv("XDG_CONFIG_HOME", poisoned)
t.Setenv("HOME", poisoned)
code, stdout, stderr := runCertmanager(t, bin, commonName, "--json")
if code != 0 {
t.Fatalf("exit code %d, stderr: %s", code, stderr)
}
var doc map[string]string
if err := json.Unmarshal([]byte(stdout), &doc); err != nil {
t.Fatalf("stdout is not JSON: %v\n%s", err, stdout)
}
if doc["certificate"] == "" {
t.Error("no certificate issued; the baked-in config path was not used")
}
}
func TestCAChainFailsCleanlyForAnUnknownMount(t *testing.T) {
waitForVault(t)
roleID := setupVault(t)
dir := t.TempDir()
bin := buildBinary(t, dir, writeConfig(t, dir, roleID))
code, stdout, stderr := runCertmanager(t, bin, "ca", "--mount", "pki_does_not_exist")
if code == 0 {
t.Fatalf("expected a non-zero exit, stdout: %s", stdout)
}
if stdout != "" {
t.Errorf("stdout must be empty on failure, got %q", stdout)
}
if !strings.Contains(stderr, "certmanager:") {
t.Errorf("stderr should carry the cause, got %q", stderr)
}
}
func keysOf(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
slices.Sort(out)
return out
}
func parseChain(t *testing.T, data string) []*x509.Certificate {
t.Helper()
var out []*x509.Certificate
rest := []byte(data)
for {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
return out
}
if block.Type != "CERTIFICATE" {
t.Fatalf("PEM block %d is a %q", len(out)+1, block.Type)
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("parse certificate %d: %v", len(out)+1, err)
}
out = append(out, cert)
}
}
+3 -3
View File
@@ -1,5 +1,5 @@
// Package integration holds the end-to-end tests that run against a real Vault
// dev server. The tests are behind the `integration` build tag; this file
// keeps the package buildable for a plain `go test ./...`, which otherwise
// fails on a package whose files are all excluded by build constraints.
// dev server. Those tests are behind the `integration` build tag; the
// subprocess environment they share, and its own test, are not, so a plain
// `go test ./...` still guards the helper the tagged tests depend on.
package integration
+66
View File
@@ -0,0 +1,66 @@
package integration
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
// hermeticEnv strips the developer's environment from the subprocess. The
// tests claim to exercise the baked-in config path, so a real
// ~/.config/certmanager/config.yaml must not resolve ahead of it and point the
// binary at a production Vault.
func hermeticEnv(t *testing.T) []string {
t.Helper()
empty := t.TempDir()
return []string{
"PATH=/usr/bin:/bin",
"HOME=" + empty,
"XDG_CONFIG_HOME=" + empty,
}
}
// Neither leg of the resolution order announces itself when it is missing: an
// unset HOME or XDG_CONFIG_HOME silently falls through to whatever the
// developer has. Both must therefore be set to somewhere empty, not dropped.
// Setting them is not enough either: the env has to replace the ambient one
// rather than extend it, so anything the developer exported is asserted gone.
func TestHermeticEnvSetsBothConfigLegs(t *testing.T) {
t.Setenv("CERTMANAGER_AMBIENT_MARKER", "leaked")
t.Setenv("HOME", filepath.Join(t.TempDir(), "developer"))
env := hermeticEnv(t)
allowed := []string{"PATH", "HOME", "XDG_CONFIG_HOME"}
for _, entry := range env {
key, _, ok := strings.Cut(entry, "=")
if !ok {
t.Errorf("%q is not a KEY=VALUE entry", entry)
continue
}
if !slices.Contains(allowed, key) {
t.Errorf("%s is carried over from the ambient environment; want only %v", key, allowed)
}
}
for _, key := range []string{"HOME=", "XDG_CONFIG_HOME="} {
idx := slices.IndexFunc(env, func(e string) bool { return strings.HasPrefix(e, key) })
if idx < 0 {
t.Fatalf("%s is unset in the subprocess env: %v", key, env)
}
dir := strings.TrimPrefix(env[idx], key)
if !filepath.IsAbs(dir) {
t.Errorf("%s%s is not an absolute path", key, dir)
}
if ambient := os.Getenv(strings.TrimSuffix(key, "=")); ambient != "" && dir == ambient {
t.Errorf("%s%s is the ambient value, not a scratch directory", key, dir)
}
for _, rel := range []string{"certmanager/config.yaml", ".config/certmanager/config.yaml"} {
if _, err := os.Stat(filepath.Join(dir, rel)); err == nil {
t.Errorf("%s%s already holds %s", key, dir, rel)
}
}
}
}
+180
View File
@@ -0,0 +1,180 @@
//go:build integration && linux
package integration
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"golang.org/x/sys/unix"
)
// Under generate() the exit code is the only failure signal and every byte of
// stdout is fed to parsejson(). A bare invocation may therefore print help and
// exit zero on a terminal and nowhere else. /dev/null and a closed fd 1 are the
// traps: both leave the process holding a character device, so a stat of the
// file mode calls them terminals.
func TestBareInvocationHelpsOnlyATerminal(t *testing.T) {
dir := t.TempDir()
bin := buildBinary(t, dir, filepath.Join(dir, "absent.yaml"))
t.Run("pipe", func(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
defer func() { _ = r.Close() }()
code := runBare(t, bin, w)
_ = w.Close()
out, err := io.ReadAll(r)
if err != nil {
t.Fatalf("read: %v", err)
}
assertMachineCaller(t, code, out)
})
t.Run("regular file", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "stdout")
f, err := os.Create(path)
if err != nil {
t.Fatalf("create: %v", err)
}
defer func() { _ = f.Close() }()
code := runBare(t, bin, f)
out, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
assertMachineCaller(t, code, out)
})
t.Run("unix socket", func(t *testing.T) {
sock, peer := socketPair(t)
code := runBare(t, bin, sock)
// The child's copy went with it; dropping ours ends the peer's read.
_ = sock.Close()
out, err := io.ReadAll(peer)
if err != nil {
t.Fatalf("read: %v", err)
}
assertMachineCaller(t, code, out)
})
// Nothing written to /dev/null is observable, so the exit code carries the
// whole contract here -- which is exactly the situation under generate().
t.Run("dev null", func(t *testing.T) {
devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
if err != nil {
t.Fatalf("open %s: %v", os.DevNull, err)
}
defer func() { _ = devNull.Close() }()
if code := runBare(t, bin, devNull); code == 0 {
t.Errorf("exit code = 0, want non-zero")
}
})
// A nil file closes fd 1 across the exec. The Go runtime reopens it onto
// /dev/null before main() runs, so the process sees a character device it
// never chose.
t.Run("closed fd 1", func(t *testing.T) {
if code := runBare(t, bin, nil); code == 0 {
t.Errorf("exit code = 0, want non-zero")
}
})
t.Run("pty", func(t *testing.T) {
master, slave := openPTY(t)
drained := make(chan []byte, 1)
go func() {
out, _ := io.ReadAll(master)
drained <- out
}()
code := runBare(t, bin, slave)
// The child's copy is gone; dropping ours makes the master read EIO.
_ = slave.Close()
out := <-drained
if code != 0 {
t.Errorf("exit code = %d, want 0", code)
}
if !strings.Contains(string(out), "Usage:") {
t.Errorf("stdout = %q, want the help text", out)
}
})
}
func assertMachineCaller(t *testing.T, code int, stdout []byte) {
t.Helper()
if code == 0 {
t.Errorf("exit code = 0, want non-zero (stdout %q)", stdout)
}
if len(stdout) != 0 {
t.Errorf("stdout = %q, want empty", stdout)
}
}
// runBare runs the binary with no arguments and stdout as fd 1. A nil stdout
// leaves fd 1 closed at exec.
func runBare(t *testing.T, bin string, stdout *os.File) int {
t.Helper()
proc, err := os.StartProcess(bin, []string{bin}, &os.ProcAttr{
Env: hermeticEnv(t),
Files: []*os.File{nil, stdout, nil},
})
if err != nil {
t.Fatalf("start %s: %v", bin, err)
}
state, err := proc.Wait()
if err != nil {
t.Fatalf("wait: %v", err)
}
return state.ExitCode()
}
// socketPair returns a connected unix socket to hand the child as stdout, and
// the peer end to read back whatever reached it.
func socketPair(t *testing.T) (sock, peer *os.File) {
t.Helper()
fds, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_STREAM, 0)
if err != nil {
t.Fatalf("socketpair: %v", err)
}
sock = os.NewFile(uintptr(fds[0]), "stdout-socket")
peer = os.NewFile(uintptr(fds[1]), "stdout-socket-peer")
t.Cleanup(func() { _ = sock.Close(); _ = peer.Close() })
return sock, peer
}
// openPTY allocates a pty pair. CI containers have no controlling terminal, so
// this is the only way to exercise the branch that prints help and exits zero.
func openPTY(t *testing.T) (master, slave *os.File) {
t.Helper()
m, err := os.OpenFile("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY, 0)
if err != nil {
t.Fatalf("open /dev/ptmx: %v", err)
}
t.Cleanup(func() { _ = m.Close() })
if err := unix.IoctlSetPointerInt(int(m.Fd()), unix.TIOCSPTLCK, 0); err != nil {
t.Fatalf("unlock pty: %v", err)
}
n, err := unix.IoctlGetInt(int(m.Fd()), unix.TIOCGPTN)
if err != nil {
t.Fatalf("pty number: %v", err)
}
s, err := os.OpenFile(fmt.Sprintf("/dev/pts/%d", n), syscall.O_RDWR|syscall.O_NOCTTY, 0)
if err != nil {
t.Fatalf("open pty slave: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
return m, s
}