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.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
/sshsignhost
|
||||
/sshsignhost-*
|
||||
/completions/
|
||||
dist/
|
||||
|
||||
@@ -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:"
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+49
@@ -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[@]}"
|
||||
Reference in New Issue
Block a user