Drive releases with goreleaser and test against a real Vault
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

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.
This commit is contained in:
2026-09-19 12:13:22 +10:00
parent 67426b9e6e
commit fde942d831
13 changed files with 633 additions and 175 deletions
+1
View File
@@ -1,3 +1,4 @@
/sshsignhost
/sshsignhost-*
/completions/
dist/
+100
View File
@@ -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 <ben@unkin.net>
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:"
+41
View File
@@ -0,0 +1,41 @@
when:
- event: pull_request
# Kept out of test.yaml so the three required PR contexts (build/test/pre-commit)
# stay exactly what they were; this one needs a Vault next to it.
services:
- name: vault
image: hashicorp/vault:1.20
environment:
# Throwaway dev-mode server; this token grants nothing outside this pod.
VAULT_DEV_ROOT_TOKEN_ID: dev-root-token
VAULT_DEV_LISTEN_ADDRESS: 0.0.0.0:8200
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
steps:
- name: integration
image: golang:1.25
environment:
SSHSIGNHOST_TEST_VAULT_ADDR: http://vault:8200
VAULT_TOKEN: dev-root-token
commands:
- go test -tags=integration -count=1 -v -run TestIntegration ./...
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+25 -76
View File
@@ -1,6 +1,15 @@
when:
- event: tag
# goreleaser derives the version and changelog from git, so it needs the full
# history and every tag -- the default partial clone gives it neither.
clone:
- name: git
image: woodpeckerci/plugin-git
settings:
partial: false
tags: true
steps:
- name: test
image: golang:1.25
@@ -17,19 +26,17 @@ steps:
memory: 2Gi
cpu: 2
- name: build
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
# One driver for both artifact types: the RPM Puppet installs via dnf and the
# raw binaries the puppetserver-compiler initContainer fetches by URL.
- name: goreleaser
image: golang:1.26
environment:
GORELEASER_VERSION: v2.17.0
GITEA_TOKEN:
from_secret: RELEASER_TOKEN
commands:
- make build VERSION=${CI_COMMIT_TAG}
# $$ escapes shell vars so Woodpecker leaves them for the shell;
# ${CI_COMMIT_TAG} is a real Woodpecker var and stays single-$.
- |
for osarch in linux/amd64 linux/arm64; do
os="$${osarch%/*}"; arch="$${osarch#*/}"
GOOS="$$os" GOARCH="$$arch" \
go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" \
-o "sshsignhost-$${os}-$${arch}" .
done
- go install github.com/goreleaser/goreleaser/v2@$${GORELEASER_VERSION}
- goreleaser release --clean
depends_on: [test]
backend_options:
kubernetes:
@@ -42,24 +49,8 @@ steps:
memory: 2Gi
cpu: 2
- name: package
image: git.unkin.net/unkin/almalinux9-rpmbuilder:latest
commands:
- ./scripts/build-rpm.sh ${CI_COMMIT_TAG}
depends_on: [build]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
# Publish the RPM to the artifactapi local rpm repo (a real yum repo;
# repodata regenerates automatically).
# goreleaser cannot PUT to artifactapi, so the RPM it built is pushed to the
# local rpm repo (a real yum repo; repodata regenerates automatically).
- name: upload-rpm
image: git.unkin.net/unkin/almalinux9-base:20260606
commands:
@@ -81,56 +72,14 @@ steps:
-H "Content-Type: application/x-rpm" \
--data-binary @"$$rpm"
done
depends_on: [package]
depends_on: [goreleaser]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 128Mi
cpu: 100m
limits:
memory: 512Mi
cpu: 500m
- name: release
image: git.unkin.net/unkin/almalinux9-base:20260606
environment:
RELEASER_TOKEN:
from_secret: RELEASER_TOKEN
commands:
- |
curl --output /usr/local/bin/tea https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/gitea-dl/tea/0.12.0/tea-0.12.0-linux-amd64 && chmod +x /usr/local/bin/tea
tea logins add --name gitea --url https://git.unkin.net --token "$${RELEASER_TOKEN}" --no-version-check
CUR_SHA=$$(git rev-list -n1 "${CI_COMMIT_TAG}")
PREV_TAG=""
for t in $$(git tag --sort=-v:refname); do
[ "$$t" = "${CI_COMMIT_TAG}" ] && continue
[ "$$(git rev-list -n1 "$$t")" = "$$CUR_SHA" ] && continue
if git merge-base --is-ancestor "$$t" "${CI_COMMIT_TAG}" 2>/dev/null; then
PREV_TAG="$$t"; break
fi
done
if [ -n "$$PREV_TAG" ]; then
NOTES=$$(git log "$${PREV_TAG}..${CI_COMMIT_TAG}" --pretty=format:"- %s")
else
NOTES=$$(git log --pretty=format:"- %s")
fi
tea releases create --tag "${CI_COMMIT_TAG}" --title "${CI_COMMIT_TAG}" --note "$${NOTES}" --login gitea --repo "${CI_REPO}"
RPM=$$(ls dist/*.rpm 2>/dev/null | head -1)
ASSETS="sshsignhost-linux-amd64 sshsignhost-linux-arm64"
[ -n "$$RPM" ] && ASSETS="$$ASSETS $$RPM"
sha256sum $$ASSETS > sha256sums.txt
tea releases assets create "${CI_COMMIT_TAG}" $$ASSETS sha256sums.txt \
--login gitea --repo "${CI_REPO}"
depends_on: [upload-rpm]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 128Mi
cpu: 100m
cpu: 1
limits:
memory: 512Mi
cpu: 500m
memory: 2Gi
cpu: 2
+11 -5
View File
@@ -15,10 +15,11 @@ main.go # cobra command, flag surface, stdout rendering
config.go # YAML config loading, defaults, validation
vault.go # Vault HTTP client: approle/kubernetes login + sign
main_test.go # end-to-end tests against an httptest TLS stand-in for Vault
Makefile # build / test / lint / completions / rpm / version bump
packaging/ # nfpm spec for the RPM
scripts/ # build-rpm.sh (completions + nfpm)
.woodpecker/ # build, test, pre-commit (PR) + release (tag)
integration_test.go # -tags=integration: signs against a real dev-mode Vault
Makefile # build / test / lint / integration-test / snapshot / version bump
.goreleaser.yaml # binaries + RPM + Gitea release assets, one driver
scripts/ # integration-test.sh (dev-mode Vault in a container)
.woodpecker/ # build, test, pre-commit, integration (PR) + release (tag)
```
## Contract with Puppet
@@ -43,13 +44,18 @@ scripts/ # build-rpm.sh (completions + nfpm)
- Sign: `POST {addr}/v1/{mount_point}/sign/{role_name}` with `X-Vault-Token`
and `cert_type: host`.
## Known blocker
## Known blockers
The Vault ACL policy grants `sshca/sign/host`, but the only role that exists is
`signhost`, so production signs 403 on `sshca/sign/signhost` even though auth
succeeds. That is a terraform-vault fix, not a bug here -- do not change the
requested path.
The `signhost` role sets `allowed_domains: main.unkin.net,consul` with
`allow_bare_domains: false`, so the bare hostname and the IP the manifest sends
alongside the FQDN are refused. `TestIntegrationRejectsPrincipalsOutsideAllowedDomains`
pins that behaviour; fixing it is another terraform-vault change.
## TLS
`tls_skip_verify` defaults to `true`, preserving the Python helper's
+10 -10
View File
@@ -5,7 +5,7 @@ GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)"
OS ?= $(shell go env GOOS)
ARCH ?= $(shell go env GOARCH)
.PHONY: all build test lint fmt pre-commit clean install completions rpm rpm-package patch minor major _tag
.PHONY: all build test lint fmt pre-commit clean install integration-test snapshot release-check patch minor major _tag
all: build
@@ -25,21 +25,21 @@ pre-commit:
pre-commit run --all-files
clean:
rm -rf $(DIST) $(BINARY)
rm -rf $(DIST) completions $(BINARY)
install:
go install $(GOFLAGS) .
completions: build
@mkdir -p $(DIST)/completions
$(DIST)/$(BINARY) completion bash > $(DIST)/completions/$(BINARY).bash
$(DIST)/$(BINARY) completion zsh > $(DIST)/completions/_$(BINARY)
$(DIST)/$(BINARY) completion fish > $(DIST)/completions/$(BINARY).fish
# Signs a host key against a throwaway dev-mode Vault; needs docker or podman.
integration-test:
./scripts/integration-test.sh
rpm: build rpm-package
# Binaries, the RPM and the release assets all come out of goreleaser.
snapshot:
goreleaser release --snapshot --clean
rpm-package:
./scripts/build-rpm.sh $(VERSION)
release-check:
goreleaser check
# Bump helpers -- read the latest semver tag and create the next one.
_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1)
+9 -2
View File
@@ -62,7 +62,14 @@ hang a catalog compile forever.
## Build
```bash
make build # -> dist/sshsignhost
make build # -> dist/sshsignhost
make test
make rpm # RPM with bash/zsh/fish completions
make integration-test # signs a host key against a dev-mode Vault in a container
make snapshot # goreleaser: binaries, RPM and archives into dist/
```
Releases are driven entirely by goreleaser on a `v*` tag: the RPM
(`/usr/local/bin/sshsignhost` plus bash/zsh/fish completions) is pushed to the
artifactapi `rpm-internal` yum repo, and the raw
`sshsignhost-<os>-<arch>` binaries, tarballs and `checksums.txt` are attached to
the Gitea release for the puppetserver-compiler initContainer to fetch.
+2
View File
@@ -4,10 +4,12 @@ go 1.25.7
require (
github.com/spf13/cobra v1.10.2
golang.org/x/crypto v0.54.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/sys v0.47.0 // indirect
)
+6
View File
@@ -7,6 +7,12 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+379
View File
@@ -0,0 +1,379 @@
//go:build integration
package main
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"golang.org/x/crypto/ssh"
)
// These mirror terraform-vault's config/ssh_secret_backend/sshca.yaml and
// config/ssh_secret_backend_role/sshca/signhost.yaml so the tool is exercised
// against the same mount, role and constraints production serves.
const (
itMount = "sshca"
itRole = "signhost"
itPolicy = "sshsignhost"
itAppRoleMount = "approle"
itAllowedDomains = "main.unkin.net,consul"
itMaxLeaseTTL = "315360000s" // 87600h
itPuppetTTL = "87600h"
)
func itVaultAddr() string {
for _, key := range []string{"SSHSIGNHOST_TEST_VAULT_ADDR", "VAULT_ADDR"} {
if v := os.Getenv(key); v != "" {
return strings.TrimRight(v, "/")
}
}
return "http://127.0.0.1:8200"
}
func itVaultToken() string {
if v := os.Getenv("VAULT_TOKEN"); v != "" {
return v
}
return "root"
}
func itWaitForVault(t *testing.T) {
t.Helper()
deadline := time.Now().Add(90 * time.Second)
url := itVaultAddr() + "/v1/sys/health?standbyok=true"
var last error
for time.Now().Before(deadline) {
resp, err := http.Get(url) // #nosec G107
if err == nil {
_ = resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return
}
last = fmt.Errorf("HTTP %d", resp.StatusCode)
} else {
last = err
}
time.Sleep(250 * time.Millisecond)
}
t.Fatalf("vault at %s never became healthy: %v", itVaultAddr(), last)
}
func itVaultAPI(t *testing.T, method, path string, body any) map[string]any {
t.Helper()
var reader io.Reader
if body != nil {
encoded, err := json.Marshal(body)
if err != nil {
t.Fatalf("encoding %s %s: %v", method, path, err)
}
reader = bytes.NewReader(encoded)
}
req, err := http.NewRequest(method, itVaultAddr()+"/v1/"+path, reader)
if err != nil {
t.Fatalf("building %s %s: %v", method, path, err)
}
req.Header.Set("X-Vault-Token", itVaultToken())
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("reading %s %s: %v", method, path, err)
}
if resp.StatusCode >= 300 {
t.Fatalf("%s %s returned HTTP %d: %s", method, path, resp.StatusCode, raw)
}
parsed := map[string]any{}
if len(bytes.TrimSpace(raw)) > 0 {
if err := json.Unmarshal(raw, &parsed); err != nil {
t.Fatalf("decoding %s %s: %v (%s)", method, path, err, raw)
}
}
return parsed
}
// itSetupVault provisions the ssh CA mount, the host role, the sign policy and
// the approle the tool authenticates with, then returns the role_id. Every call
// is idempotent-by-overwrite so the test can be re-run against a live dev server.
func itSetupVault(t *testing.T) string {
t.Helper()
itWaitForVault(t)
mounts := itVaultAPI(t, http.MethodGet, "sys/mounts", nil)
if _, ok := mounts[itMount+"/"]; !ok {
itVaultAPI(t, http.MethodPost, "sys/mounts/"+itMount, map[string]any{
"type": "ssh",
"description": "SSH CA Engine",
"config": map[string]any{"max_lease_ttl": itMaxLeaseTTL},
})
itVaultAPI(t, http.MethodPost, itMount+"/config/ca", map[string]any{
"generate_signing_key": true,
"key_type": "ssh-rsa",
})
}
itVaultAPI(t, http.MethodPost, itMount+"/roles/"+itRole, map[string]any{
"key_type": "ca",
"algorithm_signer": "rsa-sha2-256",
"ttl": itMaxLeaseTTL,
"max_ttl": itMaxLeaseTTL,
"allow_host_certificates": true,
"allow_user_certificates": false,
"allowed_domains": itAllowedDomains,
"allow_subdomains": true,
"allow_bare_domains": false,
})
// The production policy grants sshca/sign/host while the only role that
// exists is signhost, which is why production 403s today. Here we grant the
// path the tool actually requests, proving the tool is correct once the
// policy is fixed.
itVaultAPI(t, http.MethodPut, "sys/policies/acl/"+itPolicy, map[string]any{
"policy": fmt.Sprintf("path %q {\n capabilities = [\"create\", \"update\"]\n}\n", itMount+"/sign/"+itRole),
})
auths := itVaultAPI(t, http.MethodGet, "sys/auth", nil)
if _, ok := auths[itAppRoleMount+"/"]; !ok {
itVaultAPI(t, http.MethodPost, "sys/auth/"+itAppRoleMount, map[string]any{"type": "approle"})
}
// bind_secret_id: false matches production; Vault demands another constraint
// alongside it, which production satisfies with token_bound_cidrs.
itVaultAPI(t, http.MethodPost, "auth/"+itAppRoleMount+"/role/"+itPolicy, map[string]any{
"bind_secret_id": false,
"token_bound_cidrs": []string{"0.0.0.0/0"},
"token_policies": []string{itPolicy},
"token_ttl": "60s",
"token_max_ttl": "120s",
})
roleIDResp := itVaultAPI(t, http.MethodGet, "auth/"+itAppRoleMount+"/role/"+itPolicy+"/role-id", nil)
data, ok := roleIDResp["data"].(map[string]any)
if !ok {
t.Fatalf("role-id response carried no data: %v", roleIDResp)
}
roleID, ok := data["role_id"].(string)
if !ok || roleID == "" {
t.Fatalf("role-id response carried no role_id: %v", data)
}
return roleID
}
func itBuildBinary(t *testing.T, dir string) string {
t.Helper()
if path := os.Getenv("SSHSIGNHOST_BIN"); path != "" {
return path
}
binary := filepath.Join(dir, "sshsignhost")
build := exec.Command("go", "build", "-o", binary, ".")
build.Env = append(os.Environ(), "CGO_ENABLED=0")
if out, err := build.CombinedOutput(); err != nil {
t.Fatalf("building binary: %v\n%s", err, out)
}
return binary
}
func itWriteConfig(t *testing.T, dir, roleID string) string {
t.Helper()
path := filepath.Join(dir, "config.yaml")
// The dev listener is plain HTTP, so tls_skip_verify never comes into play;
// the TLS path stays covered by the httptest suite in main_test.go.
body := fmt.Sprintf(`vault:
addr: %q
auth_method: approle
approle_path: %q
mount_point: %q
role_name: %q
role_id: %q
timeout: 30s
`, itVaultAddr(), itAppRoleMount, itMount, itRole, roleID)
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("writing config: %v", err)
}
return path
}
func itHostPublicKey(t *testing.T) (string, ssh.PublicKey) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generating host key: %v", err)
}
pub, err := ssh.NewPublicKey(&key.PublicKey)
if err != nil {
t.Fatalf("converting host key: %v", err)
}
return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub))), pub
}
// itRun invokes the built binary with exactly the argv profiles::ssh::sign
// builds through generate().
func itRun(t *testing.T, binary, config, principals, publicKey string) (string, string, error) {
t.Helper()
cmd := exec.Command(binary,
"--config", config,
"--valid_principals", principals,
"--ttl", itPuppetTTL,
"--public_key", publicKey,
"--json",
)
stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
cmd.Stdout, cmd.Stderr = stdout, stderr
err := cmd.Run()
return stdout.String(), stderr.String(), err
}
func TestIntegrationSignsHostCertificate(t *testing.T) {
roleID := itSetupVault(t)
dir := t.TempDir()
binary := itBuildBinary(t, dir)
config := itWriteConfig(t, dir, roleID)
publicKey, parsedPublicKey := itHostPublicKey(t)
// allowed_domains + allow_subdomains accepts these; see
// TestIntegrationRejectsPrincipalsOutsideAllowedDomains for the bare
// hostname/IP principals the Puppet manifest also sends today.
principals := "node1.main.unkin.net,node1.consul"
wantPrincipals := []string{"node1.consul", "node1.main.unkin.net"}
stdout, stderr, err := itRun(t, binary, config, principals, publicKey)
if err != nil {
t.Fatalf("signing failed: %v (stderr %q)", err, stderr)
}
if stderr != "" {
// generate() splices stderr onto stdout, so anything here breaks parsejson().
t.Fatalf("stderr = %q, want empty", stderr)
}
var out signedKeyOutput
if err := json.Unmarshal([]byte(stdout), &out); err != nil {
t.Fatalf("stdout %q is not JSON: %v", stdout, err)
}
if out.SignedKey == "" {
t.Fatal("signed_key is empty")
}
pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(out.SignedKey))
if err != nil {
t.Fatalf("signed_key does not parse as an SSH key: %v", err)
}
cert, ok := pub.(*ssh.Certificate)
if !ok {
t.Fatalf("signed_key is a %T, want *ssh.Certificate", pub)
}
if cert.CertType != ssh.HostCert {
t.Fatalf("cert type = %d, want host (%d)", cert.CertType, ssh.HostCert)
}
// Vault returns the principals sorted, not in the order they were sent.
gotPrincipals := slices.Clone(cert.ValidPrincipals)
slices.Sort(gotPrincipals)
if !slices.Equal(gotPrincipals, wantPrincipals) {
t.Fatalf("principals = %v, want %v", gotPrincipals, wantPrincipals)
}
if !bytes.Equal(cert.Key.Marshal(), parsedPublicKey.Marshal()) {
t.Fatal("certificate was issued over a different public key")
}
validity := time.Unix(int64(cert.ValidBefore), 0).Sub(time.Unix(int64(cert.ValidAfter), 0)) // #nosec G115
wantTTL, err := time.ParseDuration(itPuppetTTL)
if err != nil {
t.Fatalf("parsing ttl: %v", err)
}
if delta := validity - wantTTL; delta < -2*time.Hour || delta > 2*time.Hour {
t.Fatalf("validity = %s, want ~%s", validity, wantTTL)
}
}
// The Puppet manifest sends the bare hostname and the primary IP alongside the
// FQDN. The production role sets allow_bare_domains: false, so Vault refuses
// them -- a real gap in the estate's config, pinned here so it is not mistaken
// for a bug in this tool.
func TestIntegrationRejectsPrincipalsOutsideAllowedDomains(t *testing.T) {
roleID := itSetupVault(t)
dir := t.TempDir()
binary := itBuildBinary(t, dir)
config := itWriteConfig(t, dir, roleID)
publicKey, _ := itHostPublicKey(t)
stdout, stderr, err := itRun(t, binary, config, "node1,node1.main.unkin.net,198.18.25.5", publicKey)
if err == nil {
t.Fatalf("signing unexpectedly succeeded: %q", stdout)
}
if stdout != "" {
// A half-written JSON document would break the catalog compile.
t.Fatalf("stdout = %q, want empty", stdout)
}
if !strings.Contains(stderr, itMount+"/sign/"+itRole) {
t.Fatalf("stderr = %q, want the sign path named", stderr)
}
}
// Without the policy granting sshca/sign/signhost the tool must fail loudly,
// which is exactly what production does today against sshca/sign/host.
func TestIntegrationDeniedSignPathFailsCleanly(t *testing.T) {
itSetupVault(t)
denied := itPolicy + "-denied"
itVaultAPI(t, http.MethodPut, "sys/policies/acl/"+denied, map[string]any{
"policy": fmt.Sprintf("path %q {\n capabilities = [\"create\", \"update\"]\n}\n", itMount+"/sign/host"),
})
itVaultAPI(t, http.MethodPost, "auth/"+itAppRoleMount+"/role/"+denied, map[string]any{
"bind_secret_id": false,
"token_bound_cidrs": []string{"0.0.0.0/0"},
"token_policies": []string{denied},
"token_ttl": "60s",
"token_max_ttl": "120s",
})
resp := itVaultAPI(t, http.MethodGet, "auth/"+itAppRoleMount+"/role/"+denied+"/role-id", nil)
roleID, _ := resp["data"].(map[string]any)["role_id"].(string)
if roleID == "" {
t.Fatal("denied role carried no role_id")
}
dir := t.TempDir()
binary := itBuildBinary(t, dir)
config := itWriteConfig(t, dir, roleID)
publicKey, _ := itHostPublicKey(t)
stdout, stderr, err := itRun(t, binary, config, "node1.main.unkin.net", publicKey)
if err == nil {
t.Fatalf("signing unexpectedly succeeded: %q", stdout)
}
if stdout != "" {
t.Fatalf("stdout = %q, want empty", stdout)
}
if !strings.Contains(stderr, "403") {
t.Fatalf("stderr = %q, want an HTTP 403 named", stderr)
}
}
-40
View File
@@ -1,40 +0,0 @@
---
# nfpm config for building the sshsignhost RPM.
# Rendered through envsubst (see scripts/build-rpm.sh) then fed to `nfpm pkg`.
name: ${PACKAGE_NAME}
version: ${PACKAGE_VERSION}
release: ${PACKAGE_RELEASE}
arch: ${PACKAGE_ARCH}
platform: ${PACKAGE_PLATFORM}
section: default
priority: extra
description: "${PACKAGE_DESCRIPTION}"
maintainer: ${PACKAGE_MAINTAINER}
homepage: ${PACKAGE_HOMEPAGE}
license: ${PACKAGE_LICENSE}
disable_globbing: false
contents:
# Puppet's generate() calls the binary at this exact path.
- src: dist/sshsignhost
dst: /usr/local/bin/sshsignhost
file_info:
mode: 0755
owner: root
group: root
- src: dist/completions/sshsignhost.bash
dst: /usr/share/bash-completion/completions/sshsignhost
file_info:
mode: 0644
- src: dist/completions/_sshsignhost
dst: /usr/share/zsh/site-functions/_sshsignhost
file_info:
mode: 0644
- src: dist/completions/sshsignhost.fish
dst: /usr/share/fish/vendor_completions.d/sshsignhost.fish
file_info:
mode: 0644
-42
View File
@@ -1,42 +0,0 @@
#!/usr/bin/env bash
#
# Package the (already built) sshsignhost binary into an RPM with nfpm,
# bundling generated bash/zsh/fish shell completions.
# Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG)
#
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}"
VERSION="${VERSION#v}" # strip a leading v
BINARY="sshsignhost"
DIST="dist"
if [ ! -f "${DIST}/${BINARY}" ]; then
echo "ERROR: ${DIST}/${BINARY} not found; run 'make build' first" >&2
exit 1
fi
COMP_DIR="${DIST}/completions"
mkdir -p "${COMP_DIR}"
"./${DIST}/${BINARY}" completion bash >"${COMP_DIR}/${BINARY}.bash"
"./${DIST}/${BINARY}" completion zsh >"${COMP_DIR}/_${BINARY}"
"./${DIST}/${BINARY}" completion fish >"${COMP_DIR}/${BINARY}.fish"
export PACKAGE_NAME="${BINARY}"
export PACKAGE_VERSION="${VERSION}"
export PACKAGE_RELEASE="1"
export PACKAGE_ARCH="amd64"
export PACKAGE_PLATFORM="linux"
export PACKAGE_DESCRIPTION="Signs SSH host certificates with Vault; called by Puppet during catalog compilation"
export PACKAGE_MAINTAINER="Ben Vincent <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
+49
View File
@@ -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[@]}"