Make certmanager a drop-in for the Python script #1

Merged
benvin merged 4 commits from benvin/drop-in-rewrite into master 2026-09-19 12:46:08 +10:00
24 changed files with 2086 additions and 958 deletions
+7
View File
@@ -0,0 +1,7 @@
# built binary (repo root only — not the cmd/ source dir)
/certmanager
# cross-compiled release artifacts (e.g. certmanager-linux-amd64)
/certmanager-*
dist/
# goreleaser-generated shell completions
/completions/
+66 -6
View File
@@ -1,8 +1,15 @@
version: 2
project_name: certmanager
before:
hooks:
- go mod tidy
# goreleaser refuses to release from a dirty tree, so nothing here may
# touch tracked files; completions/ is gitignored.
- mkdir -p completions
- sh -c "go run ./cmd/certmanager completion bash > completions/certmanager.bash"
- sh -c "go run ./cmd/certmanager completion zsh > completions/_certmanager"
- sh -c "go run ./cmd/certmanager completion fish > completions/certmanager.fish"
builds:
- id: certmanager
@@ -13,26 +20,79 @@ builds:
goos:
- linux
- darwin
- freebsd
goarch:
- amd64
- arm64
flags:
- -trimpath
ldflags:
- -s -w
# Puppet writes the config to /opt/certmanager/config.yaml.
- -s -w -X main.version={{ .Version }} -X git.unkin.net/unkin/certmanager/internal/config.defaultPath=/opt/certmanager/config.yaml
archives:
- id: certmanager
# Raw binaries: the puppetserver-compiler initContainer fetches a single
# pinned file by URL and checksums it, so it cannot unpack an archive.
- id: binaries
ids: [certmanager]
formats: [binary]
name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}"
- id: archives
ids: [certmanager]
formats: [tar.gz]
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
format: tar.gz
files:
- README.md
- LICENSE
- completions/*
nfpms:
- id: certmanager
ids: [certmanager]
package_name: certmanager
# certmanager-<version>-1.x86_64.rpm, matching the previous nfpm output.
file_name_template: "{{ .ConventionalFileName }}"
vendor: unkin
homepage: https://git.unkin.net/unkin/certmanager
maintainer: Ben Vincent <ben@unkin.net>
description: Issues Vault PKI certificates for Puppet-managed nodes; drop-in replacement for the Python certmanager script
license: MIT
formats:
- rpm
release: "1"
bindir: /usr/bin
provides:
- certmanager
replaces:
- certmanager
contents:
# Puppet's profiles::pki::vault calls the binary at this hardcoded path.
- src: /usr/bin/certmanager
dst: /usr/local/bin/certmanager
type: symlink
- src: completions/certmanager.bash
dst: /usr/share/bash-completion/completions/certmanager
file_info:
mode: 0644
- src: completions/_certmanager
dst: /usr/share/zsh/site-functions/_certmanager
file_info:
mode: 0644
- src: completions/certmanager.fish
dst: /usr/share/fish/vendor_completions.d/certmanager.fish
file_info:
mode: 0644
checksum:
name_template: "checksums.txt"
release:
gitea:
owner: unkin
name: certmanager
gitea_urls:
api: https://git.unkin.net/api/v1
download: https://git.unkin.net
changelog:
sort: asc
filters:
+14 -2
View File
@@ -5,11 +5,23 @@ repos:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: check-merge-conflict
- id: mixed-line-ending
args: [--fix=lf]
- repo: https://github.com/dnephin/pre-commit-golang
rev: v0.5.1
hooks:
- id: go-fmt
- id: go-unit-tests
# dnephin's go-vet hook runs `go list` at the module root, which has no Go
# files here (the CLI lives under cmd/), so vet the whole module instead.
- repo: local
hooks:
- id: go-vet
- id: go-mod-tidy
name: go vet
entry: go vet ./...
language: system
types: [go]
pass_filenames: false
+18
View File
@@ -0,0 +1,18 @@
when:
- event: pull_request
steps:
- name: build
image: golang:1.25
commands:
- make build
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+39
View File
@@ -0,0 +1,39 @@
when:
- event: pull_request
# Kept out of test.yaml so the three required PR contexts (build/test/pre-commit)
# stay exactly what they were; this one needs a Vault next to it.
steps:
# End-to-end: real PKI mount, real approle login, real issuance, asserted
# against the exact argv Puppet's generate() uses.
- name: integration
image: golang:1.25
environment:
VAULT_VERSION: 1.20.4
VAULT_SHA256: fc5fb5d01d192f1216b139fb5c6af17e3af742aaeffc289fd861920ec55f2c9c
CERTMANAGER_IT_VAULT_ADDR: http://127.0.0.1:8200
# Throwaway dev-mode server on loopback; this token grants nothing.
CERTMANAGER_IT_VAULT_TOKEN: dev-root-token
commands:
# A long-running Vault as its own step or service wedges the workflow on
# the kubernetes backend, so the server runs inside this step. The golang
# image does not trust the estate's internal CA, so artifactapi is fetched
# over http and the archive is pinned by checksum instead.
- curl -fsSL -o /tmp/vault.zip "http://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/hashicorp-releases/vault/$${VAULT_VERSION}/vault_$${VAULT_VERSION}_linux_amd64.zip"
- echo "$${VAULT_SHA256} /tmp/vault.zip" | sha256sum -c -
- python3 -m zipfile -e /tmp/vault.zip /usr/local/bin/
- chmod +x /usr/local/bin/vault
# Dev mode serves plain HTTP; the test polls /v1/sys/health rather than
# sleeping, and the timeout keeps a wedged server from pinning an agent.
- vault server -dev -dev-root-token-id="$${CERTMANAGER_IT_VAULT_TOKEN}" -dev-listen-address=127.0.0.1:8200 >/tmp/vault.log 2>&1 &
- timeout 600 make integration-test
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+18
View File
@@ -0,0 +1,18 @@
when:
- event: pull_request
steps:
- name: pre-commit
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
commands:
- uvx pre-commit run --all-files
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+80
View File
@@ -0,0 +1,80 @@
when:
- event: tag
steps:
- name: test
image: golang:1.25
commands:
- go test -race ./...
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
# goreleaser is the single driver for the release: it cross-compiles the
# binaries, builds the RPM (nfpm is built in) and creates the Gitea release
# with the raw binaries, archives, RPM and checksums attached.
- name: goreleaser
image: golang:1.26
environment:
GITEA_TOKEN:
from_secret: RELEASER_TOKEN
commands:
# goreleaser reads the tag history for the changelog; Woodpecker's clone
# can be shallow.
- git fetch --tags --force
- go install github.com/goreleaser/goreleaser/v2@v2.17.1
- goreleaser release --clean
depends_on: [test]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
# goreleaser cannot PUT to artifactapi, so the RPM it built into dist/ is
# published to the local rpm repo here (a real yum repo; repodata
# regenerates automatically).
- name: upload-rpm
image: git.unkin.net/unkin/almalinux9-base:20260606
commands:
- |
HOST="https://artifactapi.k8s.syd1.au.unkin.net"
REPO="rpm-internal"
for rpm in dist/*.rpm; do
FILE=$$(basename "$$rpm")
# artifactapi has no HEAD route (returns 405); probe with GET against
# the served path (RPMs are stored under Packages/) to avoid re-upload.
code=$$(curl -s -o /dev/null -w '%{http_code}' "$$HOST/api/v2/remotes/$$REPO/files/Packages/$$FILE" || true)
if [ "$$code" = "200" ]; then
echo "$$FILE already exists in $$REPO (HTTP $$code); skipping upload"
continue
fi
echo "Uploading $$FILE to $$REPO (existence probe returned $$code)"
curl -f -X PUT \
"$$HOST/api/v2/remotes/$$REPO/files/$$FILE" \
-H "Content-Type: application/x-rpm" \
--data-binary @"$$rpm"
done
depends_on: [goreleaser]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+33
View File
@@ -0,0 +1,33 @@
when:
- event: pull_request
steps:
- name: lint
image: golangci/golangci-lint:latest
commands:
- golangci-lint run ./...
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
- name: test
image: golang:1.25
commands:
- go test -v -race ./...
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+101
View File
@@ -0,0 +1,101 @@
# AGENTS.md
## Project Overview
`certmanager` issues Vault PKI certificates for Puppet-managed nodes. It is a
drop-in replacement for the Python `certmanager` script that
`profiles::helpers::certmanager` used to render into a venv.
## The contract
`profiles::pki::vault` (puppet-prod) calls the binary during catalog
compilation:
```
/usr/local/bin/certmanager <fqdn> --alt-names "host,fqdn" --ip-sans "127.0.0.1,<ip>" --expiry-days 90 --json
```
`generate()` runs with `failonfail: true, combine: true`. `combine: true` dups
stderr onto stdout at spawn, so anything on stderr is spliced into the JSON
stream and `parsejson()` fails — breaking the compile with a parse error rather
than a clean failure. Two rules follow:
- the success path writes the JSON document to stdout and nothing to stderr
(`TestRunSuccessWritesNothingToStderr` guards this);
- every failure exits non-zero, with the message on stderr and stdout empty.
## Structure
```
cmd/certmanager/main.go # CLI (flattened: the positional arg is the command)
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)
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
.woodpecker/ # CI: build, test, integration, pre-commit (PR) + release (tag)
dist/ # build output: binaries, RPM, checksums (not committed)
```
## Code Patterns
- **`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.
- **Flat CLI**: the root command takes `common_name` positionally
(`cobra.ExactArgs(1)`). Only `completion` and `help` are subcommands.
- **`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
`auth_method` before any network call.
- **`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=...`).
## 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.
- `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.
- HTTP timeouts on every request.
- `tls_skip_verify` is a config key defaulting to `true`, preserving the
Python's `verify=False` while letting it be flipped without a code change.
- Non-zero exit on every failure path.
- `ldap` and `token` auth methods are gone; only `approle` and `kubernetes`
exist.
## Testing
```bash
make test # go test -v -race ./...
make integration-test # -tags=integration, against a real Vault dev server
```
Unit tests stub Vault with `httptest.NewTLSServer` — no live Vault is required.
Coverage: the golden `--json` stdout document, stderr emptiness on success,
rejected logins for both auth methods, missing/empty JWT, unknown
`auth_method`, a non-200 issue response, request timeout, TLS verification when
enabled, file and zip output, and the comma-list parsing table.
`test/integration` (build tag `integration`) provisions a real PKI mount,
policy and `bind_secret_id: false` approle role on a Vault dev server, then
runs the binary with Puppet's argv and asserts exit code, empty stderr, the
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.
## Gotchas
- Changing the JSON key set or key order breaks `parsejson()` in
`profiles::pki::vault`; `TestRunJSONGolden` pins the exact bytes.
- `alt_names` and `ip_sans` are always sent, as empty strings when unset, to
match what the Python posted.
- The RPM owns `/usr/local/bin/certmanager` as a symlink; the Puppet class that
manages that path must be retired in the same change that ships the RPM.
+59 -13
View File
@@ -1,29 +1,75 @@
BINARY := certmanager
CMD := ./cmd/certmanager
GOFLAGS := -trimpath
LDFLAGS := -s -w
BINARY := certmanager
CMD := ./cmd/certmanager
DIST := dist
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
# Puppet writes the config here (profiles::helpers::certmanager); override to
# bake a different path into the binary.
CONFIG_PATH ?= /opt/certmanager/config.yaml
CONFIG_PKG := git.unkin.net/unkin/certmanager/internal/config
GOFLAGS := -trimpath -ldflags="-s -w -X main.version=$(VERSION) -X $(CONFIG_PKG).defaultPath=$(CONFIG_PATH)"
OS ?= $(shell go env GOOS)
ARCH ?= $(shell go env GOARCH)
.PHONY: all build test lint clean install
.PHONY: all build test integration-test lint fmt pre-commit clean install completions release-snapshot patch minor major _tag
all: build
build:
go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY) $(CMD)
CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build $(GOFLAGS) -o $(DIST)/$(BINARY) $(CMD)
test:
go test ./...
go test -v -race ./...
test-verbose:
go test -v ./...
# End-to-end test against a real Vault dev server (docker/podman locally, the
# Woodpecker `vault` service in CI). Set VAULT_ADDR to use an existing server.
integration-test:
./scripts/integration-test.sh
lint:
golangci-lint run ./...
fmt:
gofmt -w .
pre-commit:
uvx pre-commit run --all-files
clean:
rm -f $(BINARY)
rm -rf $(DIST) $(BINARY)
install:
go install $(GOFLAGS) -ldflags "$(LDFLAGS)" $(CMD)
go install $(GOFLAGS) $(CMD)
tidy:
go mod tidy
# Generate bash/zsh/fish completions into dist/completions.
completions: build
@mkdir -p $(DIST)/completions
$(DIST)/$(BINARY) completion bash > $(DIST)/completions/$(BINARY).bash
$(DIST)/$(BINARY) completion zsh > $(DIST)/completions/_$(BINARY)
$(DIST)/$(BINARY) completion fish > $(DIST)/completions/$(BINARY).fish
# Build every release artifact (binaries, tar.gz, RPM) locally without
# publishing. goreleaser is the single driver for the tagged release too.
release-snapshot:
goreleaser release --snapshot --clean
# Bump helpers — read the latest semver tag and create the next one.
_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1)
_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0)
_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1)
_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2)
_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3)
patch:
@NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \
git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
minor:
@NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \
git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
major:
@NEW=v$(shell expr $(_MAJ) + 1).0.0; \
git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
_tag:
git push origin $(TAG)
+107 -121
View File
@@ -1,149 +1,135 @@
# certmanager
Vault PKI certificate issuance and SSH host key signing tool for Puppet-managed infrastructure.
Issues Vault PKI certificates for Puppet-managed nodes. Drop-in replacement for
the Python `certmanager` script deployed by `profiles::helpers::certmanager`,
compiled to a single static binary with no runtime dependencies.
Replaces the Python `certmanager` and `sshsignhost` scripts deployed by Puppet, compiled to a single static binary with no runtime dependencies.
Puppet calls it server-side during catalog compilation from
`profiles::pki::vault`:
## Installation
```sh
make build # produces ./certmanager
make install # installs to $GOPATH/bin
```
/usr/local/bin/certmanager <fqdn> --alt-names "host,fqdn" --ip-sans "127.0.0.1,<ip>" --expiry-days 90 --json
```
`generate()` runs the binary with `combine: true`, which splices stderr into the
stdout that `parsejson()` reads — so on the success path the binary writes
nothing but the JSON document to stdout, and nothing at all to stderr. Every
failure path exits non-zero.
## Usage
```
certmanager <common_name> [flags]
```
| Flag | Short | Type | Default | Description |
|---|---|---|---|---|
| `--alt-names` | `-a` | comma list | `""` | DNS subject alternative names |
| `--ip-sans` | `-i` | comma list | `""` | IP subject alternative names |
| `--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 |
Comma lists are split on `,`, whitespace-trimmed, and empty tokens dropped.
### JSON output
```json
{
"certificate": "<PEM leaf>",
"private_key": "<PEM key>",
"full_chain": "<issuing_ca PEM>\n<certificate PEM>",
"ca_certificate": "<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.
### File output
Without `--json` the certificate is written under `vault.output_path`
(defaulting to the working directory):
| Path | Contents |
|---|---|
| `<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 |
With `-c` the same four files are written into
`<output_path>/<common_name>.zip`.
## Configuration
Config file is read from `$XDG_CONFIG_HOME/certmanager/config.yaml` (defaults to `~/.config/certmanager/config.yaml`). Override with `--config`.
YAML, everything nested under `vault:`. The path is baked in at build time
(`make build CONFIG_PATH=...`) and overridable per-invocation with `--config`.
On Puppet-managed nodes the config is written to `/opt/certmanager/config.yaml` by `profiles::helpers::certmanager`.
### Auth methods
#### AppRole (current — Puppet nodes)
| 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` |
| `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 |
| `timeout` | no | `30s` | Per-request Vault timeout |
```yaml
vault:
addr: https://vault.service.consul:8200
auth_method: approle
approle_path: approle # optional, defaults to "approle"
approle_path: approle
role_id: <role_id>
secret_id: <secret_id> # optional
mount_point: pki_int
role_name: servers_default
output_path: /tmp/certmanager
```
#### LDAP (testing / interactive use)
```yaml
vault:
addr: https://vault.service.consul:8200
auth_method: ldap
ldap_path: ldap # optional, defaults to "ldap"
ldap_username: alice
ldap_password: secret
mount_point: pki_int
role_name: servers_default
output_path: /tmp/certmanager
```
#### Kubernetes (Puppet running in-cluster)
```yaml
vault:
addr: https://vault.service.consul:8200
auth_method: kubernetes
kubernetes_path: kubernetes # optional, defaults to "kubernetes"
kubernetes_role: puppet
kubernetes_token_file: /var/run/secrets/kubernetes.io/serviceaccount/token # optional
mount_point: pki_int
role_name: servers_default
output_path: /tmp/certmanager
```
#### Token (bootstrap / testing)
```yaml
vault:
addr: https://vault.service.consul:8200
auth_method: token
token: hvs.XXXX
mount_point: pki_int
role_name: servers_default
output_path: /tmp/certmanager
```
> **Note:** `auth_method` defaults to `approle` when omitted, preserving backwards compatibility with the existing Python script config format.
## Usage
### PKI certificate
```sh
# Write files to output_path/<common_name>/
certmanager pki host.example.com \
--alt-names host,host.example.com \
--ip-sans 127.0.0.1,10.0.0.1 \
--expiry-days 90
# JSON output (for use with Puppet generate())
certmanager pki host.example.com \
--alt-names host,host.example.com \
--ip-sans 127.0.0.1,10.0.0.1 \
--expiry-days 90 \
--json
```
Output files (written to `<output_path>/<common_name>/`):
| File | Contents |
|---|---|
| `certificate.crt` | Signed certificate |
| `private.key` | Private key (mode 0600) |
| `ca_certificate.crt` | Issuing CA certificate |
| `full_chain.crt` | CA + certificate |
| `certificate.pem` | Certificate + key bundle (mode 0600) |
JSON keys: `certificate`, `private_key`, `ca_certificate`, `full_chain`.
### SSH host certificate signing
```sh
# Plain output (signed cert to stdout)
certmanager ssh \
--public-key "ssh-rsa AAAA..." \
--valid-principals host.example.com,host,10.0.0.1
# JSON output (for use with Puppet generate())
certmanager ssh \
--public-key "$(cat /etc/ssh/ssh_host_rsa_key.pub)" \
--valid-principals host.example.com,host \
--ttl 87600h \
--json
```
JSON key: `signed_key`.
### Global flags
| Flag | Default | Description |
|---|---|---|
| `--config` | `~/.config/certmanager/config.yaml` | Path to config file |
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
unknown `auth_method` each fail naming the cause; there is no fallback between
methods.
## Development
```sh
make build # compile
make test # run tests
make test-verbose # run tests with -v
make tidy # go mod tidy
make lint # golangci-lint (requires golangci-lint installed)
make build # -> dist/certmanager (CGO disabled, static)
make test # go test -v -race ./...
make lint # golangci-lint
make pre-commit # uvx pre-commit run --all-files
make completions # -> dist/completions/
make integration-test # end-to-end against a real Vault dev server
make release-snapshot # goreleaser: binaries, tar.gz and RPM into dist/
```
## Release
`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.
Point it at an existing dev server with `CERTMANAGER_IT_VAULT_ADDR`
(`VAULT_ADDR` is deliberately ignored — the test writes to the PKI mount).
Uses [goreleaser](https://goreleaser.com/). Tag a version and run:
## Releasing
```sh
goreleaser release --clean
```
goreleaser drives the whole release from a `v*` tag:
- raw binaries `certmanager-<os>-<arch>` (linux/darwin, amd64/arm64) attached to
the Gitea release — the puppetserver-compiler initContainer fetches one file
by URL and checksums it, so it cannot unpack an archive;
- `tar.gz` archives and `checksums.txt`;
- `certmanager-<version>-1.<arch>.rpm`, installing `/usr/bin/certmanager`,
symlinking `/usr/local/bin/certmanager` (the path Puppet calls) and shipping
bash/zsh/fish completions.
The release pipeline then `PUT`s the RPM to the artifactapi `rpm-internal`
repo, which goreleaser cannot do itself.
+113 -122
View File
@@ -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
}
+450
View File
@@ -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")
}
})
}
}
+94 -35
View File
@@ -3,7 +3,7 @@ package config
import (
"fmt"
"os"
"path/filepath"
"time"
"gopkg.in/yaml.v3"
)
@@ -13,77 +13,136 @@ type AuthMethod string
const (
AuthMethodAppRole AuthMethod = "approle"
AuthMethodLDAP AuthMethod = "ldap"
AuthMethodKubernetes AuthMethod = "kubernetes"
AuthMethodToken AuthMethod = "token"
)
// DefaultJWTPath is the in-cluster service account token location.
const DefaultJWTPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
// DefaultTimeout bounds every Vault request; Puppet calls this binary from
// generate() during catalog compilation, so an unbounded request would hang the
// compile forever.
const DefaultTimeout = 30 * time.Second
// defaultPath is overridable at build time with
// -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 }
// Config is the top-level configuration structure.
type Config struct {
Vault VaultConfig `yaml:"vault"`
}
// VaultConfig holds Vault connection and auth parameters.
// Only the fields relevant to the chosen AuthMethod need to be populated.
// VaultConfig holds Vault connection and auth parameters. Only the fields
// relevant to the chosen AuthMethod need to be populated.
type VaultConfig struct {
Addr string `yaml:"addr"`
AuthMethod AuthMethod `yaml:"auth_method"` // approle | ldap | kubernetes | token
AuthMethod AuthMethod `yaml:"auth_method"`
MountPoint string `yaml:"mount_point"`
RoleName string `yaml:"role_name"`
OutputPath string `yaml:"output_path"`
// approle
ApprolePath string `yaml:"approle_path"`
RoleID string `yaml:"role_id"`
SecretID string `yaml:"secret_id"`
ApprolePath string `yaml:"approle_path"`
// ldap
LDAPPath string `yaml:"ldap_path"`
LDAPUsername string `yaml:"ldap_username"`
LDAPPassword string `yaml:"ldap_password"`
K8sMount string `yaml:"k8s_mount"`
K8sRole string `yaml:"k8s_role"`
JWTPath string `yaml:"jwt_path"`
// kubernetes
KubernetesPath string `yaml:"kubernetes_path"`
KubernetesRole string `yaml:"kubernetes_role"`
KubernetesTokenFile string `yaml:"kubernetes_token_file"`
// TLSSkipVerify defaults to true, preserving the Python script's
// verify=False behaviour until the estate trusts the internal CA.
TLSSkipVerify *bool `yaml:"tls_skip_verify"`
// token (static; useful for testing or bootstrap)
Token string `yaml:"token"`
// Timeout is a Go duration string (e.g. "30s").
Timeout string `yaml:"timeout"`
}
// DefaultPath returns the XDG-compliant default config file path:
// $XDG_CONFIG_HOME/certmanager/config.yaml, falling back to
// $HOME/.config/certmanager/config.yaml.
func DefaultPath() string {
base := os.Getenv("XDG_CONFIG_HOME")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return "config.yaml"
}
base = filepath.Join(home, ".config")
// SkipTLSVerify reports whether certificate verification is disabled.
func (v VaultConfig) SkipTLSVerify() bool {
if v.TLSSkipVerify == nil {
return true
}
return filepath.Join(base, "certmanager", "config.yaml")
return *v.TLSSkipVerify
}
// Load reads and parses the config file at the given path.
// RequestTimeout returns the configured per-request timeout.
func (v VaultConfig) RequestTimeout() (time.Duration, error) {
if v.Timeout == "" {
return DefaultTimeout, nil
}
d, err := time.ParseDuration(v.Timeout)
if err != nil {
return 0, fmt.Errorf("vault.timeout %q is not a valid duration: %w", v.Timeout, err)
}
if d <= 0 {
return 0, fmt.Errorf("vault.timeout %q must be positive", v.Timeout)
}
return d, nil
}
// Load reads, parses, defaults and validates the config file at path.
func Load(path string) (*Config, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open config %q: %w", path, err)
}
defer f.Close()
defer func() { _ = f.Close() }()
var cfg Config
if err := yaml.NewDecoder(f).Decode(&cfg); err != nil {
return nil, fmt.Errorf("parse config %q: %w", path, err)
}
// Default to approle for backwards-compatibility with the existing
// Python certmanager/sshsignhost config format.
if cfg.Vault.AuthMethod == "" {
cfg.Vault.AuthMethod = AuthMethodAppRole
}
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
}
// 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 {
if v.Addr == "" {
return fmt.Errorf("vault.addr is required")
}
if v.MountPoint == "" {
return fmt.Errorf("vault.mount_point is required")
}
if v.RoleName == "" {
return fmt.Errorf("vault.role_name is required")
}
if _, err := v.RequestTimeout(); err != nil {
return err
}
switch v.AuthMethod {
case AuthMethodAppRole:
if v.RoleID == "" {
return fmt.Errorf("vault.role_id is required for auth_method %q", v.AuthMethod)
}
if v.ApprolePath == "" {
return fmt.Errorf("vault.approle_path is required for auth_method %q", v.AuthMethod)
}
case AuthMethodKubernetes:
if v.K8sMount == "" {
return fmt.Errorf("vault.k8s_mount is required for auth_method %q", v.AuthMethod)
}
if v.K8sRole == "" {
return fmt.Errorf("vault.k8s_role is required for auth_method %q", v.AuthMethod)
}
default:
return fmt.Errorf("unknown vault.auth_method %q (valid: %s, %s)",
v.AuthMethod, AuthMethodAppRole, AuthMethodKubernetes)
}
return nil
}
+85 -120
View File
@@ -3,160 +3,125 @@ package config
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestDefaultPath_XDGSet(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", "/tmp/xdg")
got := DefaultPath()
want := "/tmp/xdg/certmanager/config.yaml"
if got != want {
t.Errorf("got %q, want %q", got, want)
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 TestDefaultPath_XDGUnset(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", "")
home, _ := os.UserHomeDir()
got := DefaultPath()
want := filepath.Join(home, ".config", "certmanager", "config.yaml")
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestLoad_AppRole(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.yaml")
content := `
vault:
addr: https://vault.example.com:8200
auth_method: approle
const approleBody = `vault:
addr: https://vault.service.consul:8200
approle_path: approle
role_id: my-role-id
role_id: abc-123
mount_point: pki_int
role_name: servers_default
output_path: /tmp/certs
output_path: /tmp/certmanager
`
os.WriteFile(path, []byte(content), 0o644)
cfg, err := Load(path)
func TestLoadAppRoleDefaults(t *testing.T) {
cfg, err := Load(writeConfig(t, approleBody))
if err != nil {
t.Fatalf("Load() error: %v", err)
t.Fatalf("Load: %v", err)
}
if cfg.Vault.AuthMethod != AuthMethodAppRole {
t.Errorf("auth_method = %q", cfg.Vault.AuthMethod)
t.Errorf("auth_method = %q, want %q (default)", cfg.Vault.AuthMethod, AuthMethodAppRole)
}
if cfg.Vault.RoleID != "my-role-id" {
t.Errorf("role_id = %q", cfg.Vault.RoleID)
if cfg.Vault.JWTPath != DefaultJWTPath {
t.Errorf("jwt_path = %q, want %q", cfg.Vault.JWTPath, DefaultJWTPath)
}
if cfg.Vault.OutputPath != "/tmp/certmanager" {
t.Errorf("output_path = %q, want it read from under vault:", cfg.Vault.OutputPath)
}
if !cfg.Vault.SkipTLSVerify() {
t.Error("tls_skip_verify must default to true")
}
d, err := cfg.Vault.RequestTimeout()
if err != nil || d != DefaultTimeout {
t.Errorf("RequestTimeout() = %v, %v, want %v", d, err, DefaultTimeout)
}
}
func TestLoad_DefaultAuthMethod(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.yaml")
// Omit auth_method — should default to approle for backwards compat.
content := `
vault:
addr: https://vault.example.com:8200
role_id: my-role-id
approle_path: approle
mount_point: pki_int
role_name: servers_default
`
os.WriteFile(path, []byte(content), 0o644)
cfg, err := Load(path)
func TestLoadOverrides(t *testing.T) {
cfg, err := Load(writeConfig(t, approleBody+" tls_skip_verify: false\n timeout: 5s\n"))
if err != nil {
t.Fatalf("Load() error: %v", err)
t.Fatalf("Load: %v", err)
}
if cfg.Vault.AuthMethod != AuthMethodAppRole {
t.Errorf("expected default approle, got %q", cfg.Vault.AuthMethod)
if cfg.Vault.SkipTLSVerify() {
t.Error("tls_skip_verify: false must disable skipping")
}
d, err := cfg.Vault.RequestTimeout()
if err != nil || d != 5*time.Second {
t.Errorf("RequestTimeout() = %v, %v, want 5s", d, err)
}
}
func TestLoad_LDAP(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.yaml")
content := `
vault:
addr: https://vault.example.com:8200
auth_method: ldap
ldap_path: ldap
ldap_username: alice
ldap_password: secret
mount_point: pki_int
role_name: servers_default
`
os.WriteFile(path, []byte(content), 0o644)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Vault.AuthMethod != AuthMethodLDAP {
t.Errorf("auth_method = %q", cfg.Vault.AuthMethod)
}
if cfg.Vault.LDAPUsername != "alice" {
t.Errorf("ldap_username = %q", cfg.Vault.LDAPUsername)
}
}
func TestLoad_Kubernetes(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.yaml")
content := `
vault:
addr: https://vault.example.com:8200
func TestLoadKubernetes(t *testing.T) {
cfg, err := Load(writeConfig(t, `vault:
addr: https://vault.service.consul:8200
auth_method: kubernetes
kubernetes_path: kubernetes
kubernetes_role: puppet
kubernetes_token_file: /var/run/secrets/kubernetes.io/serviceaccount/token
k8s_mount: k8s/au/syd1
k8s_role: puppet_certmanager
mount_point: pki_int
role_name: servers_default
`
os.WriteFile(path, []byte(content), 0o644)
cfg, err := Load(path)
`))
if err != nil {
t.Fatalf("Load() error: %v", err)
t.Fatalf("Load: %v", err)
}
if cfg.Vault.AuthMethod != AuthMethodKubernetes {
t.Errorf("auth_method = %q", cfg.Vault.AuthMethod)
}
if cfg.Vault.KubernetesRole != "puppet" {
t.Errorf("kubernetes_role = %q", cfg.Vault.KubernetesRole)
if cfg.Vault.K8sMount != "k8s/au/syd1" || cfg.Vault.K8sRole != "puppet_certmanager" {
t.Errorf("kubernetes keys not parsed: %+v", cfg.Vault)
}
}
func TestLoad_Token(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.yaml")
content := `
vault:
addr: https://vault.example.com:8200
auth_method: token
token: hvs.statictoken
mount_point: pki_int
role_name: servers_default
`
os.WriteFile(path, []byte(content), 0o644)
func TestLoadErrors(t *testing.T) {
tests := []struct {
name string
body string
wantErr string
}{
{"missing addr", "vault:\n mount_point: pki_int\n role_name: r\n role_id: x\n approle_path: approle\n", "vault.addr"},
{"missing mount_point", "vault:\n addr: https://v:8200\n role_name: r\n role_id: x\n approle_path: approle\n", "vault.mount_point"},
{"missing role_name", "vault:\n addr: https://v:8200\n mount_point: pki_int\n role_id: x\n approle_path: approle\n", "vault.role_name"},
{"missing role_id", "vault:\n addr: https://v:8200\n mount_point: pki_int\n role_name: r\n approle_path: approle\n", "vault.role_id"},
{"missing approle_path", "vault:\n addr: https://v:8200\n mount_point: pki_int\n role_name: r\n role_id: x\n", "vault.approle_path"},
{"missing k8s_mount", "vault:\n addr: https://v:8200\n auth_method: kubernetes\n k8s_role: r\n mount_point: pki_int\n role_name: r\n", "vault.k8s_mount"},
{"missing k8s_role", "vault:\n addr: https://v:8200\n auth_method: kubernetes\n k8s_mount: m\n mount_point: pki_int\n role_name: r\n", "vault.k8s_role"},
{"unknown auth method", "vault:\n addr: https://v:8200\n auth_method: token\n mount_point: pki_int\n role_name: r\n", `unknown vault.auth_method "token"`},
{"bad timeout", approleBody + " timeout: soon\n", "not a valid duration"},
{"zero timeout", approleBody + " timeout: 0s\n", "must be positive"},
{"malformed yaml", "vault: [\n", "parse config"},
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Vault.AuthMethod != AuthMethodToken {
t.Errorf("auth_method = %q", cfg.Vault.AuthMethod)
}
if cfg.Vault.Token != "hvs.statictoken" {
t.Errorf("token = %q", cfg.Vault.Token)
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := Load(writeConfig(t, tc.body))
if err == nil {
t.Fatal("Load succeeded, want error")
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Errorf("error = %v, want it to mention %q", err, tc.wantErr)
}
})
}
}
func TestLoad_MissingFile(t *testing.T) {
_, err := Load("/nonexistent/config.yaml")
if err == nil {
t.Error("expected error for missing file, got nil")
func TestLoadMissingFile(t *testing.T) {
_, err := Load(filepath.Join(t.TempDir(), "absent.yaml"))
if err == nil || !strings.Contains(err.Error(), "open config") {
t.Fatalf("error = %v, want an open config failure", err)
}
}
func TestDefaultPath(t *testing.T) {
if got := DefaultPath(); got != "/opt/certmanager/config.yaml" {
t.Errorf("DefaultPath() = %q, want the Puppet-managed path", got)
}
}
+16 -19
View File
@@ -7,18 +7,19 @@ import (
"git.unkin.net/unkin/certmanager/internal/vault"
)
// CertificateResponse is the JSON-serialisable output returned to callers.
// 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"`
CACertificate string `json:"ca_certificate"`
}
type issueRequest struct {
CommonName string `json:"common_name"`
AltNames string `json:"alt_names,omitempty"`
IPSans string `json:"ip_sans,omitempty"`
AltNames string `json:"alt_names"`
IPSans string `json:"ip_sans"`
TTL string `json:"ttl"`
}
@@ -30,31 +31,27 @@ type issueResponse struct {
} `json:"data"`
}
// IssueCert requests a PKI certificate from Vault.
// IssueCert requests a certificate from the Vault PKI role.
func IssueCert(client *vault.Client, mountPoint, roleName, commonName string, altNames, ipSans []string, expiryDays int) (*CertificateResponse, error) {
req := issueRequest{
CommonName: commonName,
AltNames: strings.Join(altNames, ","),
IPSans: strings.Join(ipSans, ","),
TTL: fmt.Sprintf("%dd", expiryDays),
}
if len(altNames) > 0 {
req.AltNames = strings.Join(altNames, ",")
}
if len(ipSans) > 0 {
req.IPSans = strings.Join(ipSans, ",")
}
var resp issueResponse
path := fmt.Sprintf("%s/issue/%s", mountPoint, roleName)
if err := client.Post(path, req, &resp); err != nil {
if err := client.Post(fmt.Sprintf("%s/issue/%s", mountPoint, roleName), req, &resp); err != nil {
return nil, err
}
fullChain := resp.Data.IssuingCA + "\n" + resp.Data.Certificate
if resp.Data.Certificate == "" || resp.Data.PrivateKey == "" {
return nil, fmt.Errorf("vault returned no certificate data for %q", commonName)
}
return &CertificateResponse{
Certificate: resp.Data.Certificate,
PrivateKey: resp.Data.PrivateKey,
FullChain: fullChain,
FullChain: resp.Data.IssuingCA + "\n" + resp.Data.Certificate,
CACertificate: resp.Data.IssuingCA,
}, nil
}
+96 -41
View File
@@ -2,8 +2,10 @@ package pki_test
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
@@ -12,73 +14,126 @@ import (
"git.unkin.net/unkin/certmanager/internal/vault"
)
func newVaultClient(t *testing.T, mux *http.ServeMux) *vault.Client {
func newClient(t *testing.T, issue http.HandlerFunc) *vault.Client {
t.Helper()
const token = "test-token"
mux.HandleFunc("/v1/auth/approle/login", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"auth": map[string]any{"client_token": token},
})
})
srv := httptest.NewTLSServer(mux)
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/login") {
_, _ = fmt.Fprint(w, `{"auth":{"client_token":"s.token"}}`)
return
}
issue(w, r)
}))
t.Cleanup(srv.Close)
client, err := vault.New(config.VaultConfig{
c, err := vault.New(config.VaultConfig{
Addr: srv.URL,
AuthMethod: config.AuthMethodAppRole,
RoleID: "role",
ApprolePath: "approle",
RoleID: "role-id",
MountPoint: "pki_int",
RoleName: "servers_default",
})
if err != nil {
t.Fatalf("vault.New: %v", err)
}
return client
return c
}
func TestIssueCert(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/v1/pki_int/issue/servers_default", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"certificate": "CERT",
"private_key": "KEY",
"issuing_ca": "CA",
},
})
var path string
var body map[string]any
c := newClient(t, func(w http.ResponseWriter, r *http.Request) {
path = r.URL.Path
_ = json.NewDecoder(r.Body).Decode(&body)
_, _ = fmt.Fprint(w, `{"data":{"certificate":"CERT","private_key":"KEY","issuing_ca":"CA"}}`)
})
client := newVaultClient(t, mux)
cert, err := pki.IssueCert(client, "pki_int", "servers_default", "host.example.com",
[]string{"host", "host.example.com"}, []string{"127.0.0.1"}, 90)
got, err := pki.IssueCert(c, "pki_int", "servers_default", "host.example.com",
[]string{"host", "host.example.com"}, []string{"127.0.0.1", "10.0.0.5"}, 90)
if err != nil {
t.Fatalf("IssueCert: %v", err)
}
if cert.Certificate != "CERT" {
t.Errorf("certificate = %q", cert.Certificate)
if path != "/v1/pki_int/issue/servers_default" {
t.Errorf("issue path = %q", path)
}
if cert.PrivateKey != "KEY" {
t.Errorf("private_key = %q", cert.PrivateKey)
wantBody := 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 cert.CACertificate != "CA" {
t.Errorf("ca_certificate = %q", cert.CACertificate)
if !reflect.DeepEqual(body, wantBody) {
t.Errorf("issue body = %v, want %v", body, wantBody)
}
if !strings.Contains(cert.FullChain, "CA") || !strings.Contains(cert.FullChain, "CERT") {
t.Errorf("full_chain = %q", cert.FullChain)
want := &pki.CertificateResponse{
Certificate: "CERT",
PrivateKey: "KEY",
FullChain: "CA\nCERT",
CACertificate: "CA",
}
if !reflect.DeepEqual(got, want) {
t.Errorf("IssueCert = %+v, want %+v", got, want)
}
}
func TestIssueCert_VaultError(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/v1/pki_int/issue/servers_default", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "permission denied", http.StatusForbidden)
func TestIssueCertSendsEmptySANFields(t *testing.T) {
var body map[string]any
c := newClient(t, func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&body)
_, _ = fmt.Fprint(w, `{"data":{"certificate":"CERT","private_key":"KEY","issuing_ca":"CA"}}`)
})
client := newVaultClient(t, mux)
_, err := pki.IssueCert(client, "pki_int", "servers_default", "host.example.com", nil, nil, 90)
if err == nil {
t.Error("expected error, got nil")
if _, err := pki.IssueCert(c, "pki_int", "servers_default", "host", nil, nil, 365); err != nil {
t.Fatalf("IssueCert: %v", err)
}
if body["alt_names"] != "" || body["ip_sans"] != "" {
t.Errorf("empty SANs must be sent as empty strings, got %v", body)
}
if body["ttl"] != "365d" {
t.Errorf("ttl = %v, want 365d", body["ttl"])
}
}
func TestIssueCertVaultError(t *testing.T) {
c := newClient(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = fmt.Fprint(w, `{"errors":["common name not allowed"]}`)
})
_, err := pki.IssueCert(c, "pki_int", "servers_default", "host", nil, nil, 90)
if err == nil {
t.Fatal("IssueCert succeeded, want error")
}
if !strings.Contains(err.Error(), "vault returned 400") || !strings.Contains(err.Error(), "common name not allowed") {
t.Errorf("error = %v, want the status and Vault's message", err)
}
}
func TestIssueCertEmptyPayload(t *testing.T) {
c := newClient(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = fmt.Fprint(w, `{"data":{}}`)
})
_, err := pki.IssueCert(c, "pki_int", "servers_default", "host", nil, nil, 90)
if err == nil || !strings.Contains(err.Error(), "no certificate data") {
t.Fatalf("error = %v, want a missing-data failure", err)
}
}
func TestCertificateResponseJSONKeyOrder(t *testing.T) {
b, err := json.Marshal(&pki.CertificateResponse{
Certificate: "CERT",
PrivateKey: "KEY",
FullChain: "CA\nCERT",
CACertificate: "CA",
})
if err != nil {
t.Fatalf("Marshal: %v", err)
}
want := `{"certificate":"CERT","private_key":"KEY","full_chain":"CA\nCERT","ca_certificate":"CA"}`
if string(b) != want {
t.Errorf("JSON = %s, want %s", b, want)
}
}
-52
View File
@@ -1,52 +0,0 @@
package ssh
import (
"fmt"
"strings"
"git.unkin.net/unkin/certmanager/internal/vault"
)
// SignedCertResponse is the JSON-serialisable output returned to callers.
type SignedCertResponse struct {
SignedKey string `json:"signed_key"`
}
type signRequest struct {
CertType string `json:"cert_type"`
PublicKey string `json:"public_key"`
ValidPrincipals string `json:"valid_principals"`
TTL string `json:"ttl"`
}
type signResponse struct {
Data struct {
SignedKey string `json:"signed_key"`
} `json:"data"`
}
// SignHostCert signs an SSH host public key via the Vault SSH secrets engine.
func SignHostCert(client *vault.Client, mountPoint, roleName, publicKey string, principals []string, ttl string) (*SignedCertResponse, error) {
if ttl == "" {
ttl = "87600h"
}
req := signRequest{
CertType: "host",
PublicKey: publicKey,
ValidPrincipals: strings.Join(principals, ","),
TTL: ttl,
}
var resp signResponse
path := fmt.Sprintf("%s/sign/%s", mountPoint, roleName)
if err := client.Post(path, req, &resp); err != nil {
return nil, err
}
if resp.Data.SignedKey == "" {
return nil, fmt.Errorf("vault returned empty signed_key")
}
return &SignedCertResponse{SignedKey: resp.Data.SignedKey}, nil
}
-96
View File
@@ -1,96 +0,0 @@
package ssh_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.unkin.net/unkin/certmanager/internal/config"
"git.unkin.net/unkin/certmanager/internal/ssh"
"git.unkin.net/unkin/certmanager/internal/vault"
)
func newVaultClient(t *testing.T, mux *http.ServeMux) *vault.Client {
t.Helper()
const token = "test-token"
mux.HandleFunc("/v1/auth/approle/login", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"auth": map[string]any{"client_token": token},
})
})
srv := httptest.NewTLSServer(mux)
t.Cleanup(srv.Close)
client, err := vault.New(config.VaultConfig{
Addr: srv.URL,
AuthMethod: config.AuthMethodAppRole,
RoleID: "role",
ApprolePath: "approle",
})
if err != nil {
t.Fatalf("vault.New: %v", err)
}
return client
}
func TestSignHostCert(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/v1/ssh-host-signer/sign/hostrole", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"signed_key": "ssh-rsa-cert-v01@openssh.com AAAA...",
},
})
})
client := newVaultClient(t, mux)
resp, err := ssh.SignHostCert(client, "ssh-host-signer", "hostrole",
"ssh-rsa AAAA...", []string{"host.example.com", "host"}, "87600h")
if err != nil {
t.Fatalf("SignHostCert: %v", err)
}
if resp.SignedKey != "ssh-rsa-cert-v01@openssh.com AAAA..." {
t.Errorf("signed_key = %q", resp.SignedKey)
}
}
func TestSignHostCert_DefaultTTL(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/v1/ssh-host-signer/sign/hostrole", func(w http.ResponseWriter, r *http.Request) {
var body map[string]string
json.NewDecoder(r.Body).Decode(&body)
if body["ttl"] != "87600h" {
t.Errorf("expected default ttl 87600h, got %q", body["ttl"])
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{"signed_key": "CERT"},
})
})
client := newVaultClient(t, mux)
_, err := ssh.SignHostCert(client, "ssh-host-signer", "hostrole", "ssh-rsa AAAA...", []string{"host"}, "")
if err != nil {
t.Fatalf("SignHostCert: %v", err)
}
}
func TestSignHostCert_EmptySignedKey(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/v1/ssh-host-signer/sign/hostrole", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{"signed_key": ""},
})
})
client := newVaultClient(t, mux)
_, err := ssh.SignHostCert(client, "ssh-host-signer", "hostrole", "ssh-rsa AAAA...", []string{"host"}, "87600h")
if err == nil {
t.Error("expected error for empty signed_key, got nil")
}
}
+61 -103
View File
@@ -2,16 +2,20 @@ package vault
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"git.unkin.net/unkin/certmanager/internal/config"
)
const defaultKubernetesTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token"
// maxErrorBody caps how much of a Vault error response is quoted back.
const maxErrorBody = 2048
// Client is an authenticated Vault HTTP client.
type Client struct {
@@ -20,53 +24,45 @@ type Client struct {
http *http.Client
}
// New authenticates to Vault using the method specified in cfg and returns a
// ready Client.
// New authenticates to Vault using the method named in cfg and returns a ready
// 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) {
// TLS verification is intentionally skipped to match the existing Python
// scripts; the TODO comment in those scripts notes this should be removed
// once certs are generated everywhere.
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
}
httpClient := &http.Client{Transport: transport}
c := &Client{addr: cfg.Addr, http: httpClient}
var err error
switch cfg.AuthMethod {
case config.AuthMethodAppRole, "":
err = c.loginAppRole(cfg)
case config.AuthMethodLDAP:
err = c.loginLDAP(cfg)
case config.AuthMethodKubernetes:
err = c.loginKubernetes(cfg)
case config.AuthMethodToken:
err = c.loginToken(cfg)
default:
return nil, fmt.Errorf("unknown auth_method %q (valid: approle, ldap, kubernetes, token)", cfg.AuthMethod)
}
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,
}
c := &Client{
addr: strings.TrimRight(cfg.Addr, "/"),
http: &http.Client{Transport: transport, Timeout: timeout},
}
switch cfg.AuthMethod {
case config.AuthMethodAppRole:
err = c.loginAppRole(cfg)
case config.AuthMethodKubernetes:
err = c.loginKubernetes(cfg)
default:
return nil, fmt.Errorf("unknown vault.auth_method %q (valid: %s, %s)",
cfg.AuthMethod, config.AuthMethodAppRole, config.AuthMethodKubernetes)
}
if err != nil {
return nil, err
}
return c, nil
}
// ---------------------------------------------------------------------------
// auth method implementations
// ---------------------------------------------------------------------------
func (c *Client) loginAppRole(cfg config.VaultConfig) error {
path := cfg.ApprolePath
if path == "" {
path = "approle"
}
// The Vault role sets bind_secret_id: false, so role_id alone logs in.
payload := map[string]string{"role_id": cfg.RoleID}
if cfg.SecretID != "" {
payload["secret_id"] = cfg.SecretID
}
token, err := c.fetchToken(fmt.Sprintf("auth/%s/login", path), payload)
token, err := c.fetchToken(fmt.Sprintf("auth/%s/login", cfg.ApprolePath), payload)
if err != nil {
return fmt.Errorf("approle login: %w", err)
}
@@ -74,38 +70,18 @@ func (c *Client) loginAppRole(cfg config.VaultConfig) error {
return nil
}
func (c *Client) loginLDAP(cfg config.VaultConfig) error {
path := cfg.LDAPPath
if path == "" {
path = "ldap"
}
payload := map[string]string{"password": cfg.LDAPPassword}
token, err := c.fetchToken(fmt.Sprintf("auth/%s/login/%s", path, cfg.LDAPUsername), payload)
if err != nil {
return fmt.Errorf("ldap login: %w", err)
}
c.token = token
return nil
}
func (c *Client) loginKubernetes(cfg config.VaultConfig) error {
path := cfg.KubernetesPath
if path == "" {
path = "kubernetes"
}
tokenFile := cfg.KubernetesTokenFile
if tokenFile == "" {
tokenFile = defaultKubernetesTokenFile
}
jwt, err := os.ReadFile(tokenFile)
raw, err := os.ReadFile(cfg.JWTPath)
if err != nil {
return fmt.Errorf("kubernetes login: read service account token %q: %w", tokenFile, err)
return fmt.Errorf("kubernetes login: read service account token %q: %w", cfg.JWTPath, err)
}
payload := map[string]string{
"role": cfg.KubernetesRole,
"jwt": string(jwt),
jwt := strings.TrimSpace(string(raw))
if jwt == "" {
return fmt.Errorf("kubernetes login: service account token %q is empty", cfg.JWTPath)
}
token, err := c.fetchToken(fmt.Sprintf("auth/%s/login", path), payload)
payload := map[string]string{"role": cfg.K8sRole, "jwt": jwt}
token, err := c.fetchToken(fmt.Sprintf("auth/%s/login", cfg.K8sMount), payload)
if err != nil {
return fmt.Errorf("kubernetes login: %w", err)
}
@@ -113,74 +89,56 @@ func (c *Client) loginKubernetes(cfg config.VaultConfig) error {
return nil
}
func (c *Client) loginToken(cfg config.VaultConfig) error {
if cfg.Token == "" {
return fmt.Errorf("token auth: vault.token must not be empty")
}
c.token = cfg.Token
return nil
}
// fetchToken posts to a Vault auth endpoint and extracts the client token.
func (c *Client) fetchToken(authPath string, payload any) (string, error) {
body, err := json.Marshal(payload)
if err != nil {
return "", err
}
url := fmt.Sprintf("%s/v1/%s", c.addr, authPath)
resp, err := c.http.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status %d", resp.StatusCode)
}
var result struct {
Auth struct {
ClientToken string `json:"client_token"`
} `json:"auth"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("decode response: %w", err)
if err := c.post(authPath, "", payload, &result); err != nil {
return "", err
}
if result.Auth.ClientToken == "" {
return "", fmt.Errorf("empty client token in response")
return "", fmt.Errorf("vault returned no auth.client_token")
}
return result.Auth.ClientToken, nil
}
// ---------------------------------------------------------------------------
// request helpers
// ---------------------------------------------------------------------------
// 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)
}
func (c *Client) post(path, token string, payload, out any) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
url := fmt.Sprintf("%s/v1/%s", c.addr, path)
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("X-Vault-Token", c.token)
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("X-Vault-Token", token)
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("vault POST %s: %w", path, err)
return fmt.Errorf("POST %s: %w", path, err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("vault POST %s: unexpected status %d", path, resp.StatusCode)
detail, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody))
return fmt.Errorf("POST %s: vault returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(detail)))
}
return json.NewDecoder(resp.Body).Decode(out)
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("POST %s: decode response: %w", path, err)
}
return nil
}
+206 -228
View File
@@ -2,282 +2,260 @@ package vault_test
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.unkin.net/unkin/certmanager/internal/config"
"git.unkin.net/unkin/certmanager/internal/vault"
)
// tokenHandler returns a handler that serves a fixed Vault token on POST.
func tokenHandler(token string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"auth": map[string]any{"client_token": token},
})
}
}
func newTLSServer(t *testing.T, mux *http.ServeMux) *httptest.Server {
func newServer(t *testing.T, h http.HandlerFunc) *httptest.Server {
t.Helper()
srv := httptest.NewTLSServer(mux)
srv := httptest.NewTLSServer(h)
t.Cleanup(srv.Close)
return srv
}
// ---------------------------------------------------------------------------
// AppRole
// ---------------------------------------------------------------------------
func TestNew_AppRole(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/v1/auth/approle/login", tokenHandler("tok-approle"))
srv := newTLSServer(t, mux)
_, err := vault.New(config.VaultConfig{
Addr: srv.URL,
func baseConfig(addr string) config.VaultConfig {
return config.VaultConfig{
Addr: addr,
AuthMethod: config.AuthMethodAppRole,
ApprolePath: "approle",
RoleID: "role-id",
})
if err != nil {
t.Fatalf("AppRole auth failed: %v", err)
MountPoint: "pki_int",
RoleName: "servers_default",
}
}
func TestNew_AppRole_WithSecretID(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/v1/auth/approle/login", func(w http.ResponseWriter, r *http.Request) {
var body map[string]string
json.NewDecoder(r.Body).Decode(&body)
if body["secret_id"] != "my-secret" {
http.Error(w, "missing secret_id", http.StatusBadRequest)
return
}
tokenHandler("tok-approle-secret")(w, r)
func TestNewAppRoleSendsRoleIDOnly(t *testing.T) {
var body map[string]any
var path string
srv := newServer(t, func(w http.ResponseWriter, r *http.Request) {
path = r.URL.Path
_ = json.NewDecoder(r.Body).Decode(&body)
_, _ = fmt.Fprint(w, `{"auth":{"client_token":"s.token"}}`)
})
srv := newTLSServer(t, mux)
_, err := vault.New(config.VaultConfig{
Addr: srv.URL,
AuthMethod: config.AuthMethodAppRole,
ApprolePath: "approle",
RoleID: "role-id",
SecretID: "my-secret",
})
if err != nil {
t.Fatalf("AppRole+SecretID auth failed: %v", err)
if _, err := vault.New(baseConfig(srv.URL)); err != nil {
t.Fatalf("New: %v", err)
}
if path != "/v1/auth/approle/login" {
t.Errorf("login path = %q", path)
}
if len(body) != 1 || body["role_id"] != "role-id" {
t.Errorf("login body = %v, want only role_id", body)
}
}
func TestNew_AppRole_DefaultPath(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/v1/auth/approle/login", tokenHandler("tok"))
srv := newTLSServer(t, mux)
func TestNewKubernetesSendsRoleAndJWT(t *testing.T) {
jwt := filepath.Join(t.TempDir(), "token")
if err := os.WriteFile(jwt, []byte("the-jwt\n"), 0o600); err != nil {
t.Fatalf("write jwt: %v", err)
}
_, err := vault.New(config.VaultConfig{
Addr: srv.URL,
AuthMethod: config.AuthMethodAppRole,
RoleID: "role-id",
// ApprolePath intentionally omitted — should default to "approle"
var body map[string]any
var path string
srv := newServer(t, func(w http.ResponseWriter, r *http.Request) {
path = r.URL.Path
_ = json.NewDecoder(r.Body).Decode(&body)
_, _ = fmt.Fprint(w, `{"auth":{"client_token":"s.token"}}`)
})
if err != nil {
t.Fatalf("AppRole default path failed: %v", err)
cfg := baseConfig(srv.URL)
cfg.AuthMethod = config.AuthMethodKubernetes
cfg.K8sMount = "k8s/au/syd1"
cfg.K8sRole = "puppet_certmanager"
cfg.JWTPath = jwt
if _, err := vault.New(cfg); err != nil {
t.Fatalf("New: %v", err)
}
if path != "/v1/auth/k8s/au/syd1/login" {
t.Errorf("login path = %q", path)
}
if body["role"] != "puppet_certmanager" || body["jwt"] != "the-jwt" {
t.Errorf("login body = %v", body)
}
}
// ---------------------------------------------------------------------------
// LDAP
// ---------------------------------------------------------------------------
func TestNew_LDAP(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/v1/auth/ldap/login/testuser", func(w http.ResponseWriter, r *http.Request) {
var body map[string]string
json.NewDecoder(r.Body).Decode(&body)
if body["password"] != "secret" {
http.Error(w, "bad password", http.StatusForbidden)
return
}
tokenHandler("tok-ldap")(w, r)
})
srv := newTLSServer(t, mux)
_, err := vault.New(config.VaultConfig{
Addr: srv.URL,
AuthMethod: config.AuthMethodLDAP,
LDAPPath: "ldap",
LDAPUsername: "testuser",
LDAPPassword: "secret",
})
if err != nil {
t.Fatalf("LDAP auth failed: %v", err)
func TestNewAuthErrors(t *testing.T) {
deny := func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = fmt.Fprint(w, `{"errors":["permission denied"]}`)
}
}
func TestNew_LDAP_DefaultPath(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/v1/auth/ldap/login/u", tokenHandler("tok"))
srv := newTLSServer(t, mux)
_, err := vault.New(config.VaultConfig{
Addr: srv.URL,
AuthMethod: config.AuthMethodLDAP,
LDAPUsername: "u",
LDAPPassword: "p",
})
if err != nil {
t.Fatalf("LDAP default path failed: %v", err)
noToken := func(w http.ResponseWriter, _ *http.Request) {
_, _ = fmt.Fprint(w, `{"auth":{}}`)
}
}
// ---------------------------------------------------------------------------
// Kubernetes
// ---------------------------------------------------------------------------
func TestNew_Kubernetes(t *testing.T) {
tmp := t.TempDir()
tokenFile := filepath.Join(tmp, "token")
os.WriteFile(tokenFile, []byte("k8s-jwt"), 0o600)
mux := http.NewServeMux()
mux.HandleFunc("/v1/auth/kubernetes/login", func(w http.ResponseWriter, r *http.Request) {
var body map[string]string
json.NewDecoder(r.Body).Decode(&body)
if body["jwt"] != "k8s-jwt" || body["role"] != "puppet" {
http.Error(w, "bad jwt/role", http.StatusForbidden)
return
}
tokenHandler("tok-k8s")(w, r)
})
srv := newTLSServer(t, mux)
_, err := vault.New(config.VaultConfig{
Addr: srv.URL,
AuthMethod: config.AuthMethodKubernetes,
KubernetesPath: "kubernetes",
KubernetesRole: "puppet",
KubernetesTokenFile: tokenFile,
})
if err != nil {
t.Fatalf("Kubernetes auth failed: %v", err)
}
}
func TestNew_Kubernetes_MissingTokenFile(t *testing.T) {
_, err := vault.New(config.VaultConfig{
Addr: "https://vault.example.com",
AuthMethod: config.AuthMethodKubernetes,
KubernetesRole: "puppet",
KubernetesTokenFile: "/nonexistent/token",
})
if err == nil || !strings.Contains(err.Error(), "read service account token") {
t.Errorf("expected token file error, got %v", err)
}
}
// ---------------------------------------------------------------------------
// Token
// ---------------------------------------------------------------------------
func TestNew_Token(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/v1/pki_int/issue/role", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Vault-Token") != "static-token" {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"certificate": "C", "private_key": "K", "issuing_ca": "CA",
tests := []struct {
name string
handler http.HandlerFunc
mutate func(*testing.T, *config.VaultConfig)
wantErr string
}{
{
name: "approle rejected",
handler: deny,
wantErr: "approle login: POST auth/approle/login: vault returned 403",
},
{
name: "approle returns no token",
handler: noToken,
wantErr: "no auth.client_token",
},
{
name: "kubernetes rejected",
handler: deny,
mutate: func(t *testing.T, c *config.VaultConfig) {
jwt := filepath.Join(t.TempDir(), "token")
if err := os.WriteFile(jwt, []byte("jwt"), 0o600); err != nil {
t.Fatalf("write jwt: %v", err)
}
c.AuthMethod = config.AuthMethodKubernetes
c.K8sMount = "k8s/au/syd1"
c.K8sRole = "role"
c.JWTPath = jwt
},
})
})
srv := newTLSServer(t, mux)
wantErr: "kubernetes login: POST auth/k8s/au/syd1/login: vault returned 403",
},
{
name: "kubernetes jwt missing",
handler: noToken,
mutate: func(t *testing.T, c *config.VaultConfig) {
c.AuthMethod = config.AuthMethodKubernetes
c.K8sMount = "k8s/au/syd1"
c.K8sRole = "role"
c.JWTPath = filepath.Join(t.TempDir(), "absent")
},
wantErr: "read service account token",
},
{
name: "unknown auth method",
handler: noToken,
mutate: func(t *testing.T, c *config.VaultConfig) { c.AuthMethod = "ldap" },
wantErr: `unknown vault.auth_method "ldap"`,
},
{
name: "bad timeout",
handler: noToken,
mutate: func(t *testing.T, c *config.VaultConfig) { c.Timeout = "eventually" },
wantErr: "not a valid duration",
},
}
client, err := vault.New(config.VaultConfig{
Addr: srv.URL,
AuthMethod: config.AuthMethodToken,
Token: "static-token",
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
srv := newServer(t, tc.handler)
cfg := baseConfig(srv.URL)
if tc.mutate != nil {
tc.mutate(t, &cfg)
}
_, err := vault.New(cfg)
if err == nil {
t.Fatal("New succeeded, want error")
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Errorf("error = %v, want it to mention %q", err, tc.wantErr)
}
})
}
}
func TestNewKubernetesEmptyJWT(t *testing.T) {
jwt := filepath.Join(t.TempDir(), "token")
if err := os.WriteFile(jwt, []byte("\n\t "), 0o600); err != nil {
t.Fatalf("write jwt: %v", err)
}
srv := newServer(t, func(w http.ResponseWriter, _ *http.Request) {
t.Error("vault must not be contacted when the JWT is empty")
_, _ = fmt.Fprint(w, `{"auth":{"client_token":"s.token"}}`)
})
cfg := baseConfig(srv.URL)
cfg.AuthMethod = config.AuthMethodKubernetes
cfg.K8sMount = "k8s/au/syd1"
cfg.K8sRole = "role"
cfg.JWTPath = jwt
_, err := vault.New(cfg)
if err == nil || !strings.Contains(err.Error(), "is empty") {
t.Fatalf("error = %v, want an empty-JWT failure", err)
}
}
func TestPostSendsToken(t *testing.T) {
var token string
srv := newServer(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/login") {
_, _ = fmt.Fprint(w, `{"auth":{"client_token":"s.token"}}`)
return
}
token = r.Header.Get("X-Vault-Token")
_, _ = fmt.Fprint(w, `{"data":{"ok":true}}`)
})
c, err := vault.New(baseConfig(srv.URL))
if err != nil {
t.Fatalf("Token auth failed: %v", err)
t.Fatalf("New: %v", err)
}
var out map[string]any
if err := client.Post("pki_int/issue/role", map[string]string{"common_name": "x"}, &out); err != nil {
t.Fatalf("Post with token auth failed: %v", err)
var out struct {
Data struct {
OK bool `json:"ok"`
} `json:"data"`
}
if err := c.Post("pki_int/issue/servers_default", map[string]string{}, &out); err != nil {
t.Fatalf("Post: %v", err)
}
if token != "s.token" {
t.Errorf("X-Vault-Token = %q", token)
}
if !out.Data.OK {
t.Error("response not decoded")
}
}
func TestNew_Token_Empty(t *testing.T) {
_, err := vault.New(config.VaultConfig{
Addr: "https://vault.example.com",
AuthMethod: config.AuthMethodToken,
Token: "",
func TestPostTimesOut(t *testing.T) {
srv := newServer(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/login") {
_, _ = fmt.Fprint(w, `{"auth":{"client_token":"s.token"}}`)
return
}
time.Sleep(500 * time.Millisecond)
_, _ = fmt.Fprint(w, `{"data":{}}`)
})
cfg := baseConfig(srv.URL)
cfg.Timeout = "50ms"
c, err := vault.New(cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
start := time.Now()
err = c.Post("pki_int/issue/servers_default", map[string]string{}, &struct{}{})
if err == nil {
t.Error("expected error for empty token")
t.Fatal("Post succeeded, want a timeout")
}
if elapsed := time.Since(start); elapsed > 400*time.Millisecond {
t.Errorf("Post took %v, the timeout did not apply", elapsed)
}
}
// ---------------------------------------------------------------------------
// Unknown auth method
// ---------------------------------------------------------------------------
func TestNew_UnknownMethod(t *testing.T) {
_, err := vault.New(config.VaultConfig{
Addr: "https://vault.example.com",
AuthMethod: "magic",
func TestTLSVerificationCanBeEnabled(t *testing.T) {
srv := newServer(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = fmt.Fprint(w, `{"auth":{"client_token":"s.token"}}`)
})
if err == nil || !strings.Contains(err.Error(), "unknown auth_method") {
t.Errorf("expected unknown method error, got %v", err)
}
}
// ---------------------------------------------------------------------------
// Auth failure
// ---------------------------------------------------------------------------
func TestNew_AuthFailure(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "permission denied", http.StatusForbidden)
}))
t.Cleanup(srv.Close)
_, err := vault.New(config.VaultConfig{
Addr: srv.URL,
AuthMethod: config.AuthMethodAppRole,
ApprolePath: "approle",
RoleID: "role-id",
})
if err == nil {
t.Error("expected auth error, got nil")
}
}
func TestNew_EmptyToken(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"auth": map[string]any{"client_token": ""},
})
}))
t.Cleanup(srv.Close)
_, err := vault.New(config.VaultConfig{
Addr: srv.URL,
AuthMethod: config.AuthMethodAppRole,
ApprolePath: "approle",
RoleID: "role-id",
})
if err == nil || !strings.Contains(err.Error(), "empty client token") {
t.Errorf("expected empty token error, got %v", err)
cfg := baseConfig(srv.URL)
skip := false
cfg.TLSSkipVerify = &skip
if _, err := vault.New(cfg); err == nil {
t.Fatal("New succeeded against a self-signed server with verification enabled")
}
}
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
#
# Run the end-to-end integration test against a real Vault dev server.
#
# With CERTMANAGER_IT_VAULT_ADDR set (CI, where Woodpecker runs Vault as a
# service) the test is run directly. Otherwise a throwaway dev server is
# started with docker/podman and torn down afterwards.
#
# VAULT_ADDR is deliberately ignored: this test mounts PKI and writes policies,
# and a developer shell usually has VAULT_ADDR pointing at production.
#
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
VAULT_IMAGE="${VAULT_IMAGE:-hashicorp/vault:1.21}"
CONTAINER_NAME="${CONTAINER_NAME:-certmanager-integration-vault}"
run_tests() {
env -u VAULT_ADDR -u VAULT_TOKEN \
CERTMANAGER_IT_VAULT_ADDR="${CERTMANAGER_IT_VAULT_ADDR}" \
CERTMANAGER_IT_VAULT_TOKEN="${CERTMANAGER_IT_VAULT_TOKEN}" \
go test -tags=integration -count=1 -v ./test/integration/...
}
if [ -n "${CERTMANAGER_IT_VAULT_ADDR:-}" ]; then
echo "Using the Vault dev server at ${CERTMANAGER_IT_VAULT_ADDR}"
CERTMANAGER_IT_VAULT_TOKEN="${CERTMANAGER_IT_VAULT_TOKEN:-root}"
run_tests
exit 0
fi
RUNTIME=""
for candidate in docker podman; do
if command -v "${candidate}" >/dev/null 2>&1; then
RUNTIME="${candidate}"
break
fi
done
if [ -z "${RUNTIME}" ]; then
echo "ERROR: need docker or podman to start a Vault dev server, or set VAULT_ADDR" >&2
exit 1
fi
cleanup() {
"${RUNTIME}" rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true
}
trap cleanup EXIT
cleanup
echo "Starting ${VAULT_IMAGE} dev server via ${RUNTIME}"
"${RUNTIME}" run -d --name "${CONTAINER_NAME}" \
-p 8200:8200 \
-e VAULT_DEV_ROOT_TOKEN_ID=root \
-e VAULT_DEV_LISTEN_ADDRESS=0.0.0.0:8200 \
"${VAULT_IMAGE}" \
server -dev >/dev/null
# The test itself polls sys/health, so no sleep here.
CERTMANAGER_IT_VAULT_ADDR="http://127.0.0.1:8200"
CERTMANAGER_IT_VAULT_TOKEN="root"
run_tests
+355
View File
@@ -0,0 +1,355 @@
//go:build integration
// Package integration exercises the certmanager binary end to end against a
// real Vault dev server: real PKI mount, real approle login, real issuance.
package integration
import (
"bytes"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
"time"
)
const (
pkiMount = "pki"
pkiRole = "puppet"
approlePath = "approle"
approleRole = "certmanager"
policyName = "certmanager-integration"
certDomain = "integration.test"
shortName = "certmanager-host"
commonName = "host." + certDomain
altName = "alias." + certDomain
ipSAN1 = "127.0.0.1"
ipSAN2 = "10.0.0.5"
expiryDays = 90
)
// The address and token come from CERTMANAGER_IT_* rather than VAULT_ADDR /
// VAULT_TOKEN on purpose: this test mounts and writes to PKI, and a developer
// shell nearly always has VAULT_ADDR pointing at production.
func vaultAddr() string {
if a := os.Getenv("CERTMANAGER_IT_VAULT_ADDR"); a != "" {
return strings.TrimRight(a, "/")
}
return "http://127.0.0.1:8200"
}
func vaultToken() string {
if t := os.Getenv("CERTMANAGER_IT_VAULT_TOKEN"); t != "" {
return t
}
return "root"
}
// waitForVault polls sys/health until the server is initialised and unsealed.
func waitForVault(t *testing.T) {
t.Helper()
deadline := time.Now().Add(90 * time.Second)
url := vaultAddr() + "/v1/sys/health?standbyok=true"
var last string
for time.Now().Before(deadline) {
resp, err := http.Get(url) //nolint:gosec,noctx // fixed test-local URL
if err != nil {
last = err.Error()
} else {
code := resp.StatusCode
_ = resp.Body.Close()
if code == http.StatusOK {
return
}
last = fmt.Sprintf("HTTP %d", code)
}
time.Sleep(250 * time.Millisecond)
}
t.Fatalf("vault at %s not ready: %s", vaultAddr(), last)
}
// vaultReq performs an authenticated request against the dev server and
// decodes the JSON body. A non-2xx response fails the test unless the error
// body matches one of okErrors (used to make mount/auth enabling idempotent).
func vaultReq(t *testing.T, method, path string, payload any, okErrors ...string) map[string]any {
t.Helper()
var body io.Reader
if payload != nil {
raw, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload for %s: %v", path, err)
}
body = bytes.NewReader(raw)
}
req, err := http.NewRequest(method, vaultAddr()+"/v1/"+path, body) //nolint:noctx // test helper
if err != nil {
t.Fatalf("build request %s %s: %v", method, path, err)
}
req.Header.Set("X-Vault-Token", vaultToken())
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
for _, ok := range okErrors {
if strings.Contains(string(raw), ok) {
return nil
}
}
t.Fatalf("%s %s: vault returned %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(raw)))
}
var out map[string]any
if len(bytes.TrimSpace(raw)) > 0 {
_ = json.Unmarshal(raw, &out)
}
return out
}
// setupVault provisions the PKI mount, issuing role, policy and approle role,
// returning the role_id certmanager will authenticate with.
func setupVault(t *testing.T) string {
t.Helper()
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",
"ttl": "87600h",
})
vaultReq(t, http.MethodPost, pkiMount+"/roles/"+pkiRole, map[string]any{
"allowed_domains": []string{certDomain, shortName},
"allow_subdomains": true,
"allow_bare_domains": true,
"allow_ip_sans": true,
"key_type": "rsa",
"key_bits": 2048,
"max_ttl": "8760h",
})
vaultReq(t, http.MethodPut, "sys/policies/acl/"+policyName, map[string]any{
"policy": fmt.Sprintf(`path "%s/issue/%s" { capabilities = ["update"] }`, pkiMount, pkiRole),
})
vaultReq(t, http.MethodPost, "sys/auth/"+approlePath,
map[string]any{"type": "approle"}, "path is already in use")
// Mirrors the production role: bind_secret_id false (role_id alone logs in)
// with token_bound_cidrs as the required constraint.
vaultReq(t, http.MethodPost, fmt.Sprintf("auth/%s/role/%s", approlePath, approleRole), map[string]any{
"bind_secret_id": false,
"token_bound_cidrs": []string{"0.0.0.0/0"},
"token_policies": []string{policyName},
"token_ttl": "10m",
})
out := vaultReq(t, http.MethodGet, fmt.Sprintf("auth/%s/role/%s/role-id", approlePath, approleRole), nil)
data, _ := out["data"].(map[string]any)
roleID, _ := data["role_id"].(string)
if roleID == "" {
t.Fatal("vault returned an empty approle role_id")
}
return roleID
}
// 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 {
t.Helper()
bin := filepath.Join(dir, "certmanager")
ldflags := fmt.Sprintf("-s -w -X main.version=integration -X git.unkin.net/unkin/certmanager/internal/config.defaultPath=%s", configPath)
cmd := exec.Command("go", "build", "-trimpath", "-ldflags="+ldflags, "-o", bin, "./cmd/certmanager")
cmd.Dir = repoRoot(t)
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("build certmanager: %v\n%s", err, out)
}
return bin
}
func repoRoot(t *testing.T) string {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
return filepath.Join(wd, "..", "..")
}
func writeConfig(t *testing.T, dir, roleID string) string {
t.Helper()
path := filepath.Join(dir, "config.yaml")
body := fmt.Sprintf(`vault:
addr: %s
auth_method: approle
approle_path: %s
role_id: %s
mount_point: %s
role_name: %s
output_path: %s
`, vaultAddr(), approlePath, roleID, pkiMount, pkiRole, dir)
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
return path
}
func runCertmanager(t *testing.T, bin string, args ...string) (int, string, string) {
t.Helper()
cmd := exec.Command(bin, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
code := 0
if err := cmd.Run(); err != nil {
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("run %s: %v", bin, err)
}
code = exitErr.ExitCode()
}
return code, stdout.String(), stderr.String()
}
func TestIssueCertificateAgainstVault(t *testing.T) {
waitForVault(t)
roleID := setupVault(t)
dir := t.TempDir()
cfg := writeConfig(t, dir, roleID)
bin := buildBinary(t, dir, cfg)
// Exactly the argv profiles::pki::vault passes to generate().
code, stdout, stderr := runCertmanager(t, bin,
commonName,
"--alt-names", shortName+","+altName,
"--ip-sans", ipSAN1+","+ipSAN2,
"--expiry-days", fmt.Sprint(expiryDays),
"--json",
)
if code != 0 {
t.Fatalf("exit code %d, stderr: %s", code, stderr)
}
// generate() runs with combine: true, splicing stderr into the stdout that
// parsejson() reads -- a single byte on stderr breaks the catalog compile.
if stderr != "" {
t.Errorf("stderr must be empty on success, got %q", 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)
}
for _, key := range []string{"certificate", "private_key", "full_chain", "ca_certificate"} {
if doc[key] == "" {
t.Errorf("JSON key %q is missing or empty", key)
}
}
cert := parseCert(t, doc["certificate"])
if cert.Subject.CommonName != commonName {
t.Errorf("certificate CN = %q, want %q", cert.Subject.CommonName, commonName)
}
for _, want := range []string{shortName, altName} {
if !slices.Contains(cert.DNSNames, want) {
t.Errorf("certificate DNS SANs %v missing %q", cert.DNSNames, want)
}
}
for _, want := range []string{ipSAN1, ipSAN2} {
if !containsIP(cert.IPAddresses, want) {
t.Errorf("certificate IP SANs %v missing %q", cert.IPAddresses, want)
}
}
// Proves the "<days>d" TTL string is understood by a real Vault.
lifetime := cert.NotAfter.Sub(cert.NotBefore)
if lifetime < 89*24*time.Hour || lifetime > 91*24*time.Hour {
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")
}
if err := cert.CheckSignatureFrom(ca); err != nil {
t.Errorf("certificate is not signed by ca_certificate: %v", err)
}
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")
}
}
func TestRefusedNameFailsCleanly(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,
"nope.example.com",
"--alt-names", "",
"--ip-sans", "",
"--expiry-days", fmt.Sprint(expiryDays),
"--json",
)
if code == 0 {
t.Fatalf("expected a non-zero exit for a refused name, stdout: %s", stdout)
}
// A half-written JSON document would break parsejson() in the catalog.
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 parseCert(t *testing.T, data string) *x509.Certificate {
t.Helper()
block, _ := pem.Decode([]byte(data))
if block == nil {
t.Fatalf("not a PEM document: %s", data)
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("parse certificate: %v", err)
}
return cert
}
func containsIP(haystack []net.IP, needle string) bool {
want := net.ParseIP(needle)
for _, v := range haystack {
if v.Equal(want) {
return true
}
}
return false
}
+5
View File
@@ -0,0 +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.
package integration