Add Go implementation of sshsignhost
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

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
This commit is contained in:
2026-09-19 10:35:00 +10:00
parent 304cd4e229
commit 67426b9e6e
17 changed files with 1425 additions and 1 deletions
+3
View File
@@ -0,0 +1,3 @@
/sshsignhost
/sshsignhost-*
dist/
+17
View File
@@ -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
+18
View File
@@ -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
+18
View File
@@ -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
+136
View File
@@ -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
+33
View File
@@ -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
+57
View File
@@ -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.
+64
View File
@@ -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)
+66 -1
View File
@@ -1,3 +1,68 @@
# sshsignhost
Vault-backed SSH host certificate signing helper for Puppet
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
```
+123
View File
@@ -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
}
+13
View File
@@ -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
)
+13
View File
@@ -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=
+99
View File
@@ -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)
}
}
+499
View File
@@ -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())
}
}
+40
View File
@@ -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
+42
View File
@@ -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 <ben@unkin.net>"
export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/sshsignhost"
export PACKAGE_LICENSE="MIT"
envsubst <packaging/nfpm.yaml >"${DIST}/nfpm.yaml"
nfpm pkg --config "${DIST}/nfpm.yaml" --target "${DIST}" --packager rpm
echo "Built:"
ls -1 "${DIST}"/*.rpm
+184
View File
@@ -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
}