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

64 lines
2.8 KiB
Markdown

# 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.