From 67426b9e6ef4fe1b25b23efa81625f49eb89ce19 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 19 Sep 2026 10:35:00 +1000 Subject: [PATCH 1/4] 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 +} From fde942d83167ada342b92aed9eeabd77d83177ec Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 19 Sep 2026 12:13:22 +1000 Subject: [PATCH 2/4] Drive releases with goreleaser and test against a real Vault Replace the nfpm/build-rpm.sh release path with a single goreleaser config producing the RPM, the raw sshsignhost-- binaries the puppetserver-compiler initContainer fetches, tarballs and checksums; the tag pipeline runs goreleaser then PUTs the RPM to artifactapi. Add an integration suite behind the 'integration' build tag that provisions an ssh CA mount, host role and approle on a dev-mode Vault and signs a host key through the built binary with Puppet's exact argv, asserting an empty stderr and a decoded host certificate. --- .gitignore | 1 + .goreleaser.yaml | 100 +++++++++ .woodpecker/integration.yaml | 41 ++++ .woodpecker/release.yaml | 101 +++------- AGENTS.md | 16 +- Makefile | 20 +- README.md | 11 +- go.mod | 2 + go.sum | 6 + integration_test.go | 379 +++++++++++++++++++++++++++++++++++ packaging/nfpm.yaml | 40 ---- scripts/build-rpm.sh | 42 ---- scripts/integration-test.sh | 49 +++++ 13 files changed, 633 insertions(+), 175 deletions(-) create mode 100644 .goreleaser.yaml create mode 100644 .woodpecker/integration.yaml create mode 100644 integration_test.go delete mode 100644 packaging/nfpm.yaml delete mode 100755 scripts/build-rpm.sh create mode 100755 scripts/integration-test.sh diff --git a/.gitignore b/.gitignore index d4200b0..0494353 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /sshsignhost /sshsignhost-* +/completions/ dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..3d7fda0 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,100 @@ +--- +version: 2 + +project_name: sshsignhost + +before: + hooks: + - mkdir -p completions + - sh -c "go run . completion bash > completions/sshsignhost.bash" + - sh -c "go run . completion zsh > completions/_sshsignhost" + - sh -c "go run . completion fish > completions/sshsignhost.fish" + +builds: + - id: sshsignhost + binary: sshsignhost + main: . + env: + - CGO_ENABLED=0 + flags: + - -trimpath + ldflags: + - -s -w -X main.version={{ .Version }} + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + +archives: + # Raw binaries: the puppetserver-compiler initContainer fetches a single + # file by URL and checksums it, so the bare binary must be a release asset. + - id: binaries + ids: + - sshsignhost + formats: + - binary + name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}" + - id: archives + ids: + - sshsignhost + formats: + - tar.gz + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + files: + - README.md + - src: completions/* + dst: completions + +nfpms: + - id: rpm + ids: + - sshsignhost + package_name: sshsignhost + formats: + - rpm + file_name_template: "{{ .ConventionalFileName }}" + release: "1" + maintainer: Ben Vincent + homepage: https://git.unkin.net/unkin/sshsignhost + license: MIT + section: default + priority: extra + description: Signs SSH host certificates with Vault; called by Puppet during catalog compilation + # Puppet's generate() calls the binary at this exact path. + bindir: /usr/local/bin + contents: + - src: completions/sshsignhost.bash + dst: /usr/share/bash-completion/completions/sshsignhost + file_info: + mode: 0644 + - src: completions/_sshsignhost + dst: /usr/share/zsh/site-functions/_sshsignhost + file_info: + mode: 0644 + - src: completions/sshsignhost.fish + dst: /usr/share/fish/vendor_completions.d/sshsignhost.fish + file_info: + mode: 0644 + +checksum: + name_template: checksums.txt + +# The release lands on the estate's Gitea, not github.com. +gitea_urls: + api: https://git.unkin.net/api/v1 + download: https://git.unkin.net + +release: + draft: false + prerelease: auto + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^ci:" + - "^chore:" diff --git a/.woodpecker/integration.yaml b/.woodpecker/integration.yaml new file mode 100644 index 0000000..0c95c91 --- /dev/null +++ b/.woodpecker/integration.yaml @@ -0,0 +1,41 @@ +when: + - event: pull_request + +# Kept out of test.yaml so the three required PR contexts (build/test/pre-commit) +# stay exactly what they were; this one needs a Vault next to it. +services: + - name: vault + image: hashicorp/vault:1.20 + environment: + # Throwaway dev-mode server; this token grants nothing outside this pod. + VAULT_DEV_ROOT_TOKEN_ID: dev-root-token + VAULT_DEV_LISTEN_ADDRESS: 0.0.0.0:8200 + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + +steps: + - name: integration + image: golang:1.25 + environment: + SSHSIGNHOST_TEST_VAULT_ADDR: http://vault:8200 + VAULT_TOKEN: dev-root-token + commands: + - go test -tags=integration -count=1 -v -run TestIntegration ./... + 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 index 48251f1..d0d6672 100644 --- a/.woodpecker/release.yaml +++ b/.woodpecker/release.yaml @@ -1,6 +1,15 @@ when: - event: tag +# goreleaser derives the version and changelog from git, so it needs the full +# history and every tag -- the default partial clone gives it neither. +clone: + - name: git + image: woodpeckerci/plugin-git + settings: + partial: false + tags: true + steps: - name: test image: golang:1.25 @@ -17,19 +26,17 @@ steps: memory: 2Gi cpu: 2 - - name: build - image: git.unkin.net/unkin/almalinux9-gobuilder:20260606 + # One driver for both artifact types: the RPM Puppet installs via dnf and the + # raw binaries the puppetserver-compiler initContainer fetches by URL. + - name: goreleaser + image: golang:1.26 + environment: + GORELEASER_VERSION: v2.17.0 + GITEA_TOKEN: + from_secret: RELEASER_TOKEN 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 + - go install github.com/goreleaser/goreleaser/v2@$${GORELEASER_VERSION} + - goreleaser release --clean depends_on: [test] backend_options: kubernetes: @@ -42,24 +49,8 @@ steps: 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). + # goreleaser cannot PUT to artifactapi, so the RPM it built is pushed to the + # local rpm repo (a real yum repo; repodata regenerates automatically). - name: upload-rpm image: git.unkin.net/unkin/almalinux9-base:20260606 commands: @@ -81,56 +72,14 @@ steps: -H "Content-Type: application/x-rpm" \ --data-binary @"$$rpm" done - depends_on: [package] + depends_on: [goreleaser] 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 + cpu: 1 limits: - memory: 512Mi - cpu: 500m + memory: 2Gi + cpu: 2 diff --git a/AGENTS.md b/AGENTS.md index 2a6cbc2..dc3f29a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,10 +15,11 @@ 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) +integration_test.go # -tags=integration: signs against a real dev-mode Vault +Makefile # build / test / lint / integration-test / snapshot / version bump +.goreleaser.yaml # binaries + RPM + Gitea release assets, one driver +scripts/ # integration-test.sh (dev-mode Vault in a container) +.woodpecker/ # build, test, pre-commit, integration (PR) + release (tag) ``` ## Contract with Puppet @@ -43,13 +44,18 @@ scripts/ # build-rpm.sh (completions + nfpm) - Sign: `POST {addr}/v1/{mount_point}/sign/{role_name}` with `X-Vault-Token` and `cert_type: host`. -## Known blocker +## Known blockers 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. +The `signhost` role sets `allowed_domains: main.unkin.net,consul` with +`allow_bare_domains: false`, so the bare hostname and the IP the manifest sends +alongside the FQDN are refused. `TestIntegrationRejectsPrincipalsOutsideAllowedDomains` +pins that behaviour; fixing it is another terraform-vault change. + ## TLS `tls_skip_verify` defaults to `true`, preserving the Python helper's diff --git a/Makefile b/Makefile index 5677fa8..9ab7e2a 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ 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 +.PHONY: all build test lint fmt pre-commit clean install integration-test snapshot release-check patch minor major _tag all: build @@ -25,21 +25,21 @@ pre-commit: pre-commit run --all-files clean: - rm -rf $(DIST) $(BINARY) + rm -rf $(DIST) completions $(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 +# Signs a host key against a throwaway dev-mode Vault; needs docker or podman. +integration-test: + ./scripts/integration-test.sh -rpm: build rpm-package +# Binaries, the RPM and the release assets all come out of goreleaser. +snapshot: + goreleaser release --snapshot --clean -rpm-package: - ./scripts/build-rpm.sh $(VERSION) +release-check: + goreleaser check # 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) diff --git a/README.md b/README.md index ce4b41b..f47b596 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,14 @@ hang a catalog compile forever. ## Build ```bash -make build # -> dist/sshsignhost +make build # -> dist/sshsignhost make test -make rpm # RPM with bash/zsh/fish completions +make integration-test # signs a host key against a dev-mode Vault in a container +make snapshot # goreleaser: binaries, RPM and archives into dist/ ``` + +Releases are driven entirely by goreleaser on a `v*` tag: the RPM +(`/usr/local/bin/sshsignhost` plus bash/zsh/fish completions) is pushed to the +artifactapi `rpm-internal` yum repo, and the raw +`sshsignhost--` binaries, tarballs and `checksums.txt` are attached to +the Gitea release for the puppetserver-compiler initContainer to fetch. diff --git a/go.mod b/go.mod index ceee82d..1bff3ed 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,12 @@ go 1.25.7 require ( github.com/spf13/cobra v1.10.2 + golang.org/x/crypto v0.54.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.47.0 // indirect ) diff --git a/go.sum b/go.sum index 47edb24..802d67e 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,12 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 0000000..48a5733 --- /dev/null +++ b/integration_test.go @@ -0,0 +1,379 @@ +//go:build integration + +package main + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "golang.org/x/crypto/ssh" +) + +// These mirror terraform-vault's config/ssh_secret_backend/sshca.yaml and +// config/ssh_secret_backend_role/sshca/signhost.yaml so the tool is exercised +// against the same mount, role and constraints production serves. +const ( + itMount = "sshca" + itRole = "signhost" + itPolicy = "sshsignhost" + itAppRoleMount = "approle" + itAllowedDomains = "main.unkin.net,consul" + itMaxLeaseTTL = "315360000s" // 87600h + itPuppetTTL = "87600h" +) + +func itVaultAddr() string { + for _, key := range []string{"SSHSIGNHOST_TEST_VAULT_ADDR", "VAULT_ADDR"} { + if v := os.Getenv(key); v != "" { + return strings.TrimRight(v, "/") + } + } + return "http://127.0.0.1:8200" +} + +func itVaultToken() string { + if v := os.Getenv("VAULT_TOKEN"); v != "" { + return v + } + return "root" +} + +func itWaitForVault(t *testing.T) { + t.Helper() + + deadline := time.Now().Add(90 * time.Second) + url := itVaultAddr() + "/v1/sys/health?standbyok=true" + var last error + for time.Now().Before(deadline) { + resp, err := http.Get(url) // #nosec G107 + if err == nil { + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return + } + last = fmt.Errorf("HTTP %d", resp.StatusCode) + } else { + last = err + } + time.Sleep(250 * time.Millisecond) + } + t.Fatalf("vault at %s never became healthy: %v", itVaultAddr(), last) +} + +func itVaultAPI(t *testing.T, method, path string, body any) map[string]any { + t.Helper() + + var reader io.Reader + if body != nil { + encoded, err := json.Marshal(body) + if err != nil { + t.Fatalf("encoding %s %s: %v", method, path, err) + } + reader = bytes.NewReader(encoded) + } + + req, err := http.NewRequest(method, itVaultAddr()+"/v1/"+path, reader) + if err != nil { + t.Fatalf("building %s %s: %v", method, path, err) + } + req.Header.Set("X-Vault-Token", itVaultToken()) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + defer func() { _ = resp.Body.Close() }() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("reading %s %s: %v", method, path, err) + } + if resp.StatusCode >= 300 { + t.Fatalf("%s %s returned HTTP %d: %s", method, path, resp.StatusCode, raw) + } + + parsed := map[string]any{} + if len(bytes.TrimSpace(raw)) > 0 { + if err := json.Unmarshal(raw, &parsed); err != nil { + t.Fatalf("decoding %s %s: %v (%s)", method, path, err, raw) + } + } + return parsed +} + +// itSetupVault provisions the ssh CA mount, the host role, the sign policy and +// the approle the tool authenticates with, then returns the role_id. Every call +// is idempotent-by-overwrite so the test can be re-run against a live dev server. +func itSetupVault(t *testing.T) string { + t.Helper() + + itWaitForVault(t) + + mounts := itVaultAPI(t, http.MethodGet, "sys/mounts", nil) + if _, ok := mounts[itMount+"/"]; !ok { + itVaultAPI(t, http.MethodPost, "sys/mounts/"+itMount, map[string]any{ + "type": "ssh", + "description": "SSH CA Engine", + "config": map[string]any{"max_lease_ttl": itMaxLeaseTTL}, + }) + itVaultAPI(t, http.MethodPost, itMount+"/config/ca", map[string]any{ + "generate_signing_key": true, + "key_type": "ssh-rsa", + }) + } + + itVaultAPI(t, http.MethodPost, itMount+"/roles/"+itRole, map[string]any{ + "key_type": "ca", + "algorithm_signer": "rsa-sha2-256", + "ttl": itMaxLeaseTTL, + "max_ttl": itMaxLeaseTTL, + "allow_host_certificates": true, + "allow_user_certificates": false, + "allowed_domains": itAllowedDomains, + "allow_subdomains": true, + "allow_bare_domains": false, + }) + + // The production policy grants sshca/sign/host while the only role that + // exists is signhost, which is why production 403s today. Here we grant the + // path the tool actually requests, proving the tool is correct once the + // policy is fixed. + itVaultAPI(t, http.MethodPut, "sys/policies/acl/"+itPolicy, map[string]any{ + "policy": fmt.Sprintf("path %q {\n capabilities = [\"create\", \"update\"]\n}\n", itMount+"/sign/"+itRole), + }) + + auths := itVaultAPI(t, http.MethodGet, "sys/auth", nil) + if _, ok := auths[itAppRoleMount+"/"]; !ok { + itVaultAPI(t, http.MethodPost, "sys/auth/"+itAppRoleMount, map[string]any{"type": "approle"}) + } + + // bind_secret_id: false matches production; Vault demands another constraint + // alongside it, which production satisfies with token_bound_cidrs. + itVaultAPI(t, http.MethodPost, "auth/"+itAppRoleMount+"/role/"+itPolicy, map[string]any{ + "bind_secret_id": false, + "token_bound_cidrs": []string{"0.0.0.0/0"}, + "token_policies": []string{itPolicy}, + "token_ttl": "60s", + "token_max_ttl": "120s", + }) + + roleIDResp := itVaultAPI(t, http.MethodGet, "auth/"+itAppRoleMount+"/role/"+itPolicy+"/role-id", nil) + data, ok := roleIDResp["data"].(map[string]any) + if !ok { + t.Fatalf("role-id response carried no data: %v", roleIDResp) + } + roleID, ok := data["role_id"].(string) + if !ok || roleID == "" { + t.Fatalf("role-id response carried no role_id: %v", data) + } + return roleID +} + +func itBuildBinary(t *testing.T, dir string) string { + t.Helper() + + if path := os.Getenv("SSHSIGNHOST_BIN"); path != "" { + return path + } + + binary := filepath.Join(dir, "sshsignhost") + build := exec.Command("go", "build", "-o", binary, ".") + build.Env = append(os.Environ(), "CGO_ENABLED=0") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("building binary: %v\n%s", err, out) + } + return binary +} + +func itWriteConfig(t *testing.T, dir, roleID string) string { + t.Helper() + + path := filepath.Join(dir, "config.yaml") + // The dev listener is plain HTTP, so tls_skip_verify never comes into play; + // the TLS path stays covered by the httptest suite in main_test.go. + body := fmt.Sprintf(`vault: + addr: %q + auth_method: approle + approle_path: %q + mount_point: %q + role_name: %q + role_id: %q + timeout: 30s +`, itVaultAddr(), itAppRoleMount, itMount, itRole, roleID) + + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("writing config: %v", err) + } + return path +} + +func itHostPublicKey(t *testing.T) (string, ssh.PublicKey) { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generating host key: %v", err) + } + pub, err := ssh.NewPublicKey(&key.PublicKey) + if err != nil { + t.Fatalf("converting host key: %v", err) + } + return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub))), pub +} + +// itRun invokes the built binary with exactly the argv profiles::ssh::sign +// builds through generate(). +func itRun(t *testing.T, binary, config, principals, publicKey string) (string, string, error) { + t.Helper() + + cmd := exec.Command(binary, + "--config", config, + "--valid_principals", principals, + "--ttl", itPuppetTTL, + "--public_key", publicKey, + "--json", + ) + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + cmd.Stdout, cmd.Stderr = stdout, stderr + err := cmd.Run() + return stdout.String(), stderr.String(), err +} + +func TestIntegrationSignsHostCertificate(t *testing.T) { + roleID := itSetupVault(t) + + dir := t.TempDir() + binary := itBuildBinary(t, dir) + config := itWriteConfig(t, dir, roleID) + publicKey, parsedPublicKey := itHostPublicKey(t) + + // allowed_domains + allow_subdomains accepts these; see + // TestIntegrationRejectsPrincipalsOutsideAllowedDomains for the bare + // hostname/IP principals the Puppet manifest also sends today. + principals := "node1.main.unkin.net,node1.consul" + wantPrincipals := []string{"node1.consul", "node1.main.unkin.net"} + + stdout, stderr, err := itRun(t, binary, config, principals, publicKey) + if err != nil { + t.Fatalf("signing failed: %v (stderr %q)", err, stderr) + } + if stderr != "" { + // generate() splices stderr onto stdout, so anything here breaks parsejson(). + t.Fatalf("stderr = %q, want empty", stderr) + } + + var out signedKeyOutput + if err := json.Unmarshal([]byte(stdout), &out); err != nil { + t.Fatalf("stdout %q is not JSON: %v", stdout, err) + } + if out.SignedKey == "" { + t.Fatal("signed_key is empty") + } + + pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(out.SignedKey)) + if err != nil { + t.Fatalf("signed_key does not parse as an SSH key: %v", err) + } + cert, ok := pub.(*ssh.Certificate) + if !ok { + t.Fatalf("signed_key is a %T, want *ssh.Certificate", pub) + } + if cert.CertType != ssh.HostCert { + t.Fatalf("cert type = %d, want host (%d)", cert.CertType, ssh.HostCert) + } + // Vault returns the principals sorted, not in the order they were sent. + gotPrincipals := slices.Clone(cert.ValidPrincipals) + slices.Sort(gotPrincipals) + if !slices.Equal(gotPrincipals, wantPrincipals) { + t.Fatalf("principals = %v, want %v", gotPrincipals, wantPrincipals) + } + if !bytes.Equal(cert.Key.Marshal(), parsedPublicKey.Marshal()) { + t.Fatal("certificate was issued over a different public key") + } + + validity := time.Unix(int64(cert.ValidBefore), 0).Sub(time.Unix(int64(cert.ValidAfter), 0)) // #nosec G115 + wantTTL, err := time.ParseDuration(itPuppetTTL) + if err != nil { + t.Fatalf("parsing ttl: %v", err) + } + if delta := validity - wantTTL; delta < -2*time.Hour || delta > 2*time.Hour { + t.Fatalf("validity = %s, want ~%s", validity, wantTTL) + } +} + +// The Puppet manifest sends the bare hostname and the primary IP alongside the +// FQDN. The production role sets allow_bare_domains: false, so Vault refuses +// them -- a real gap in the estate's config, pinned here so it is not mistaken +// for a bug in this tool. +func TestIntegrationRejectsPrincipalsOutsideAllowedDomains(t *testing.T) { + roleID := itSetupVault(t) + + dir := t.TempDir() + binary := itBuildBinary(t, dir) + config := itWriteConfig(t, dir, roleID) + publicKey, _ := itHostPublicKey(t) + + stdout, stderr, err := itRun(t, binary, config, "node1,node1.main.unkin.net,198.18.25.5", publicKey) + if err == nil { + t.Fatalf("signing unexpectedly succeeded: %q", stdout) + } + if stdout != "" { + // A half-written JSON document would break the catalog compile. + t.Fatalf("stdout = %q, want empty", stdout) + } + if !strings.Contains(stderr, itMount+"/sign/"+itRole) { + t.Fatalf("stderr = %q, want the sign path named", stderr) + } +} + +// Without the policy granting sshca/sign/signhost the tool must fail loudly, +// which is exactly what production does today against sshca/sign/host. +func TestIntegrationDeniedSignPathFailsCleanly(t *testing.T) { + itSetupVault(t) + + denied := itPolicy + "-denied" + itVaultAPI(t, http.MethodPut, "sys/policies/acl/"+denied, map[string]any{ + "policy": fmt.Sprintf("path %q {\n capabilities = [\"create\", \"update\"]\n}\n", itMount+"/sign/host"), + }) + itVaultAPI(t, http.MethodPost, "auth/"+itAppRoleMount+"/role/"+denied, map[string]any{ + "bind_secret_id": false, + "token_bound_cidrs": []string{"0.0.0.0/0"}, + "token_policies": []string{denied}, + "token_ttl": "60s", + "token_max_ttl": "120s", + }) + resp := itVaultAPI(t, http.MethodGet, "auth/"+itAppRoleMount+"/role/"+denied+"/role-id", nil) + roleID, _ := resp["data"].(map[string]any)["role_id"].(string) + if roleID == "" { + t.Fatal("denied role carried no role_id") + } + + dir := t.TempDir() + binary := itBuildBinary(t, dir) + config := itWriteConfig(t, dir, roleID) + publicKey, _ := itHostPublicKey(t) + + stdout, stderr, err := itRun(t, binary, config, "node1.main.unkin.net", publicKey) + if err == nil { + t.Fatalf("signing unexpectedly succeeded: %q", stdout) + } + if stdout != "" { + t.Fatalf("stdout = %q, want empty", stdout) + } + if !strings.Contains(stderr, "403") { + t.Fatalf("stderr = %q, want an HTTP 403 named", stderr) + } +} diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml deleted file mode 100644 index 78d31a1..0000000 --- a/packaging/nfpm.yaml +++ /dev/null @@ -1,40 +0,0 @@ ---- -# 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 deleted file mode 100755 index ce9eeca..0000000 --- a/scripts/build-rpm.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/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/scripts/integration-test.sh b/scripts/integration-test.sh new file mode 100755 index 0000000..f2fc8da --- /dev/null +++ b/scripts/integration-test.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# +# Run the Vault integration suite against a throwaway dev-mode Vault. +# Set SSHSIGNHOST_TEST_VAULT_ADDR (and VAULT_TOKEN) to reuse a running server; +# otherwise a container is started and removed again. +# +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT_DIR}" + +GO_TEST=(go test -tags=integration -count=1 -v -run TestIntegration ./...) + +if [ -n "${SSHSIGNHOST_TEST_VAULT_ADDR:-}" ]; then + exec "${GO_TEST[@]}" +fi + +RUNTIME="${CONTAINER_RUNTIME:-}" +if [ -z "${RUNTIME}" ]; then + for candidate in docker podman; do + if command -v "${candidate}" >/dev/null 2>&1; then + RUNTIME="${candidate}" + break + fi + done +fi +if [ -z "${RUNTIME}" ]; then + echo "ERROR: need docker or podman, or set SSHSIGNHOST_TEST_VAULT_ADDR" >&2 + exit 1 +fi + +IMAGE="${VAULT_IMAGE:-hashicorp/vault:1.20}" +PORT="${VAULT_PORT:-8200}" +NAME="sshsignhost-vault-$$" +TOKEN="dev-root-token" + +cleanup() { "${RUNTIME}" rm -f "${NAME}" >/dev/null 2>&1 || true; } +trap cleanup EXIT + +"${RUNTIME}" run -d --rm --name "${NAME}" \ + -p "127.0.0.1:${PORT}:8200" \ + -e "VAULT_DEV_ROOT_TOKEN_ID=${TOKEN}" \ + -e "VAULT_DEV_LISTEN_ADDRESS=0.0.0.0:8200" \ + "${IMAGE}" >/dev/null + +# The suite polls /v1/sys/health itself, so there is nothing to sleep on here. +export SSHSIGNHOST_TEST_VAULT_ADDR="http://127.0.0.1:${PORT}" +export VAULT_TOKEN="${TOKEN}" +"${GO_TEST[@]}" From 36c506918d0ed5f0b2f4a57ab4bb8f9ae029ee7a Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 19 Sep 2026 12:18:08 +1000 Subject: [PATCH 3/4] Declare the vault service port and bound integration HTTP calls --- .woodpecker/integration.yaml | 4 ++++ integration_test.go | 12 ++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.woodpecker/integration.yaml b/.woodpecker/integration.yaml index 0c95c91..0af9cc9 100644 --- a/.woodpecker/integration.yaml +++ b/.woodpecker/integration.yaml @@ -6,6 +6,10 @@ when: services: - name: vault image: hashicorp/vault:1.20 + # The kubernetes backend builds the Service from this list; without it the + # service resolves but carries no port. + ports: + - 8200 environment: # Throwaway dev-mode server; this token grants nothing outside this pod. VAULT_DEV_ROOT_TOKEN_ID: dev-root-token diff --git a/integration_test.go b/integration_test.go index 48a5733..9524cf3 100644 --- a/integration_test.go +++ b/integration_test.go @@ -50,6 +50,10 @@ func itVaultToken() string { return "root" } +// Every call is bounded so an unreachable server fails the suite instead of +// hanging a CI step on a blackholed connect. +var itClient = &http.Client{Timeout: 15 * time.Second} + func itWaitForVault(t *testing.T) { t.Helper() @@ -57,7 +61,11 @@ func itWaitForVault(t *testing.T) { url := itVaultAddr() + "/v1/sys/health?standbyok=true" var last error for time.Now().Before(deadline) { - resp, err := http.Get(url) // #nosec G107 + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + t.Fatalf("building health request: %v", err) + } + resp, err := itClient.Do(req) if err == nil { _ = resp.Body.Close() if resp.StatusCode == http.StatusOK { @@ -91,7 +99,7 @@ func itVaultAPI(t *testing.T, method, path string, body any) map[string]any { req.Header.Set("X-Vault-Token", itVaultToken()) req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) + resp, err := itClient.Do(req) if err != nil { t.Fatalf("%s %s: %v", method, path, err) } From f2389b49a786ff73fd99dde533ee19541079825c Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 19 Sep 2026 12:34:09 +1000 Subject: [PATCH 4/4] Run dev-mode Vault inside the integration step --- .woodpecker/integration.yaml | 38 +++++++++++++----------------------- 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/.woodpecker/integration.yaml b/.woodpecker/integration.yaml index 0af9cc9..167558a 100644 --- a/.woodpecker/integration.yaml +++ b/.woodpecker/integration.yaml @@ -3,36 +3,26 @@ when: # Kept out of test.yaml so the three required PR contexts (build/test/pre-commit) # stay exactly what they were; this one needs a Vault next to it. -services: - - name: vault - image: hashicorp/vault:1.20 - # The kubernetes backend builds the Service from this list; without it the - # service resolves but carries no port. - ports: - - 8200 - environment: - # Throwaway dev-mode server; this token grants nothing outside this pod. - VAULT_DEV_ROOT_TOKEN_ID: dev-root-token - VAULT_DEV_LISTEN_ADDRESS: 0.0.0.0:8200 - backend_options: - kubernetes: - serviceAccountName: default - resources: - requests: - memory: 512Mi - cpu: 1 - limits: - memory: 2Gi - cpu: 2 - steps: - name: integration image: golang:1.25 environment: - SSHSIGNHOST_TEST_VAULT_ADDR: http://vault:8200 + VAULT_VERSION: 1.20.4 + VAULT_SHA256: fc5fb5d01d192f1216b139fb5c6af17e3af742aaeffc289fd861920ec55f2c9c + SSHSIGNHOST_TEST_VAULT_ADDR: http://127.0.0.1:8200 + # Throwaway dev-mode server on loopback; this token grants nothing. VAULT_TOKEN: dev-root-token commands: - - go test -tags=integration -count=1 -v -run TestIntegration ./... + # The golang image does not trust the estate's internal CA, so artifactapi + # is fetched over http and the archive is pinned by checksum instead. + - curl -fsSL -o /tmp/vault.zip "http://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/hashicorp-releases/vault/$${VAULT_VERSION}/vault_$${VAULT_VERSION}_linux_amd64.zip" + - echo "$${VAULT_SHA256} /tmp/vault.zip" | sha256sum -c - + - python3 -m zipfile -e /tmp/vault.zip /usr/local/bin/ + - chmod +x /usr/local/bin/vault + # Dev mode serves plain HTTP; the suite polls /v1/sys/health rather than + # sleeping, and the timeout keeps a wedged server from pinning an agent. + - vault server -dev -dev-root-token-id="$${VAULT_TOKEN}" -dev-listen-address=127.0.0.1:8200 >/tmp/vault.log 2>&1 & + - timeout 600 go test -tags=integration -count=1 -v -run TestIntegration ./... backend_options: kubernetes: serviceAccountName: default