67426b9e6e
Puppet's generate() splices stderr into stdout, so the Python helper's
error prints and its absent HTTP timeouts can corrupt a catalog compile or
hang it forever.
- sign SSH host certificates via Vault approle or kubernetes auth
- emit only {"signed_key": ...} on stdout, errors on stderr, non-zero exit
- forward valid_principals to Vault unsplit; bound every call by a timeout
- keep TLS verification skippable via tls_skip_verify (default true)
- package the binary and completions as an RPM; add PR and tag pipelines
500 lines
13 KiB
Go
500 lines
13 KiB
Go
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())
|
|
}
|
|
}
|