diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0494353 --- /dev/null +++ b/.gitignore @@ -0,0 +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/.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/integration.yaml b/.woodpecker/integration.yaml new file mode 100644 index 0000000..167558a --- /dev/null +++ b/.woodpecker/integration.yaml @@ -0,0 +1,35 @@ +when: + - event: pull_request + +# Kept out of test.yaml so the three required PR contexts (build/test/pre-commit) +# stay exactly what they were; this one needs a Vault next to it. +steps: + - name: integration + image: golang:1.25 + environment: + 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: + # 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 + 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..d0d6672 --- /dev/null +++ b/.woodpecker/release.yaml @@ -0,0 +1,85 @@ +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 + commands: + - go test -race ./... + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + + # 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: + - go install github.com/goreleaser/goreleaser/v2@$${GORELEASER_VERSION} + - goreleaser release --clean + depends_on: [test] + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + + # goreleaser cannot PUT to artifactapi, so the RPM it built 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: + - | + 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: [goreleaser] + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml new file mode 100644 index 0000000..5e179a7 --- /dev/null +++ b/.woodpecker/test.yaml @@ -0,0 +1,33 @@ +when: + - event: pull_request + +steps: + - name: lint + image: golangci/golangci-lint:latest + commands: + - golangci-lint run ./... + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + + - name: test + image: golang:1.25 + commands: + - go test -v -race ./... + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..dc3f29a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,63 @@ +# 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 +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 + +- 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 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 +`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..9ab7e2a --- /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 integration-test snapshot release-check 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) completions $(BINARY) + +install: + go install $(GOFLAGS) . + +# Signs a host key against a throwaway dev-mode Vault; needs docker or podman. +integration-test: + ./scripts/integration-test.sh + +# Binaries, the RPM and the release assets all come out of goreleaser. +snapshot: + goreleaser release --snapshot --clean + +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) +_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..f47b596 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,75 @@ # 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 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/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..1bff3ed --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module git.unkin.net/unkin/sshsignhost + +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 new file mode 100644 index 0000000..802d67e --- /dev/null +++ b/go.sum @@ -0,0 +1,19 @@ +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= +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= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 0000000..9524cf3 --- /dev/null +++ b/integration_test.go @@ -0,0 +1,387 @@ +//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" +} + +// 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() + + deadline := time.Now().Add(90 * time.Second) + url := itVaultAddr() + "/v1/sys/health?standbyok=true" + var last error + for time.Now().Before(deadline) { + 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 { + 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 := itClient.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/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/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[@]}" 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 +}