Files
unkin-agent fde942d831
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/integration Pipeline was canceled
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-<os>-<arch> 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.
2026-09-19 12:13:39 +10:00

2.8 KiB

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.