diff --git a/.gitignore b/.gitignore index d4200b0..0494353 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /sshsignhost /sshsignhost-* +/completions/ dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..3d7fda0 --- /dev/null +++ b/.goreleaser.yaml @@ -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 + 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:" diff --git a/.woodpecker/integration.yaml b/.woodpecker/integration.yaml new file mode 100644 index 0000000..0c95c91 --- /dev/null +++ b/.woodpecker/integration.yaml @@ -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 diff --git a/.woodpecker/release.yaml b/.woodpecker/release.yaml index 48251f1..d0d6672 100644 --- a/.woodpecker/release.yaml +++ b/.woodpecker/release.yaml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 2a6cbc2..dc3f29a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/Makefile b/Makefile index 5677fa8..9ab7e2a 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/README.md b/README.md index ce4b41b..f47b596 100644 --- a/README.md +++ b/README.md @@ -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--` binaries, tarballs and `checksums.txt` are attached to +the Gitea release for the puppetserver-compiler initContainer to fetch. diff --git a/go.mod b/go.mod index ceee82d..1bff3ed 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 47edb24..802d67e 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 0000000..48a5733 --- /dev/null +++ b/integration_test.go @@ -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) + } +} diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml deleted file mode 100644 index 78d31a1..0000000 --- a/packaging/nfpm.yaml +++ /dev/null @@ -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 diff --git a/scripts/build-rpm.sh b/scripts/build-rpm.sh deleted file mode 100755 index ce9eeca..0000000 --- a/scripts/build-rpm.sh +++ /dev/null @@ -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 " -export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/sshsignhost" -export PACKAGE_LICENSE="MIT" - -envsubst "${DIST}/nfpm.yaml" -nfpm pkg --config "${DIST}/nfpm.yaml" --target "${DIST}" --packager rpm - -echo "Built:" -ls -1 "${DIST}"/*.rpm diff --git a/scripts/integration-test.sh b/scripts/integration-test.sh new file mode 100755 index 0000000..f2fc8da --- /dev/null +++ b/scripts/integration-test.sh @@ -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[@]}"