Merge pull request 'Add Go implementation of sshsignhost' (#1) from benvin/scaffold-sshsignhost into main
ci/woodpecker/tag/release Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +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,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
|
||||
@@ -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
|
||||
@@ -0,0 +1,35 @@
|
||||
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.
|
||||
steps:
|
||||
- name: integration
|
||||
image: golang:1.25
|
||||
environment:
|
||||
VAULT_VERSION: 1.20.4
|
||||
VAULT_SHA256: fc5fb5d01d192f1216b139fb5c6af17e3af742aaeffc289fd861920ec55f2c9c
|
||||
SSHSIGNHOST_TEST_VAULT_ADDR: http://127.0.0.1:8200
|
||||
# Throwaway dev-mode server on loopback; this token grants nothing.
|
||||
VAULT_TOKEN: dev-root-token
|
||||
commands:
|
||||
# The golang image does not trust the estate's internal CA, so artifactapi
|
||||
# is fetched over http and the archive is pinned by checksum instead.
|
||||
- curl -fsSL -o /tmp/vault.zip "http://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/hashicorp-releases/vault/$${VAULT_VERSION}/vault_$${VAULT_VERSION}_linux_amd64.zip"
|
||||
- echo "$${VAULT_SHA256} /tmp/vault.zip" | sha256sum -c -
|
||||
- python3 -m zipfile -e /tmp/vault.zip /usr/local/bin/
|
||||
- chmod +x /usr/local/bin/vault
|
||||
# Dev mode serves plain HTTP; the suite polls /v1/sys/health rather than
|
||||
# sleeping, and the timeout keeps a wedged server from pinning an agent.
|
||||
- vault server -dev -dev-root-token-id="$${VAULT_TOKEN}" -dev-listen-address=127.0.0.1:8200 >/tmp/vault.log 2>&1 &
|
||||
- timeout 600 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
|
||||
@@ -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
|
||||
@@ -0,0 +1,85 @@
|
||||
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
|
||||
commands:
|
||||
- go test -race ./...
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: default
|
||||
resources:
|
||||
requests:
|
||||
memory: 512Mi
|
||||
cpu: 1
|
||||
limits:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
|
||||
# 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:
|
||||
- go install github.com/goreleaser/goreleaser/v2@$${GORELEASER_VERSION}
|
||||
- goreleaser release --clean
|
||||
depends_on: [test]
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: default
|
||||
resources:
|
||||
requests:
|
||||
memory: 512Mi
|
||||
cpu: 1
|
||||
limits:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
|
||||
# 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:
|
||||
- |
|
||||
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: [goreleaser]
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: default
|
||||
resources:
|
||||
requests:
|
||||
memory: 512Mi
|
||||
cpu: 1
|
||||
limits:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
@@ -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
|
||||
@@ -0,0 +1,63 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Project Overview
|
||||
|
||||
`sshsignhost` signs SSH host certificates with Vault. It is a drop-in
|
||||
replacement for the Python script Puppet used to render at
|
||||
`/opt/sshsignhost/sshsignhost`, and is invoked by
|
||||
`profiles::ssh::sign` (puppet-prod) through `generate()` at
|
||||
`/usr/local/bin/sshsignhost`.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
main.go # cobra command, flag surface, stdout rendering
|
||||
config.go # YAML config loading, defaults, validation
|
||||
vault.go # Vault HTTP client: approle/kubernetes login + sign
|
||||
main_test.go # end-to-end tests against an httptest TLS stand-in for Vault
|
||||
integration_test.go # -tags=integration: signs against a real dev-mode Vault
|
||||
Makefile # build / test / lint / integration-test / snapshot / version bump
|
||||
.goreleaser.yaml # binaries + RPM + Gitea release assets, one driver
|
||||
scripts/ # integration-test.sh (dev-mode Vault in a container)
|
||||
.woodpecker/ # build, test, pre-commit, integration (PR) + release (tag)
|
||||
```
|
||||
|
||||
## Contract with Puppet
|
||||
|
||||
- Flags use underscores (`--public_key`, `--valid_principals`), matching the
|
||||
argv the manifest builds.
|
||||
- `--valid_principals` is forwarded to Vault as the comma-separated string it
|
||||
was given; it is never split and rejoined.
|
||||
- `generate()` uses `failonfail: true, combine: true`: stderr is duped onto
|
||||
stdout at spawn. On success nothing may reach stderr, or `parsejson()` fails
|
||||
and the catalog compile breaks. `TestSuccessWritesNothingToStderr` runs the
|
||||
real binary in a subprocess to hold that line.
|
||||
- A 200 response without `data.signed_key` is a failure, never an empty result.
|
||||
|
||||
## Vault
|
||||
|
||||
- AppRole: `POST {addr}/v1/auth/{approle_path}/login` with `role_id` only; the
|
||||
Vault role sets `bind_secret_id: false`.
|
||||
- Kubernetes: `POST {addr}/v1/auth/{k8s_mount}/login` with `role` and the JWT
|
||||
read from `jwt_path`. There is no fallback between methods -- a rejected
|
||||
login or an unknown `auth_method` fails with the cause named.
|
||||
- Sign: `POST {addr}/v1/{mount_point}/sign/{role_name}` with `X-Vault-Token`
|
||||
and `cert_type: host`.
|
||||
|
||||
## Known blockers
|
||||
|
||||
The Vault ACL policy grants `sshca/sign/host`, but the only role that exists is
|
||||
`signhost`, so production signs 403 on `sshca/sign/signhost` even though auth
|
||||
succeeds. That is a terraform-vault fix, not a bug here -- do not change the
|
||||
requested path.
|
||||
|
||||
The `signhost` role sets `allowed_domains: main.unkin.net,consul` with
|
||||
`allow_bare_domains: false`, so the bare hostname and the IP the manifest sends
|
||||
alongside the FQDN are refused. `TestIntegrationRejectsPrincipalsOutsideAllowedDomains`
|
||||
pins that behaviour; fixing it is another terraform-vault change.
|
||||
|
||||
## TLS
|
||||
|
||||
`tls_skip_verify` defaults to `true`, preserving the Python helper's
|
||||
`verify=False` behaviour, and is a config key so it can be flipped without a
|
||||
code change once certificates are trusted everywhere.
|
||||
@@ -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 integration-test snapshot release-check 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) completions $(BINARY)
|
||||
|
||||
install:
|
||||
go install $(GOFLAGS) .
|
||||
|
||||
# Signs a host key against a throwaway dev-mode Vault; needs docker or podman.
|
||||
integration-test:
|
||||
./scripts/integration-test.sh
|
||||
|
||||
# Binaries, the RPM and the release assets all come out of goreleaser.
|
||||
snapshot:
|
||||
goreleaser release --snapshot --clean
|
||||
|
||||
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)
|
||||
_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)
|
||||
@@ -1,3 +1,75 @@
|
||||
# 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 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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
module git.unkin.net/unkin/sshsignhost
|
||||
|
||||
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
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
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=
|
||||
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=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,387 @@
|
||||
//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"
|
||||
}
|
||||
|
||||
// Every call is bounded so an unreachable server fails the suite instead of
|
||||
// hanging a CI step on a blackholed connect.
|
||||
var itClient = &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
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) {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("building health request: %v", err)
|
||||
}
|
||||
resp, err := itClient.Do(req)
|
||||
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 := itClient.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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
}
|
||||
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[@]}"
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user