diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..50b7ecf --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index a57e4f7..79606e3 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -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--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 + 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: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3f99cbe..7b98d81 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml new file mode 100644 index 0000000..74123b5 --- /dev/null +++ b/.woodpecker/build.yaml @@ -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 diff --git a/.woodpecker/integration.yaml b/.woodpecker/integration.yaml new file mode 100644 index 0000000..c63b40b --- /dev/null +++ b/.woodpecker/integration.yaml @@ -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 diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..d57b508 --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -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 diff --git a/.woodpecker/release.yaml b/.woodpecker/release.yaml new file mode 100644 index 0000000..8667e0d --- /dev/null +++ b/.woodpecker/release.yaml @@ -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 diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml new file mode 100644 index 0000000..5e179a7 --- /dev/null +++ b/.woodpecker/test.yaml @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b2da9d2 --- /dev/null +++ b/AGENTS.md @@ -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 --alt-names "host,fqdn" --ip-sans "127.0.0.1," --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. diff --git a/Makefile b/Makefile index 25cbaed..cc787bc 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/README.md b/README.md index bbe92e1..846267a 100644 --- a/README.md +++ b/README.md @@ -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 --alt-names "host,fqdn" --ip-sans "127.0.0.1," --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 [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: 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": "", + "private_key": "", + "full_chain": "\n", + "ca_certificate": "" +} +``` + +`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 | +|---|---| +| `//certificate.crt` | Signed certificate | +| `//private.key` | Private key (mode 0600) | +| `//full_chain.crt` | Issuing CA + certificate | +| `//ca_certificate.crt` | Issuing CA certificate | + +With `-c` the same four files are written into +`/.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: - 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// -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 `//`): - -| 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--` (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--1..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. diff --git a/cmd/certmanager/main.go b/cmd/certmanager/main.go index 85ce657..434fa6f 100644 --- a/cmd/certmanager/main.go +++ b/cmd/certmanager/main.go @@ -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 ", - Short: "Issue a PKI certificate from Vault", - Args: cobra.ExactArgs(1), + Use: "certmanager ", + 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 -} diff --git a/cmd/certmanager/main_test.go b/cmd/certmanager/main_test.go new file mode 100644 index 0000000..858fc6e --- /dev/null +++ b/cmd/certmanager/main_test.go @@ -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") + } + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 0f00610..905a648 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 01ea296..490f8df 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) } } diff --git a/internal/pki/pki.go b/internal/pki/pki.go index a1fa6f3..2e09543 100644 --- a/internal/pki/pki.go +++ b/internal/pki/pki.go @@ -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 } diff --git a/internal/pki/pki_test.go b/internal/pki/pki_test.go index 80cb538..e6da5a0 100644 --- a/internal/pki/pki_test.go +++ b/internal/pki/pki_test.go @@ -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) } } diff --git a/internal/ssh/ssh.go b/internal/ssh/ssh.go deleted file mode 100644 index 54e2e2f..0000000 --- a/internal/ssh/ssh.go +++ /dev/null @@ -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 -} diff --git a/internal/ssh/ssh_test.go b/internal/ssh/ssh_test.go deleted file mode 100644 index 23fedd2..0000000 --- a/internal/ssh/ssh_test.go +++ /dev/null @@ -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") - } -} diff --git a/internal/vault/client.go b/internal/vault/client.go index 3ccd760..250d181 100644 --- a/internal/vault/client.go +++ b/internal/vault/client.go @@ -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 } diff --git a/internal/vault/client_test.go b/internal/vault/client_test.go index 89df15b..42c9e0a 100644 --- a/internal/vault/client_test.go +++ b/internal/vault/client_test.go @@ -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") } } diff --git a/scripts/integration-test.sh b/scripts/integration-test.sh new file mode 100755 index 0000000..d5684ee --- /dev/null +++ b/scripts/integration-test.sh @@ -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 diff --git a/test/integration/certmanager_test.go b/test/integration/certmanager_test.go new file mode 100644 index 0000000..7a1fdc0 --- /dev/null +++ b/test/integration/certmanager_test.go @@ -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 "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 +} diff --git a/test/integration/doc.go b/test/integration/doc.go new file mode 100644 index 0000000..dde368b --- /dev/null +++ b/test/integration/doc.go @@ -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