From 67426b9e6ef4fe1b25b23efa81625f49eb89ce19 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 19 Sep 2026 10:35:00 +1000 Subject: [PATCH] Add Go implementation of sshsignhost Puppet's generate() splices stderr into stdout, so the Python helper's error prints and its absent HTTP timeouts can corrupt a catalog compile or hang it forever. - sign SSH host certificates via Vault approle or kubernetes auth - emit only {"signed_key": ...} on stdout, errors on stderr, non-zero exit - forward valid_principals to Vault unsplit; bound every call by a timeout - keep TLS verification skippable via tls_skip_verify (default true) - package the binary and completions as an RPM; add PR and tag pipelines --- .gitignore | 3 + .pre-commit-config.yaml | 17 ++ .woodpecker/build.yaml | 18 ++ .woodpecker/pre-commit.yaml | 18 ++ .woodpecker/release.yaml | 136 ++++++++++ .woodpecker/test.yaml | 33 +++ AGENTS.md | 57 ++++ Makefile | 64 +++++ README.md | 67 ++++- config.go | 123 +++++++++ go.mod | 13 + go.sum | 13 + main.go | 99 +++++++ main_test.go | 499 ++++++++++++++++++++++++++++++++++++ packaging/nfpm.yaml | 40 +++ scripts/build-rpm.sh | 42 +++ vault.go | 184 +++++++++++++ 17 files changed, 1425 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .woodpecker/build.yaml create mode 100644 .woodpecker/pre-commit.yaml create mode 100644 .woodpecker/release.yaml create mode 100644 .woodpecker/test.yaml create mode 100644 AGENTS.md create mode 100644 Makefile create mode 100644 config.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 main_test.go create mode 100644 packaging/nfpm.yaml create mode 100755 scripts/build-rpm.sh create mode 100644 vault.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d4200b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/sshsignhost +/sshsignhost-* +dist/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2e63b82 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-merge-conflict + - id: mixed-line-ending + args: [--fix=lf] + + - repo: https://github.com/dnephin/pre-commit-golang + rev: v0.5.1 + hooks: + - id: go-fmt + - id: go-vet + - id: go-unit-tests diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml new file mode 100644 index 0000000..74123b5 --- /dev/null +++ b/.woodpecker/build.yaml @@ -0,0 +1,18 @@ +when: + - event: pull_request + +steps: + - name: build + image: golang:1.25 + commands: + - make build + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..d57b508 --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -0,0 +1,18 @@ +when: + - event: pull_request + +steps: + - name: pre-commit + image: git.unkin.net/unkin/almalinux9-gobuilder:20260606 + commands: + - uvx pre-commit run --all-files + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/release.yaml b/.woodpecker/release.yaml new file mode 100644 index 0000000..48251f1 --- /dev/null +++ b/.woodpecker/release.yaml @@ -0,0 +1,136 @@ +when: + - event: tag + +steps: + - name: test + image: golang:1.25 + commands: + - go test -race ./... + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + + - name: build + image: git.unkin.net/unkin/almalinux9-gobuilder:20260606 + commands: + - make build VERSION=${CI_COMMIT_TAG} + # $$ escapes shell vars so Woodpecker leaves them for the shell; + # ${CI_COMMIT_TAG} is a real Woodpecker var and stays single-$. + - | + for osarch in linux/amd64 linux/arm64; do + os="$${osarch%/*}"; arch="$${osarch#*/}" + GOOS="$$os" GOARCH="$$arch" \ + go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" \ + -o "sshsignhost-$${os}-$${arch}" . + done + depends_on: [test] + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + + - name: package + image: git.unkin.net/unkin/almalinux9-rpmbuilder:latest + commands: + - ./scripts/build-rpm.sh ${CI_COMMIT_TAG} + depends_on: [build] + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + + # Publish the RPM to the artifactapi local rpm repo (a real yum repo; + # repodata regenerates automatically). + - name: upload-rpm + image: git.unkin.net/unkin/almalinux9-base:20260606 + commands: + - | + HOST="https://artifactapi.k8s.syd1.au.unkin.net" + REPO="rpm-internal" + for rpm in dist/*.rpm; do + FILE=$$(basename "$$rpm") + # artifactapi has no HEAD route (405); probe with GET against the + # served path (RPMs are stored under Packages/) to avoid re-upload. + code=$$(curl -s -o /dev/null -w '%{http_code}' "$$HOST/api/v2/remotes/$$REPO/files/Packages/$$FILE" || true) + if [ "$$code" = "200" ]; then + echo "$$FILE already exists in $$REPO (HTTP $$code); skipping upload" + continue + fi + echo "Uploading $$FILE to $$REPO (existence probe returned $$code)" + curl -f -X PUT \ + "$$HOST/api/v2/remotes/$$REPO/files/$$FILE" \ + -H "Content-Type: application/x-rpm" \ + --data-binary @"$$rpm" + done + depends_on: [package] + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 128Mi + cpu: 100m + limits: + memory: 512Mi + cpu: 500m + + - name: release + image: git.unkin.net/unkin/almalinux9-base:20260606 + environment: + RELEASER_TOKEN: + from_secret: RELEASER_TOKEN + commands: + - | + curl --output /usr/local/bin/tea https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/gitea-dl/tea/0.12.0/tea-0.12.0-linux-amd64 && chmod +x /usr/local/bin/tea + tea logins add --name gitea --url https://git.unkin.net --token "$${RELEASER_TOKEN}" --no-version-check + CUR_SHA=$$(git rev-list -n1 "${CI_COMMIT_TAG}") + PREV_TAG="" + for t in $$(git tag --sort=-v:refname); do + [ "$$t" = "${CI_COMMIT_TAG}" ] && continue + [ "$$(git rev-list -n1 "$$t")" = "$$CUR_SHA" ] && continue + if git merge-base --is-ancestor "$$t" "${CI_COMMIT_TAG}" 2>/dev/null; then + PREV_TAG="$$t"; break + fi + done + if [ -n "$$PREV_TAG" ]; then + NOTES=$$(git log "$${PREV_TAG}..${CI_COMMIT_TAG}" --pretty=format:"- %s") + else + NOTES=$$(git log --pretty=format:"- %s") + fi + tea releases create --tag "${CI_COMMIT_TAG}" --title "${CI_COMMIT_TAG}" --note "$${NOTES}" --login gitea --repo "${CI_REPO}" + RPM=$$(ls dist/*.rpm 2>/dev/null | head -1) + ASSETS="sshsignhost-linux-amd64 sshsignhost-linux-arm64" + [ -n "$$RPM" ] && ASSETS="$$ASSETS $$RPM" + sha256sum $$ASSETS > sha256sums.txt + tea releases assets create "${CI_COMMIT_TAG}" $$ASSETS sha256sums.txt \ + --login gitea --repo "${CI_REPO}" + depends_on: [upload-rpm] + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 128Mi + cpu: 100m + limits: + memory: 512Mi + cpu: 500m diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml new file mode 100644 index 0000000..5e179a7 --- /dev/null +++ b/.woodpecker/test.yaml @@ -0,0 +1,33 @@ +when: + - event: pull_request + +steps: + - name: lint + image: golangci/golangci-lint:latest + commands: + - golangci-lint run ./... + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + + - name: test + image: golang:1.25 + commands: + - go test -v -race ./... + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2a6cbc2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,57 @@ +# AGENTS.md + +## Project Overview + +`sshsignhost` signs SSH host certificates with Vault. It is a drop-in +replacement for the Python script Puppet used to render at +`/opt/sshsignhost/sshsignhost`, and is invoked by +`profiles::ssh::sign` (puppet-prod) through `generate()` at +`/usr/local/bin/sshsignhost`. + +## Structure + +``` +main.go # cobra command, flag surface, stdout rendering +config.go # YAML config loading, defaults, validation +vault.go # Vault HTTP client: approle/kubernetes login + sign +main_test.go # end-to-end tests against an httptest TLS stand-in for Vault +Makefile # build / test / lint / completions / rpm / version bump +packaging/ # nfpm spec for the RPM +scripts/ # build-rpm.sh (completions + nfpm) +.woodpecker/ # build, test, pre-commit (PR) + release (tag) +``` + +## Contract with Puppet + +- Flags use underscores (`--public_key`, `--valid_principals`), matching the + argv the manifest builds. +- `--valid_principals` is forwarded to Vault as the comma-separated string it + was given; it is never split and rejoined. +- `generate()` uses `failonfail: true, combine: true`: stderr is duped onto + stdout at spawn. On success nothing may reach stderr, or `parsejson()` fails + and the catalog compile breaks. `TestSuccessWritesNothingToStderr` runs the + real binary in a subprocess to hold that line. +- A 200 response without `data.signed_key` is a failure, never an empty result. + +## Vault + +- AppRole: `POST {addr}/v1/auth/{approle_path}/login` with `role_id` only; the + Vault role sets `bind_secret_id: false`. +- Kubernetes: `POST {addr}/v1/auth/{k8s_mount}/login` with `role` and the JWT + read from `jwt_path`. There is no fallback between methods -- a rejected + login or an unknown `auth_method` fails with the cause named. +- Sign: `POST {addr}/v1/{mount_point}/sign/{role_name}` with `X-Vault-Token` + and `cert_type: host`. + +## Known blocker + +The Vault ACL policy grants `sshca/sign/host`, but the only role that exists is +`signhost`, so production signs 403 on `sshca/sign/signhost` even though auth +succeeds. That is a terraform-vault fix, not a bug here -- do not change the +requested path. + +## TLS + +`tls_skip_verify` defaults to `true`, preserving the Python helper's +`verify=False` behaviour, and is a config key so it can be flipped without a +code change once certificates are trusted everywhere. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5677fa8 --- /dev/null +++ b/Makefile @@ -0,0 +1,64 @@ +BINARY := sshsignhost +DIST := dist +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)" +OS ?= $(shell go env GOOS) +ARCH ?= $(shell go env GOARCH) + +.PHONY: all build test lint fmt pre-commit clean install completions rpm rpm-package patch minor major _tag + +all: build + +build: + CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build $(GOFLAGS) -o $(DIST)/$(BINARY) . + +test: + go test -v -race ./... + +lint: + golangci-lint run ./... + +fmt: + gofmt -w . + +pre-commit: + pre-commit run --all-files + +clean: + rm -rf $(DIST) $(BINARY) + +install: + go install $(GOFLAGS) . + +completions: build + @mkdir -p $(DIST)/completions + $(DIST)/$(BINARY) completion bash > $(DIST)/completions/$(BINARY).bash + $(DIST)/$(BINARY) completion zsh > $(DIST)/completions/_$(BINARY) + $(DIST)/$(BINARY) completion fish > $(DIST)/completions/$(BINARY).fish + +rpm: build rpm-package + +rpm-package: + ./scripts/build-rpm.sh $(VERSION) + +# Bump helpers -- read the latest semver tag and create the next one. +_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1) +_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0) +_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1) +_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2) +_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3) + +patch: + @NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \ + git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW + +minor: + @NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \ + git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW + +major: + @NEW=v$(shell expr $(_MAJ) + 1).0.0; \ + git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW + +_tag: + git push origin $(TAG) diff --git a/README.md b/README.md index 84d79d3..ce4b41b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,68 @@ # sshsignhost -Vault-backed SSH host certificate signing helper for Puppet \ No newline at end of file +Vault-backed SSH host certificate signing helper for Puppet. + +Puppet's `profiles::ssh::sign` class calls the binary server-side during catalog +compilation via `generate()`, then feeds its stdout to `parsejson()`: + +``` +/usr/local/bin/sshsignhost --valid_principals "host,fqdn,ip" --ttl 87600h \ + --public_key "ssh-rsa AAAA..." --json +``` + +`generate()` runs with `combine: true`, so stderr is spliced into stdout. +On success the process writes only the JSON document to stdout and nothing at +all to stderr; any failure prints a message naming the cause to stderr and +exits non-zero, which aborts the compile cleanly instead of yielding a parse +error. + +## Usage + +| Flag | Default | Required | +|---|---|---| +| `--public_key` | — | yes | +| `--valid_principals` (comma-separated, forwarded to Vault verbatim) | — | yes | +| `--ttl` | `87600h` | no | +| `--json` | `false` | no | +| `--config` | `/opt/sshsignhost/config.yaml` | no | + +With `--json`, stdout is exactly: + +```json +{"signed_key": "ssh-rsa-cert-v01@openssh.com AAAA..."} +``` + +Without it, the raw `signed_key` value plus a newline. + +## Configuration + +```yaml +vault: + addr: 'https://vault.service.consul:8200' + auth_method: 'approle' # or 'kubernetes' + mount_point: 'sshca' + role_name: 'signhost' + # auth_method: approle + role_id: '...' + approle_path: 'approle' + # auth_method: kubernetes + k8s_mount: 'k8s/au/syd1' + k8s_role: 'puppet_sshsigner' + jwt_path: '/var/run/secrets/kubernetes.io/serviceaccount/token' + # optional + tls_skip_verify: true # default true + timeout: '15s' # default 15s +``` + +`tls_skip_verify` defaults to `true` to match the Python helper this replaces +(`verify=False`); flip it to `false` once Vault's listeners present trusted +certificates. `timeout` bounds every Vault call so a black-holed Vault cannot +hang a catalog compile forever. + +## Build + +```bash +make build # -> dist/sshsignhost +make test +make rpm # RPM with bash/zsh/fish completions +``` diff --git a/config.go b/config.go new file mode 100644 index 0000000..44c506b --- /dev/null +++ b/config.go @@ -0,0 +1,123 @@ +package main + +import ( + "fmt" + "os" + "time" + + "gopkg.in/yaml.v3" +) + +const ( + defaultConfigPath = "/opt/sshsignhost/config.yaml" + defaultTTL = "87600h" + defaultJWTPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" // #nosec G101 + defaultTimeout = 15 * time.Second + + authAppRole = "approle" + authKubernetes = "kubernetes" +) + +type configFile struct { + Vault vaultConfig `yaml:"vault"` +} + +type vaultConfig struct { + Addr string `yaml:"addr"` + AuthMethod string `yaml:"auth_method"` + MountPoint string `yaml:"mount_point"` + RoleName string `yaml:"role_name"` + RoleID string `yaml:"role_id"` + AppRolePath string `yaml:"approle_path"` + K8sMount string `yaml:"k8s_mount"` + K8sRole string `yaml:"k8s_role"` + JWTPath string `yaml:"jwt_path"` + TLSSkipVerify *bool `yaml:"tls_skip_verify"` + Timeout string `yaml:"timeout"` +} + +func loadConfig(path string) (vaultConfig, error) { + raw, err := os.ReadFile(path) // #nosec G304 + if err != nil { + return vaultConfig{}, fmt.Errorf("reading config %q: %w", path, err) + } + + var parsed configFile + if err := yaml.Unmarshal(raw, &parsed); err != nil { + return vaultConfig{}, fmt.Errorf("parsing config %q: %w", path, err) + } + + cfg := parsed.Vault + if cfg.AuthMethod == "" { + cfg.AuthMethod = authAppRole + } + if cfg.JWTPath == "" { + cfg.JWTPath = defaultJWTPath + } + + if err := cfg.validate(); err != nil { + return vaultConfig{}, fmt.Errorf("config %q: %w", path, err) + } + return cfg, nil +} + +func (c vaultConfig) validate() error { + required := []struct { + key string + value string + }{ + {"addr", c.Addr}, + {"mount_point", c.MountPoint}, + {"role_name", c.RoleName}, + } + for _, field := range required { + if field.value == "" { + return fmt.Errorf("vault.%s is required", field.key) + } + } + + switch c.AuthMethod { + case authAppRole: + if c.RoleID == "" { + return fmt.Errorf("vault.role_id is required for auth_method %q", authAppRole) + } + if c.AppRolePath == "" { + return fmt.Errorf("vault.approle_path is required for auth_method %q", authAppRole) + } + case authKubernetes: + if c.K8sMount == "" { + return fmt.Errorf("vault.k8s_mount is required for auth_method %q", authKubernetes) + } + if c.K8sRole == "" { + return fmt.Errorf("vault.k8s_role is required for auth_method %q", authKubernetes) + } + default: + return fmt.Errorf("unsupported auth_method %q: expected %q or %q", c.AuthMethod, authAppRole, authKubernetes) + } + + if _, err := c.requestTimeout(); err != nil { + return err + } + return nil +} + +// TLS verification stays off by default because the estate's Vault listeners +// are not yet covered by trusted certificates; set tls_skip_verify: false once +// they are. +func (c vaultConfig) skipTLSVerify() bool { + return c.TLSSkipVerify == nil || *c.TLSSkipVerify +} + +func (c vaultConfig) requestTimeout() (time.Duration, error) { + if c.Timeout == "" { + return defaultTimeout, nil + } + timeout, err := time.ParseDuration(c.Timeout) + if err != nil { + return 0, fmt.Errorf("vault.timeout %q is not a duration: %w", c.Timeout, err) + } + if timeout <= 0 { + return 0, fmt.Errorf("vault.timeout %q must be positive", c.Timeout) + } + return timeout, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ceee82d --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module git.unkin.net/unkin/sshsignhost + +go 1.25.7 + +require ( + github.com/spf13/cobra v1.10.2 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..47edb24 --- /dev/null +++ b/go.sum @@ -0,0 +1,13 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..391b24d --- /dev/null +++ b/main.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + + "github.com/spf13/cobra" +) + +var version = "dev" + +type options struct { + configPath string + publicKey string + validPrincipals string + ttl string + jsonOutput bool +} + +type signedKeyOutput struct { + SignedKey string `json:"signed_key"` +} + +func newRootCommand(stdout io.Writer) *cobra.Command { + opts := &options{} + + cmd := &cobra.Command{ + Use: "sshsignhost", + Short: "Sign an SSH host certificate with Vault", + Version: version, + // Puppet's generate() splices stderr into stdout, so usage and error + // text must never be emitted by cobra itself. + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, _ []string) error { + return run(cmd.Context(), stdout, opts) + }, + } + + flags := cmd.Flags() + flags.StringVar(&opts.configPath, "config", defaultConfigPath, "path to the YAML config file") + flags.StringVar(&opts.publicKey, "public_key", "", "SSH public key as a string") + flags.StringVar(&opts.validPrincipals, "valid_principals", "", "comma-separated list of valid principals") + flags.StringVar(&opts.ttl, "ttl", defaultTTL, "certificate time-to-live") + flags.BoolVar(&opts.jsonOutput, "json", false, `emit {"signed_key": "..."} on stdout`) + + if err := cmd.MarkFlagRequired("public_key"); err != nil { + panic(err) + } + if err := cmd.MarkFlagRequired("valid_principals"); err != nil { + panic(err) + } + + return cmd +} + +func run(ctx context.Context, stdout io.Writer, opts *options) error { + cfg, err := loadConfig(opts.configPath) + if err != nil { + return err + } + + client, err := newVaultClient(cfg) + if err != nil { + return err + } + + token, err := client.authenticate(ctx) + if err != nil { + return err + } + + signedKey, err := client.signHostKey(ctx, token, opts.publicKey, opts.validPrincipals, opts.ttl) + if err != nil { + return err + } + + if !opts.jsonOutput { + _, err = fmt.Fprintln(stdout, signedKey) + return err + } + + encoded, err := json.Marshal(signedKeyOutput{SignedKey: signedKey}) + if err != nil { + return fmt.Errorf("encoding signed key as JSON: %w", err) + } + _, err = fmt.Fprintln(stdout, string(encoded)) + return err +} + +func main() { + if err := newRootCommand(os.Stdout).Execute(); err != nil { + fmt.Fprintf(os.Stderr, "sshsignhost: %v\n", err) + os.Exit(1) + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..d8a4ddf --- /dev/null +++ b/main_test.go @@ -0,0 +1,499 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +const ( + testSignedKey = "ssh-rsa-cert-v01@openssh.com AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20" + testPublicKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDexample" + testToken = "hvs.CAESIFAKE" + subprocessEnv = "SSHSIGNHOST_TEST_SUBPROCESS" +) + +func TestMain(m *testing.M) { + if os.Getenv(subprocessEnv) == "1" { + main() + return + } + os.Exit(m.Run()) +} + +type capturedRequest struct { + path string + token string + body map[string]string + called bool +} + +type fakeVault struct { + t *testing.T + server *httptest.Server + authStatus int + signStatus int + signBody string + delay time.Duration + login capturedRequest + sign capturedRequest +} + +func newFakeVault(t *testing.T) *fakeVault { + t.Helper() + + vault := &fakeVault{ + t: t, + authStatus: http.StatusOK, + signStatus: http.StatusOK, + signBody: fmt.Sprintf(`{"data":{"signed_key":%q}}`, testSignedKey), + } + vault.server = httptest.NewTLSServer(http.HandlerFunc(vault.handle)) + t.Cleanup(vault.server.Close) + return vault +} + +func (f *fakeVault) handle(w http.ResponseWriter, r *http.Request) { + if f.delay > 0 { + time.Sleep(f.delay) + } + + raw, err := io.ReadAll(r.Body) + if err != nil { + f.t.Errorf("reading request body: %v", err) + return + } + body := map[string]string{} + if err := json.Unmarshal(raw, &body); err != nil { + f.t.Errorf("decoding request body %q: %v", raw, err) + return + } + + captured := capturedRequest{path: r.URL.Path, token: r.Header.Get("X-Vault-Token"), body: body, called: true} + + switch { + case strings.HasSuffix(r.URL.Path, "/login"): + f.login = captured + if f.authStatus != http.StatusOK { + w.WriteHeader(f.authStatus) + _, _ = fmt.Fprint(w, `{"errors":["permission denied"]}`) + return + } + _, _ = fmt.Fprintf(w, `{"auth":{"client_token":%q}}`, testToken) + default: + f.sign = captured + if f.signStatus != http.StatusOK { + w.WriteHeader(f.signStatus) + _, _ = fmt.Fprint(w, `{"errors":["1 error occurred:\n\t* permission denied\n\n"]}`) + return + } + _, _ = fmt.Fprint(w, f.signBody) + } +} + +func writeConfig(t *testing.T, body string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("writing config: %v", err) + } + return path +} + +func approleConfig(t *testing.T, addr string) string { + t.Helper() + + return writeConfig(t, fmt.Sprintf(`vault: + addr: '%s' + role_id: 'role-id-value' + approle_path: 'approle' + mount_point: 'sshca' + role_name: 'signhost' +output_path: '/etc/ssh/ssh_host_rsa_key-cert.pem' +`, addr)) +} + +func kubernetesConfig(t *testing.T, addr, jwtPath string) string { + t.Helper() + + return writeConfig(t, fmt.Sprintf(`vault: + addr: '%s' + auth_method: 'kubernetes' + k8s_mount: 'k8s/au/syd1' + k8s_role: 'puppet_sshsigner' + jwt_path: '%s' + mount_point: 'sshca' + role_name: 'signhost' +`, addr, jwtPath)) +} + +func writeJWT(t *testing.T, contents string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("writing jwt: %v", err) + } + return path +} + +func execute(t *testing.T, args ...string) (string, error) { + t.Helper() + + stdout := &bytes.Buffer{} + cmd := newRootCommand(stdout) + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + err := cmd.ExecuteContext(context.Background()) + return stdout.String(), err +} + +func TestSignJSONOutputIsExact(t *testing.T) { + vault := newFakeVault(t) + + stdout, err := execute(t, + "--config", approleConfig(t, vault.server.URL), + "--valid_principals", "host,host.example.net,10.0.0.1", + "--ttl", "87600h", + "--public_key", testPublicKey, + "--json", + ) + if err != nil { + t.Fatalf("run: %v", err) + } + + want := fmt.Sprintf("{\"signed_key\":%q}\n", testSignedKey) + if stdout != want { + t.Fatalf("stdout = %q, want %q", stdout, want) + } + + var decoded map[string]string + if err := json.Unmarshal([]byte(stdout), &decoded); err != nil { + t.Fatalf("stdout is not valid JSON: %v", err) + } + if decoded["signed_key"] != testSignedKey { + t.Fatalf("signed_key = %q", decoded["signed_key"]) + } +} + +func TestSignSendsExpectedVaultRequests(t *testing.T) { + vault := newFakeVault(t) + principals := "host,host.example.net,10.0.0.1" + + if _, err := execute(t, + "--config", approleConfig(t, vault.server.URL), + "--valid_principals", principals, + "--ttl", "87600h", + "--public_key", testPublicKey, + "--json", + ); err != nil { + t.Fatalf("run: %v", err) + } + + if vault.login.path != "/v1/auth/approle/login" { + t.Errorf("login path = %q", vault.login.path) + } + if vault.login.body["role_id"] != "role-id-value" { + t.Errorf("login body = %v", vault.login.body) + } + if _, ok := vault.login.body["secret_id"]; ok { + t.Errorf("login body carried a secret_id: %v", vault.login.body) + } + if vault.sign.path != "/v1/sshca/sign/signhost" { + t.Errorf("sign path = %q", vault.sign.path) + } + if vault.sign.token != testToken { + t.Errorf("X-Vault-Token = %q", vault.sign.token) + } + if vault.sign.body["cert_type"] != "host" { + t.Errorf("cert_type = %q", vault.sign.body["cert_type"]) + } + // Vault wants the principals as the comma-separated string it was given. + if vault.sign.body["valid_principals"] != principals { + t.Errorf("valid_principals = %q, want %q", vault.sign.body["valid_principals"], principals) + } + if vault.sign.body["public_key"] != testPublicKey { + t.Errorf("public_key = %q", vault.sign.body["public_key"]) + } + if vault.sign.body["ttl"] != "87600h" { + t.Errorf("ttl = %q", vault.sign.body["ttl"]) + } +} + +func TestSignDefaultTTLAndPlainOutput(t *testing.T) { + vault := newFakeVault(t) + + stdout, err := execute(t, + "--config", approleConfig(t, vault.server.URL), + "--valid_principals", "host", + "--public_key", testPublicKey, + ) + if err != nil { + t.Fatalf("run: %v", err) + } + if stdout != testSignedKey+"\n" { + t.Fatalf("stdout = %q", stdout) + } + if vault.sign.body["ttl"] != defaultTTL { + t.Fatalf("ttl = %q, want %q", vault.sign.body["ttl"], defaultTTL) + } +} + +func TestKubernetesAuthSignsWithServiceAccountToken(t *testing.T) { + vault := newFakeVault(t) + jwtPath := writeJWT(t, "jwt-token-value\n") + + stdout, err := execute(t, + "--config", kubernetesConfig(t, vault.server.URL, jwtPath), + "--valid_principals", "host", + "--public_key", testPublicKey, + "--json", + ) + if err != nil { + t.Fatalf("run: %v", err) + } + if !strings.Contains(stdout, testSignedKey) { + t.Fatalf("stdout = %q", stdout) + } + if vault.login.path != "/v1/auth/k8s/au/syd1/login" { + t.Errorf("login path = %q", vault.login.path) + } + if vault.login.body["role"] != "puppet_sshsigner" { + t.Errorf("role = %q", vault.login.body["role"]) + } + if vault.login.body["jwt"] != "jwt-token-value" { + t.Errorf("jwt = %q", vault.login.body["jwt"]) + } +} + +func TestFailures(t *testing.T) { + tests := []struct { + name string + config func(t *testing.T, vault *fakeVault) string + prepare func(vault *fakeVault) + wantErr []string + wantNoSign bool + wantNoLogin bool + }{ + { + name: "approle login rejected", + config: func(t *testing.T, v *fakeVault) string { return approleConfig(t, v.server.URL) }, + prepare: func(v *fakeVault) { v.authStatus = http.StatusForbidden }, + wantErr: []string{"approle login", "403", "permission denied"}, + wantNoSign: true, + }, + { + name: "kubernetes login rejected", + config: func(t *testing.T, v *fakeVault) string { + return kubernetesConfig(t, v.server.URL, writeJWT(t, "jwt-token-value")) + }, + prepare: func(v *fakeVault) { v.authStatus = http.StatusForbidden }, + wantErr: []string{"kubernetes login", "puppet_sshsigner", "k8s/au/syd1", "403"}, + wantNoSign: true, + }, + { + name: "missing service account token", + config: func(t *testing.T, v *fakeVault) string { + return kubernetesConfig(t, v.server.URL, filepath.Join(t.TempDir(), "absent")) + }, + wantErr: []string{"reading service account token", "absent"}, + wantNoLogin: true, + wantNoSign: true, + }, + { + name: "empty service account token", + config: func(t *testing.T, v *fakeVault) string { + return kubernetesConfig(t, v.server.URL, writeJWT(t, " \n")) + }, + wantErr: []string{"service account token", "is empty"}, + wantNoLogin: true, + wantNoSign: true, + }, + { + name: "unknown auth method", + config: func(t *testing.T, v *fakeVault) string { + return writeConfig(t, fmt.Sprintf(`vault: + addr: '%s' + auth_method: 'oidc' + mount_point: 'sshca' + role_name: 'signhost' +`, v.server.URL)) + }, + wantErr: []string{`unsupported auth_method "oidc"`, "approle", "kubernetes"}, + wantNoLogin: true, + wantNoSign: true, + }, + { + name: "sign rejected by vault policy", + config: func(t *testing.T, v *fakeVault) string { return approleConfig(t, v.server.URL) }, + prepare: func(v *fakeVault) { v.signStatus = http.StatusForbidden }, + wantErr: []string{"sshca/sign/signhost", "403", "permission denied"}, + }, + { + name: "sign response without signed_key", + config: func(t *testing.T, v *fakeVault) string { return approleConfig(t, v.server.URL) }, + prepare: func(v *fakeVault) { v.signBody = `{"data":{"serial_number":"42"}}` }, + wantErr: []string{"sshca/sign/signhost", "no data.signed_key"}, + }, + { + name: "vault does not answer in time", + config: func(t *testing.T, v *fakeVault) string { + return writeConfig(t, fmt.Sprintf(`vault: + addr: '%s' + role_id: 'role-id-value' + approle_path: 'approle' + mount_point: 'sshca' + role_name: 'signhost' + timeout: '150ms' +`, v.server.URL)) + }, + prepare: func(v *fakeVault) { v.delay = 2 * time.Second }, + wantErr: []string{"approle login", "timeout"}, + }, + { + name: "config file missing", + config: func(t *testing.T, _ *fakeVault) string { + return filepath.Join(t.TempDir(), "absent.yaml") + }, + wantErr: []string{"reading config", "absent.yaml"}, + wantNoLogin: true, + wantNoSign: true, + }, + { + name: "config missing required key", + config: func(t *testing.T, v *fakeVault) string { + return writeConfig(t, fmt.Sprintf(`vault: + addr: '%s' + role_id: 'role-id-value' + approle_path: 'approle' + role_name: 'signhost' +`, v.server.URL)) + }, + wantErr: []string{"vault.mount_point is required"}, + wantNoLogin: true, + wantNoSign: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + vault := newFakeVault(t) + if test.prepare != nil { + test.prepare(vault) + } + + stdout, err := execute(t, + "--config", test.config(t, vault), + "--valid_principals", "host", + "--public_key", testPublicKey, + "--json", + ) + if err == nil { + t.Fatalf("expected an error, got stdout %q", stdout) + } + if stdout != "" { + t.Errorf("stdout = %q, want empty on failure", stdout) + } + for _, want := range test.wantErr { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not mention %q", err, want) + } + } + if test.wantNoLogin && vault.login.called { + t.Errorf("vault login was called") + } + if test.wantNoSign && vault.sign.called { + t.Errorf("vault sign was called") + } + }) + } +} + +func TestMissingRequiredFlags(t *testing.T) { + vault := newFakeVault(t) + config := approleConfig(t, vault.server.URL) + + for _, args := range [][]string{ + {"--config", config, "--valid_principals", "host"}, + {"--config", config, "--public_key", testPublicKey}, + } { + if _, err := execute(t, args...); err == nil { + t.Fatalf("expected an error for args %v", args) + } + } +} + +// Puppet runs the binary with generate(..., combine: true), which dups stderr +// onto stdout: a single byte on stderr corrupts the JSON parsejson() reads. +func TestSuccessWritesNothingToStderr(t *testing.T) { + vault := newFakeVault(t) + + cmd := exec.Command(os.Args[0], + "--config", approleConfig(t, vault.server.URL), + "--valid_principals", "host,host.example.net,10.0.0.1", + "--ttl", "87600h", + "--public_key", testPublicKey, + "--json", + ) + cmd.Env = append(os.Environ(), subprocessEnv+"=1") + + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + cmd.Stdout, cmd.Stderr = stdout, stderr + + if err := cmd.Run(); err != nil { + t.Fatalf("subprocess failed: %v (stderr %q)", err, stderr.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + + want := fmt.Sprintf("{\"signed_key\":%q}\n", testSignedKey) + if stdout.String() != want { + t.Fatalf("stdout = %q, want %q", stdout.String(), want) + } +} + +func TestFailureExitsNonZeroWithMessageOnStderr(t *testing.T) { + vault := newFakeVault(t) + vault.signStatus = http.StatusForbidden + + cmd := exec.Command(os.Args[0], + "--config", approleConfig(t, vault.server.URL), + "--valid_principals", "host", + "--public_key", testPublicKey, + "--json", + ) + cmd.Env = append(os.Environ(), subprocessEnv+"=1") + + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + cmd.Stdout, cmd.Stderr = stdout, stderr + + err := cmd.Run() + if err == nil { + t.Fatalf("expected a non-zero exit, stdout %q", stdout.String()) + } + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() == 0 { + t.Fatalf("expected a non-zero exit status, got %v", err) + } + if stdout.Len() != 0 { + t.Errorf("stdout = %q, want empty", stdout.String()) + } + if !strings.Contains(stderr.String(), "sshca/sign/signhost") { + t.Errorf("stderr = %q, want the failing path named", stderr.String()) + } +} diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml new file mode 100644 index 0000000..78d31a1 --- /dev/null +++ b/packaging/nfpm.yaml @@ -0,0 +1,40 @@ +--- +# nfpm config for building the sshsignhost RPM. +# Rendered through envsubst (see scripts/build-rpm.sh) then fed to `nfpm pkg`. + +name: ${PACKAGE_NAME} +version: ${PACKAGE_VERSION} +release: ${PACKAGE_RELEASE} +arch: ${PACKAGE_ARCH} +platform: ${PACKAGE_PLATFORM} +section: default +priority: extra +description: "${PACKAGE_DESCRIPTION}" + +maintainer: ${PACKAGE_MAINTAINER} +homepage: ${PACKAGE_HOMEPAGE} +license: ${PACKAGE_LICENSE} + +disable_globbing: false + +contents: + # Puppet's generate() calls the binary at this exact path. + - src: dist/sshsignhost + dst: /usr/local/bin/sshsignhost + file_info: + mode: 0755 + owner: root + group: root + + - src: dist/completions/sshsignhost.bash + dst: /usr/share/bash-completion/completions/sshsignhost + file_info: + mode: 0644 + - src: dist/completions/_sshsignhost + dst: /usr/share/zsh/site-functions/_sshsignhost + file_info: + mode: 0644 + - src: dist/completions/sshsignhost.fish + dst: /usr/share/fish/vendor_completions.d/sshsignhost.fish + file_info: + mode: 0644 diff --git a/scripts/build-rpm.sh b/scripts/build-rpm.sh new file mode 100755 index 0000000..ce9eeca --- /dev/null +++ b/scripts/build-rpm.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# +# Package the (already built) sshsignhost binary into an RPM with nfpm, +# bundling generated bash/zsh/fish shell completions. +# Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG) +# +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT_DIR}" + +VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}" +VERSION="${VERSION#v}" # strip a leading v +BINARY="sshsignhost" +DIST="dist" + +if [ ! -f "${DIST}/${BINARY}" ]; then + echo "ERROR: ${DIST}/${BINARY} not found; run 'make build' first" >&2 + exit 1 +fi + +COMP_DIR="${DIST}/completions" +mkdir -p "${COMP_DIR}" +"./${DIST}/${BINARY}" completion bash >"${COMP_DIR}/${BINARY}.bash" +"./${DIST}/${BINARY}" completion zsh >"${COMP_DIR}/_${BINARY}" +"./${DIST}/${BINARY}" completion fish >"${COMP_DIR}/${BINARY}.fish" + +export PACKAGE_NAME="${BINARY}" +export PACKAGE_VERSION="${VERSION}" +export PACKAGE_RELEASE="1" +export PACKAGE_ARCH="amd64" +export PACKAGE_PLATFORM="linux" +export PACKAGE_DESCRIPTION="Signs SSH host certificates with Vault; called by Puppet during catalog compilation" +export PACKAGE_MAINTAINER="Ben Vincent " +export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/sshsignhost" +export PACKAGE_LICENSE="MIT" + +envsubst "${DIST}/nfpm.yaml" +nfpm pkg --config "${DIST}/nfpm.yaml" --target "${DIST}" --packager rpm + +echo "Built:" +ls -1 "${DIST}"/*.rpm diff --git a/vault.go b/vault.go new file mode 100644 index 0000000..296547d --- /dev/null +++ b/vault.go @@ -0,0 +1,184 @@ +package main + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "strings" +) + +const maxErrorBody = 4096 + +type vaultClient struct { + cfg vaultConfig + client *http.Client +} + +type authResponse struct { + Auth struct { + ClientToken string `json:"client_token"` + } `json:"auth"` +} + +type signResponse struct { + Data struct { + SignedKey string `json:"signed_key"` + } `json:"data"` +} + +func newVaultClient(cfg vaultConfig) (*vaultClient, error) { + timeout, err := cfg.requestTimeout() + if err != nil { + return nil, err + } + + transport := &http.Transport{ + DialContext: (&net.Dialer{Timeout: timeout}).DialContext, + TLSHandshakeTimeout: timeout, + ResponseHeaderTimeout: timeout, + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: cfg.skipTLSVerify(), // #nosec G402 + }, + } + + return &vaultClient{cfg: cfg, client: &http.Client{Timeout: timeout, Transport: transport}}, nil +} + +func (v *vaultClient) authenticate(ctx context.Context) (string, error) { + switch v.cfg.AuthMethod { + case authAppRole: + return v.loginAppRole(ctx) + case authKubernetes: + return v.loginKubernetes(ctx) + default: + return "", fmt.Errorf("unsupported auth_method %q: expected %q or %q", v.cfg.AuthMethod, authAppRole, authKubernetes) + } +} + +func (v *vaultClient) loginAppRole(ctx context.Context) (string, error) { + path := fmt.Sprintf("auth/%s/login", v.cfg.AppRolePath) + // The Vault role sets bind_secret_id: false, so role_id alone logs in. + body := map[string]string{"role_id": v.cfg.RoleID} + + token, err := v.login(ctx, path, body) + if err != nil { + return "", fmt.Errorf("approle login on mount %q: %w", v.cfg.AppRolePath, err) + } + return token, nil +} + +func (v *vaultClient) loginKubernetes(ctx context.Context) (string, error) { + raw, err := os.ReadFile(v.cfg.JWTPath) // #nosec G304 + if err != nil { + return "", fmt.Errorf("reading service account token %q: %w", v.cfg.JWTPath, err) + } + jwt := strings.TrimSpace(string(raw)) + if jwt == "" { + return "", fmt.Errorf("service account token %q is empty", v.cfg.JWTPath) + } + + path := fmt.Sprintf("auth/%s/login", v.cfg.K8sMount) + body := map[string]string{"role": v.cfg.K8sRole, "jwt": jwt} + + token, err := v.login(ctx, path, body) + if err != nil { + return "", fmt.Errorf("kubernetes login as role %q on mount %q: %w", v.cfg.K8sRole, v.cfg.K8sMount, err) + } + return token, nil +} + +func (v *vaultClient) login(ctx context.Context, path string, body map[string]string) (string, error) { + raw, err := v.post(ctx, path, "", body) + if err != nil { + return "", err + } + + var parsed authResponse + if err := json.Unmarshal(raw, &parsed); err != nil { + return "", fmt.Errorf("decoding login response: %w", err) + } + if parsed.Auth.ClientToken == "" { + return "", errors.New("login response carried no auth.client_token") + } + return parsed.Auth.ClientToken, nil +} + +func (v *vaultClient) signHostKey(ctx context.Context, token, publicKey, validPrincipals, ttl string) (string, error) { + path := fmt.Sprintf("%s/sign/%s", v.cfg.MountPoint, v.cfg.RoleName) + body := map[string]string{ + "cert_type": "host", + "public_key": publicKey, + "valid_principals": validPrincipals, + "ttl": ttl, + } + + raw, err := v.post(ctx, path, token, body) + if err != nil { + return "", fmt.Errorf("signing host key at %q: %w", path, err) + } + + var parsed signResponse + if err := json.Unmarshal(raw, &parsed); err != nil { + return "", fmt.Errorf("decoding sign response from %q: %w", path, err) + } + if parsed.Data.SignedKey == "" { + return "", fmt.Errorf("sign response from %q carried no data.signed_key", path) + } + return parsed.Data.SignedKey, nil +} + +func (v *vaultClient) post(ctx context.Context, path, token string, body map[string]string) ([]byte, error) { + if ctx == nil { + ctx = context.Background() + } + + encoded, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("encoding request body: %w", err) + } + + url := fmt.Sprintf("%s/v1/%s", strings.TrimRight(v.cfg.Addr, "/"), path) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(encoded)) + if err != nil { + return nil, fmt.Errorf("building request for %s: %w", url, err) + } + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("X-Vault-Token", token) + } + + resp, err := v.client.Do(req) + if err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return nil, fmt.Errorf("timeout after %s waiting for %s", v.client.Timeout, url) + } + return nil, fmt.Errorf("requesting %s: %w", url, err) + } + defer func() { _ = resp.Body.Close() }() + + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("reading response from %s: %w", url, err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("vault returned HTTP %d: %s", resp.StatusCode, truncate(string(raw))) + } + return raw, nil +} + +func truncate(body string) string { + body = strings.TrimSpace(body) + if len(body) > maxErrorBody { + return body[:maxErrorBody] + "..." + } + return body +}