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