388 lines
12 KiB
Go
388 lines
12 KiB
Go
//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)
|
|
}
|
|
}
|