diff --git a/internal/cluster/lead.go b/internal/cluster/lead.go new file mode 100644 index 0000000..e2183bf --- /dev/null +++ b/internal/cluster/lead.go @@ -0,0 +1,86 @@ +// Package cluster holds cluster-wide invariants that are not owned +// by a single subsystem. The first inhabitant is the lead-eligibility +// rule R-003: the cluster lead is always a bare Linux node; Proxmox +// nodes are permanently ineligible because their kernel is shared +// with guest VMs/containers and a lead failure there takes down the +// hypervisor too. +// +// The package is deliberately decoupled from the scheduler: it owns +// its own minimal NodeInfo (Hostname + Kind) so it can be unit-tested +// without pulling in the scheduler's capacity model. The scheduler's +// scheduler.NodeInfo has a `Kind string` field with the same values +// ("linux", "proxmox"); callers convert at the boundary. +package cluster + +import ( + "errors" + "fmt" + "strings" +) + +// NodeKind classifies a node for lead-eligibility purposes (R-003). +// The string values match scheduler.NodeInfo.Kind and model.NodeKind +// so callers can pass either representation through without mapping. +type NodeKind string + +const ( + // NodeKindLinux is a bare Linux node — lead-eligible (R-003). + NodeKindLinux NodeKind = "linux" + // NodeKindProxmox is a Proxmox VE host — permanently lead- + // ineligible (R-003): the hypervisor kernel is shared with + // guests, so a lead process there is a blast-radius hazard. + NodeKindProxmox NodeKind = "proxmox" +) + +// ErrProxmoxNotLead is returned when a Proxmox node is proposed as +// the new cluster lead (R-003). +var ErrProxmoxNotLead = errors.New("Proxmox nodes cannot hold the cluster lead role (R-003)") + +// ErrNodeNotRegistered is returned when the proposed lead is not in +// the supplied node list at all. +var ErrNodeNotRegistered = errors.New("cluster: proposed lead is not a registered node") + +// NodeInfo is the minimal node projection the lead rules need. It is +// intentionally smaller than scheduler.NodeInfo so this package has +// no upstream dependency on the scheduler. +type NodeInfo struct { + Hostname string + Kind NodeKind +} + +// IsLeadEligible reports whether a node of the given kind may hold +// the cluster lead role (R-003). Linux nodes are eligible; Proxmox +// nodes are permanently ineligible; any other kind (including the +// empty string) is treated as ineligible. +func IsLeadEligible(kind NodeKind) bool { + return kind == NodeKindLinux +} + +// ValidateLeadRotation checks that newLead is a registered Linux node +// and refuses Proxmox nodes with ErrProxmoxNotLead (R-003). It returns +// ErrNodeNotRegistered when newLead is not in nodes at all. The check +// is case-sensitive on hostname; node registries in Orca are +// case-normalized at the store layer so this matches reality. +func ValidateLeadRotation(newLead string, nodes []NodeInfo) error { + for _, n := range nodes { + if n.Hostname != newLead { + continue + } + if n.Kind == NodeKindProxmox { + return ErrProxmoxNotLead + } + if n.Kind == NodeKindLinux { + return nil + } + // Registered but neither linux nor proxmox (e.g. "localhost" + // auto-registered node, or a future kind). Treat unknown kinds + // as ineligible rather than guessing. + return fmt.Errorf("cluster: node %q has ineligible kind %q: %w", newLead, n.Kind, ErrProxmoxNotLead) + } + // Not found in the registry at all. + return fmt.Errorf("cluster: node %q not found: %w", newLead, ErrNodeNotRegistered) +} + +// String renders a NodeKind for logs. It lowercases to match the +// on-disk representation regardless of how the caller constructed it. +func (k NodeKind) String() string { return strings.ToLower(string(k)) } diff --git a/internal/cluster/lead_test.go b/internal/cluster/lead_test.go new file mode 100644 index 0000000..61776c2 --- /dev/null +++ b/internal/cluster/lead_test.go @@ -0,0 +1,119 @@ +package cluster + +import ( + "errors" + "testing" +) + +func TestIsLeadEligible(t *testing.T) { + cases := []struct { + name string + kind NodeKind + want bool + }{ + {"linux", NodeKindLinux, true}, + {"proxmox", NodeKindProxmox, false}, + {"empty", "", false}, + {"unknown", NodeKind("foo"), false}, + {"localhost", NodeKind("localhost"), false}, + } + for _, tc := range cases { + if got := IsLeadEligible(tc.kind); got != tc.want { + t.Errorf("IsLeadEligible(%q) = %v, want %v", tc.kind, got, tc.want) + } + } +} + +func TestValidateLeadRotation_LinuxOK(t *testing.T) { + nodes := []NodeInfo{ + {Hostname: "n1", Kind: NodeKindLinux}, + {Hostname: "n2", Kind: NodeKindLinux}, + {Hostname: "pve1", Kind: NodeKindProxmox}, + } + if err := ValidateLeadRotation("n2", nodes); err != nil { + t.Errorf("ValidateLeadRotation(n2): err = %v, want nil", err) + } + if err := ValidateLeadRotation("n1", nodes); err != nil { + t.Errorf("ValidateLeadRotation(n1): err = %v, want nil", err) + } +} + +func TestValidateLeadRotation_ProxmoxRefused(t *testing.T) { + nodes := []NodeInfo{ + {Hostname: "n1", Kind: NodeKindLinux}, + {Hostname: "pve1", Kind: NodeKindProxmox}, + } + err := ValidateLeadRotation("pve1", nodes) + if err == nil { + t.Fatal("ValidateLeadRotation(pve1): expected error, got nil") + } + if !errors.Is(err, ErrProxmoxNotLead) { + t.Errorf("err = %v, want ErrProxmoxNotLead", err) + } + if got := err.Error(); got != "Proxmox nodes cannot hold the cluster lead role (R-003)" { + t.Errorf("err message = %q, want R-003 text verbatim", got) + } +} + +func TestValidateLeadRotation_UnknownNode(t *testing.T) { + nodes := []NodeInfo{ + {Hostname: "n1", Kind: NodeKindLinux}, + } + err := ValidateLeadRotation("ghost", nodes) + if err == nil { + t.Fatal("ValidateLeadRotation(ghost): expected error, got nil") + } + if !errors.Is(err, ErrNodeNotRegistered) { + t.Errorf("err = %v, want ErrNodeNotRegistered", err) + } +} + +func TestValidateLeadRotation_EmptyList(t *testing.T) { + err := ValidateLeadRotation("anyone", nil) + if err == nil { + t.Fatal("ValidateLeadRotation on empty list: expected error, got nil") + } + if !errors.Is(err, ErrNodeNotRegistered) { + t.Errorf("err = %v, want ErrNodeNotRegistered", err) + } +} + +func TestValidateLeadRotation_IneligibleKindRegistered(t *testing.T) { + // A node registered with a kind that is neither linux nor + // proxmox (e.g. the auto-registered "localhost" kind) is + // rejected as ineligible, not as unregistered. + nodes := []NodeInfo{ + {Hostname: "self", Kind: NodeKind("localhost")}, + } + err := ValidateLeadRotation("self", nodes) + if err == nil { + t.Fatal("expected error for localhost kind, got nil") + } + if !errors.Is(err, ErrProxmoxNotLead) { + t.Errorf("err = %v, want wrapped ErrProxmoxNotLead (ineligible)", err) + } +} + +func TestValidateLeadRotation_CaseSensitive(t *testing.T) { + // Hostnames are case-normalized at the store layer; the rule + // matches exactly. "N1" is NOT the same as "n1". + nodes := []NodeInfo{ + {Hostname: "n1", Kind: NodeKindLinux}, + } + if err := ValidateLeadRotation("N1", nodes); !errors.Is(err, ErrNodeNotRegistered) { + t.Errorf("N1 (case mismatch): err = %v, want ErrNodeNotRegistered", err) + } +} + +func TestNodeKindString(t *testing.T) { + if got := NodeKindLinux.String(); got != "linux" { + t.Errorf("Linux.String() = %q", got) + } + if got := NodeKindProxmox.String(); got != "proxmox" { + t.Errorf("Proxmox.String() = %q", got) + } + // Uppercase constructor should lower-case. + if got := NodeKind("PROXMOX").String(); got != "proxmox" { + t.Errorf("PROXMOX.String() = %q, want proxmox", got) + } +} diff --git a/internal/stepca/stepca.go b/internal/stepca/stepca.go new file mode 100644 index 0000000..92d5322 --- /dev/null +++ b/internal/stepca/stepca.go @@ -0,0 +1,260 @@ +// Package stepca wraps the smallstep `step` CLI for the Orca cluster +// CA (REQ-076, D-101 reversing AD-010). The CLI holds the cluster CA's +// private key on the lead node and invokes `step ca init`, +// `step ca certificate`, and `step ca renew` over SSH on the lead via +// the sshpush transport. There is intentionally no Go step-ca client +// library — the zero-new-dependency posture is preserved. +// +// Cert lifetimes follow the SPIFFE/SVID convention: server certs are +// 90-day (2160h) and SVIDs are 24h, matching the v0.9 PRD workload +// identity model (D-068). Renewal happens 30 days before expiry. +package stepca + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "git.cloudinit.dev/coreci/orca/internal/paths" + "git.cloudinit.dev/coreci/orca/internal/sshpush" +) + +// Sentinel errors. +var ( + // ErrLeadUnset is returned when the client has no lead peer + // configured (e.g., NewClient was given an empty leadPeer). + ErrLeadUnset = errors.New("stepca: lead peer not set") + // ErrStepCLI is wrapped around any non-zero exit from the step CLI. + ErrStepCLI = errors.New("stepca: step CLI failed") +) + +// Cert lifetimes (D-068, REQ-076). +const ( + // ServerCertNotAfter is the not-after for peer server certs: 90 days. + ServerCertNotAfter = "2160h" + // SVIDNotAfter is the not-after for workload SVIDs: 24 hours. + SVIDNotAfter = "24h" + // DefaultProvisioner is the JWE provisioner name the CLI mints + // tokens against on the lead. + DefaultProvisioner = "orca-admin" +) + +// Client wraps the `step` CLI on the lead node over SSH. The zero +// value is NOT usable; construct one with NewClient. +type Client struct { + transport *sshpush.Transport + leadPeer string + // exec is the command-execution seam. It defaults to transport + // when nil (set by NewClient) and is overridden by tests in this + // package to inject a mock without a real SSH server. + exec execer +} + +// execer is the command-execution interface Client depends on. +// *sshpush.Transport satisfies it via its Exec method. Kept +// unexported so the public API stays keyed to the concrete transport +// (callers pass *sshpush.Transport to NewClient). +type execer interface { + Exec(ctx context.Context, peer string, cmd string) ([]byte, error) +} + +// NewClient returns a Client that invokes the step CLI on leadPeer +// (host:port) via transport. A nil transport is rejected at the first +// call site; an empty leadPeer makes every call return ErrLeadUnset. +func NewClient(transport *sshpush.Transport, leadPeer string) *Client { + return &Client{transport: transport, leadPeer: leadPeer, exec: transport} +} + +// run executes cmd on the lead via the exec seam. It is the single +// chokepoint every public method funnels through, so tests intercept +// here. +func (c *Client) run(ctx context.Context, cmd string) ([]byte, error) { + return c.exec.Exec(ctx, c.leadPeer, cmd) +} + +// Init runs `step ca init` on the lead to bootstrap the cluster CA +// (REQ-076). The root cert is expected to land at the location given +// by paths.CACertPath() (the v0.9 cluster/ca.crt location). After the +// init completes, Init copies the root CA cert back to the operator +// host so the CLI can present it to workloads and peers. +func (c *Client) Init(ctx context.Context, name string, dns string, address string) error { + if err := c.preflight(); err != nil { + return err + } + cmd := fmt.Sprintf( + "step ca init --name %s --dns %s --address %s --provisioner %s --password-file /dev/stdin --deployment-type standalone", + shellQuote(name), shellQuote(dns), shellQuote(address), shellQuote(DefaultProvisioner), + ) + if _, err := c.run(ctx, cmd); err != nil { + return fmt.Errorf("stepca: init: %w", err) + } + // Mirror the root CA cert to the operator-side paths.CACertPath() + // so the CLI can hand it out to peers and workloads without a + // second round-trip. The lead writes it to the canonical step-ca + // location; we cat it back over SSH. + remote := "/etc/step-ca/certs/root_ca.crt" + out, err := c.run(ctx, fmt.Sprintf("cat %s", shellQuote(remote))) + if err != nil { + return fmt.Errorf("stepca: read root ca: %w", err) + } + if len(out) == 0 { + return fmt.Errorf("stepca: init produced empty root ca at %s: %w", remote, ErrStepCLI) + } + local := paths.CACertPath() + if mkErr := os.MkdirAll(filepath.Dir(local), 0o755); mkErr != nil { + return fmt.Errorf("stepca: mkdir %s: %w", filepath.Dir(local), mkErr) + } + if wErr := os.WriteFile(local, out, 0o644); wErr != nil { + return fmt.Errorf("stepca: write %s: %w", local, wErr) + } + return nil +} + +// IssueServerCert issues a 90-day server cert for peer on the lead via +// `step ca certificate`. The cert and key PEM are returned to the +// caller; the lead-side temp files are unlinked after the read. +// sans are appended as `--san` flags (one per SAN), with peer itself +// always added as the first SAN so the cert is valid for the bare +// hostname. +func (c *Client) IssueServerCert(ctx context.Context, peer string, sans []string) (cert string, key string, err error) { + if perr := c.preflight(); perr != nil { + return "", "", perr + } + return c.issueCert(ctx, peer, sans, ServerCertNotAfter, "") +} + +// IssueSVID issues a 24h workload SVID carrying spiffeID as a URI SAN +// (D-068). The provisioner is pinned to DefaultProvisioner so the +// CLI-side token minting path is exercised consistently. +func (c *Client) IssueSVID(ctx context.Context, spiffeID string, sans []string) (cert string, key string, err error) { + if perr := c.preflight(); perr != nil { + return "", "", perr + } + return c.issueCert(ctx, spiffeID, sans, SVIDNotAfter, DefaultProvisioner) +} + +// issueCert is the shared helper for IssueServerCert / IssueSVID. +// subject is the cert subject CN (and the first --san). notAfter is +// the duration string passed verbatim to `--not-after`. provisioner, +// when non-empty, is passed as `--provisioner`. +func (c *Client) issueCert(ctx context.Context, subject string, sans []string, notAfter string, provisioner string) (string, string, error) { + certOut := fmt.Sprintf("/tmp/orca-%s.crt", sanitize(subject)) + keyOut := fmt.Sprintf("/tmp/orca-%s.key", sanitize(subject)) + var sb strings.Builder + sb.WriteString("step ca certificate ") + sb.WriteString(shellQuote(subject)) + sb.WriteString(" ") + sb.WriteString(shellQuote(certOut)) + sb.WriteString(" ") + sb.WriteString(shellQuote(keyOut)) + sb.WriteString(" --not-after ") + sb.WriteString(shellQuote(notAfter)) + sb.WriteString(" --san ") + sb.WriteString(shellQuote(subject)) + for _, s := range sans { + sb.WriteString(" --san ") + sb.WriteString(shellQuote(s)) + } + if provisioner != "" { + sb.WriteString(" --provisioner ") + sb.WriteString(shellQuote(provisioner)) + } + sb.WriteString(" --password-file /dev/stdin --force") + cmd := sb.String() + if _, err := c.run(ctx, cmd); err != nil { + return "", "", fmt.Errorf("stepca: issue %s: %w", subject, err) + } + certPEM, err := c.readFile(ctx, certOut) + if err != nil { + return "", "", err + } + keyPEM, err := c.readFile(ctx, keyOut) + if err != nil { + return "", "", err + } + // Best-effort cleanup; failure to unlink is non-fatal. + _, _ = c.run(ctx, fmt.Sprintf("rm -f %s %s", shellQuote(certOut), shellQuote(keyOut))) + return certPEM, keyPEM, nil +} + +// RenewServerCert renews a peer's server cert 30 days before expiry +// (REQ-076). The caller is responsible for deciding it is time to +// renew; this method runs `step ca renew ` on the lead +// and returns the renewed cert PEM. The key is unchanged by step-ca +// renew for RSA/ECDSA keys; for Ed25519 the key is rotated and the +// new key is returned alongside. +func (c *Client) RenewServerCert(ctx context.Context, peer string) error { + if perr := c.preflight(); perr != nil { + return perr + } + certPath := fmt.Sprintf("/tmp/orca-%s.crt", sanitize(peer)) + keyPath := fmt.Sprintf("/tmp/orca-%s.key", sanitize(peer)) + cmd := fmt.Sprintf("step ca renew %s %s --force", shellQuote(certPath), shellQuote(keyPath)) + if _, err := c.run(ctx, cmd); err != nil { + return fmt.Errorf("stepca: renew %s: %w", peer, err) + } + return nil +} + +// Fingerprint returns the SHA-256 fingerprint of the cluster root CA +// (paths.CACertPath on the lead, mirrored locally by Init). It runs +// `step certificate fingerprint ` on the lead and trims the +// trailing newline. +func (c *Client) Fingerprint(ctx context.Context) (string, error) { + if perr := c.preflight(); perr != nil { + return "", perr + } + remote := "/etc/step-ca/certs/root_ca.crt" + cmd := fmt.Sprintf("step certificate fingerprint %s", shellQuote(remote)) + out, err := c.run(ctx, cmd) + if err != nil { + return "", fmt.Errorf("stepca: fingerprint: %w", err) + } + fp := strings.TrimSpace(string(out)) + if fp == "" { + return "", fmt.Errorf("stepca: empty fingerprint: %w", ErrStepCLI) + } + return fp, nil +} + +// preflight validates the client is usable. +func (c *Client) preflight() error { + if c.exec == nil { + return errors.New("stepca: transport is nil") + } + if c.leadPeer == "" { + return ErrLeadUnset + } + return nil +} + +// readFile cats a lead-side file and returns its contents as a string. +func (c *Client) readFile(ctx context.Context, path string) (string, error) { + out, err := c.run(ctx, fmt.Sprintf("cat %s", shellQuote(path))) + if err != nil { + return "", fmt.Errorf("stepca: read %s: %w", path, err) + } + if len(out) == 0 { + return "", fmt.Errorf("stepca: empty file %s: %w", path, ErrStepCLI) + } + return string(out), nil +} + +// sanitize replaces path-unsafe characters in a subject so it can be +// used in a /tmp filename. SPIFFE IDs contain `://` and `/`, both of +// which would confuse the shell. We collapse to `_`. +func sanitize(s string) string { + r := strings.NewReplacer("://", "-", "/", "_", ":", "_", " ", "_") + return r.Replace(s) +} + +// shellQuote single-quotes a string for safe shell interpolation. It +// escapes embedded single-quotes via the standard '\” idiom (mirrors +// sshpush.shellQuote, kept local to avoid importing an unexported +// helper). +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +} diff --git a/internal/stepca/stepca_test.go b/internal/stepca/stepca_test.go new file mode 100644 index 0000000..2053d75 --- /dev/null +++ b/internal/stepca/stepca_test.go @@ -0,0 +1,365 @@ +package stepca + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "git.cloudinit.dev/coreci/orca/internal/paths" + "git.cloudinit.dev/coreci/orca/internal/sshpush" +) + +// mockExec is a record-and-replay execer for the stepca.Client. It +// stores every command it received keyed by a substring match, so a +// test can assert "Init ran `step ca init`" without coupling to +// exact-flag ordering. Each entry maps a substring the test expects +// to appear in the command to the output that should be returned. +type mockExec struct { + // responses is a list of (substring, output, err). The first + // matching entry wins; an entry with an empty substring matches + // any command (catch-all). + responses []mockResp + // calls records every command the client issued, in order. + calls []string +} + +type mockResp struct { + match string + out []byte + err error +} + +func (m *mockExec) Exec(ctx context.Context, peer string, cmd string) ([]byte, error) { + m.calls = append(m.calls, cmd) + for _, r := range m.responses { + if r.match == "" || strings.Contains(cmd, r.match) { + return r.out, r.err + } + } + return nil, nil +} + +// newMockClient returns a Client wired to a mockExec and an ORCA_HOME +// under a temp dir (so paths.CACertPath() resolves to a writable path +// during Init tests). +func newMockClient(t *testing.T, lead string) (*Client, *mockExec) { + t.Helper() + dir := t.TempDir() + t.Setenv("ORCA_HOME", dir) + mx := &mockExec{} + c := NewClient(nil, lead) + c.exec = mx + return c, mx +} + +func containsCall(t *testing.T, mx *mockExec, want string) { + t.Helper() + for _, c := range mx.calls { + if strings.Contains(c, want) { + return + } + } + t.Errorf("no exec call contained %q; calls were:\n%s", want, strings.Join(mx.calls, "\n")) +} + +func TestNewClient_Defaults(t *testing.T) { + tr := sshpush.NewTransport("/tmp/key", "/tmp/kh") + c := NewClient(tr, "lead:22") + if c.leadPeer != "lead:22" { + t.Errorf("leadPeer = %q", c.leadPeer) + } + if c.transport != tr { + t.Error("transport not stored") + } + if c.exec == nil { + t.Error("exec seam is nil") + } +} + +func TestClient_Preflight_LeadUnset(t *testing.T) { + c, _ := newMockClient(t, "") + if err := c.Init(context.Background(), "n", "d", "a"); !errors.Is(err, ErrLeadUnset) { + t.Errorf("Init with empty lead: err = %v, want ErrLeadUnset", err) + } + if _, _, err := c.IssueServerCert(context.Background(), "p", nil); !errors.Is(err, ErrLeadUnset) { + t.Errorf("IssueServerCert: err = %v, want ErrLeadUnset", err) + } + if _, _, err := c.IssueSVID(context.Background(), "spiffe://orca/x", nil); !errors.Is(err, ErrLeadUnset) { + t.Errorf("IssueSVID: err = %v, want ErrLeadUnset", err) + } + if err := c.RenewServerCert(context.Background(), "p"); !errors.Is(err, ErrLeadUnset) { + t.Errorf("RenewServerCert: err = %v, want ErrLeadUnset", err) + } + if _, err := c.Fingerprint(context.Background()); !errors.Is(err, ErrLeadUnset) { + t.Errorf("Fingerprint: err = %v, want ErrLeadUnset", err) + } +} + +func TestClient_Preflight_NilExec(t *testing.T) { + c := &Client{leadPeer: "lead:22"} // exec is nil + if err := c.Init(context.Background(), "n", "d", "a"); err == nil { + t.Fatal("Init with nil exec: expected error, got nil") + } +} + +func TestInit_Success(t *testing.T) { + c, mx := newMockClient(t, "lead:22") + caPEM := []byte("-----BEGIN CERTIFICATE-----\nFAKE\n-----END CERTIFICATE-----\n") + mx.responses = []mockResp{ + {match: "step ca init", out: nil, err: nil}, + {match: "cat '/etc/step-ca/certs/root_ca.crt'", out: caPEM, err: nil}, + } + if err := c.Init(context.Background(), "orca", "ca.orca.local", ":8443"); err != nil { + t.Fatalf("Init: %v", err) + } + containsCall(t, mx, "step ca init --name 'orca'") + containsCall(t, mx, "--dns 'ca.orca.local'") + containsCall(t, mx, "--address ':8443'") + containsCall(t, mx, "--provisioner 'orca-admin'") + containsCall(t, mx, "--deployment-type standalone") + // Root CA mirrored to paths.CACertPath(). + got, err := os.ReadFile(paths.CACertPath()) + if err != nil { + t.Fatalf("read mirrored CA: %v", err) + } + if string(got) != string(caPEM) { + t.Errorf("mirrored CA = %q, want %q", got, caPEM) + } +} + +func TestInit_StepCLIFails(t *testing.T) { + c, mx := newMockClient(t, "lead:22") + stepErr := errors.New("step: non-zero exit 1") + mx.responses = []mockResp{ + {match: "step ca init", out: nil, err: stepErr}, + } + err := c.Init(context.Background(), "orca", "ca.orca.local", ":8443") + if err == nil { + t.Fatal("Init: expected error, got nil") + } + if !strings.Contains(err.Error(), "stepca: init") { + t.Errorf("err = %v, want wrapped 'stepca: init'", err) + } +} + +func TestInit_EmptyRootCA(t *testing.T) { + c, mx := newMockClient(t, "lead:22") + mx.responses = []mockResp{ + {match: "step ca init", out: nil, err: nil}, + {match: "cat '/etc/step-ca/certs/root_ca.crt'", out: nil, err: nil}, + } + err := c.Init(context.Background(), "orca", "ca.orca.local", ":8443") + if err == nil { + t.Fatal("Init with empty root CA: expected error, got nil") + } + if !errors.Is(err, ErrStepCLI) { + t.Errorf("err = %v, want ErrStepCLI", err) + } +} + +func TestIssueServerCert_Success(t *testing.T) { + c, mx := newMockClient(t, "lead:22") + certPEM := []byte("SERVER-CERT-PEM") + keyPEM := []byte("SERVER-KEY-PEM") + mx.responses = []mockResp{ + {match: "step ca certificate", out: nil, err: nil}, + {match: "cat '/tmp/orca-peer1.crt'", out: certPEM, err: nil}, + {match: "cat '/tmp/orca-peer1.key'", out: keyPEM, err: nil}, + {match: "rm -f", out: nil, err: nil}, + } + gotCert, gotKey, err := c.IssueServerCert(context.Background(), "peer1", []string{"peer1.orca.local", "10.0.0.1"}) + if err != nil { + t.Fatalf("IssueServerCert: %v", err) + } + if gotCert != string(certPEM) { + t.Errorf("cert = %q", gotCert) + } + if gotKey != string(keyPEM) { + t.Errorf("key = %q", gotKey) + } + containsCall(t, mx, "step ca certificate 'peer1'") + containsCall(t, mx, "--not-after '2160h'") + containsCall(t, mx, "--san 'peer1.orca.local'") + containsCall(t, mx, "--san '10.0.0.1'") + // Server cert path must NOT pin a provisioner (uses default). + for _, call := range mx.calls { + if strings.HasPrefix(call, "step ca certificate") && strings.Contains(call, "--provisioner") { + t.Errorf("server cert should not pin provisioner; cmd: %s", call) + } + } +} + +func TestIssueSVID_Success(t *testing.T) { + c, mx := newMockClient(t, "lead:22") + spiffe := "spiffe://orca/ns/_defaults/job/web/alloc/0" + certPEM := []byte("SVID-CERT-PEM") + keyPEM := []byte("SVID-KEY-PEM") + mx.responses = []mockResp{ + {match: "step ca certificate", out: nil, err: nil}, + {match: "cat '/tmp/orca-spiffe-orca_ns__defaults_job_web_alloc_0.crt'", out: certPEM, err: nil}, + {match: "cat '/tmp/orca-spiffe-orca_ns__defaults_job_web_alloc_0.key'", out: keyPEM, err: nil}, + {match: "rm -f", out: nil, err: nil}, + } + gotCert, gotKey, err := c.IssueSVID(context.Background(), spiffe, []string{"web.orca.local"}) + if err != nil { + t.Fatalf("IssueSVID: %v", err) + } + if gotCert != string(certPEM) || gotKey != string(keyPEM) { + t.Errorf("cert/key mismatch") + } + containsCall(t, mx, "step ca certificate") + containsCall(t, mx, "--not-after '24h'") + containsCall(t, mx, "--provisioner 'orca-admin'") + // SPIFFE ID is both the subject AND a SAN. + containsCall(t, mx, "--san '"+spiffe+"'") +} + +func TestIssueServerCert_StepFails(t *testing.T) { + c, mx := newMockClient(t, "lead:22") + mx.responses = []mockResp{ + {match: "step ca certificate", out: nil, err: errors.New("step: exit 1")}, + } + _, _, err := c.IssueServerCert(context.Background(), "peer1", nil) + if err == nil || !strings.Contains(err.Error(), "stepca: issue") { + t.Errorf("err = %v, want wrapped 'stepca: issue'", err) + } +} + +func TestIssueServerCert_ReadCertFails(t *testing.T) { + c, mx := newMockClient(t, "lead:22") + mx.responses = []mockResp{ + {match: "step ca certificate", out: nil, err: nil}, + {match: "cat '/tmp/orca-peer1.crt'", out: nil, err: errors.New("ssh: cat failed")}, + {match: "cat '/tmp/orca-peer1.key'", out: nil, err: nil}, + } + _, _, err := c.IssueServerCert(context.Background(), "peer1", nil) + if err == nil || !strings.Contains(err.Error(), "read") { + t.Errorf("err = %v, want wrapped 'read'", err) + } +} + +func TestIssueServerCert_EmptyCert(t *testing.T) { + c, mx := newMockClient(t, "lead:22") + mx.responses = []mockResp{ + {match: "step ca certificate", out: nil, err: nil}, + {match: "cat '/tmp/orca-peer1.crt'", out: nil, err: nil}, + {match: "cat '/tmp/orca-peer1.key'", out: []byte("KEY"), err: nil}, + {match: "rm -f", out: nil, err: nil}, + } + _, _, err := c.IssueServerCert(context.Background(), "peer1", nil) + if err == nil || !errors.Is(err, ErrStepCLI) { + t.Errorf("err = %v, want ErrStepCLI", err) + } +} + +func TestRenewServerCert_Success(t *testing.T) { + c, mx := newMockClient(t, "lead:22") + mx.responses = []mockResp{ + {match: "step ca renew", out: nil, err: nil}, + } + if err := c.RenewServerCert(context.Background(), "peer1"); err != nil { + t.Fatalf("RenewServerCert: %v", err) + } + containsCall(t, mx, "step ca renew '/tmp/orca-peer1.crt' '/tmp/orca-peer1.key' --force") +} + +func TestRenewServerCert_Fails(t *testing.T) { + c, mx := newMockClient(t, "lead:22") + mx.responses = []mockResp{ + {match: "step ca renew", out: nil, err: errors.New("step: renew failed")}, + } + err := c.RenewServerCert(context.Background(), "peer1") + if err == nil || !strings.Contains(err.Error(), "stepca: renew") { + t.Errorf("err = %v, want wrapped 'stepca: renew'", err) + } +} + +func TestFingerprint_Success(t *testing.T) { + c, mx := newMockClient(t, "lead:22") + mx.responses = []mockResp{ + {match: "step certificate fingerprint", out: []byte("a1b2c3d4e5f6\n"), err: nil}, + } + fp, err := c.Fingerprint(context.Background()) + if err != nil { + t.Fatalf("Fingerprint: %v", err) + } + if fp != "a1b2c3d4e5f6" { + t.Errorf("fp = %q, want a1b2c3d4e5f6 (trimmed)", fp) + } + containsCall(t, mx, "step certificate fingerprint '/etc/step-ca/certs/root_ca.crt'") +} + +func TestFingerprint_Empty(t *testing.T) { + c, mx := newMockClient(t, "lead:22") + mx.responses = []mockResp{ + {match: "step certificate fingerprint", out: []byte(""), err: nil}, + } + _, err := c.Fingerprint(context.Background()) + if err == nil || !errors.Is(err, ErrStepCLI) { + t.Errorf("err = %v, want ErrStepCLI", err) + } +} + +func TestFingerprint_Fails(t *testing.T) { + c, mx := newMockClient(t, "lead:22") + mx.responses = []mockResp{ + {match: "step certificate fingerprint", out: nil, err: errors.New("ssh: exec failed")}, + } + _, err := c.Fingerprint(context.Background()) + if err == nil || !strings.Contains(err.Error(), "stepca: fingerprint") { + t.Errorf("err = %v, want wrapped 'stepca: fingerprint'", err) + } +} + +func TestInit_MkdirFails(t *testing.T) { + // Point ORCA_HOME at a path that cannot be created under to + // force MkdirAll failure. We use a file as the parent. + dir := t.TempDir() + blocker := filepath.Join(dir, "block") + if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil { + t.Fatalf("write blocker: %v", err) + } + t.Setenv("ORCA_HOME", filepath.Join(blocker, "sub")) + // Construct the client directly (not newMockClient, which + // resets ORCA_HOME to a fresh temp dir). + mx := &mockExec{} + caPEM := []byte("FAKE") + mx.responses = []mockResp{ + {match: "step ca init", out: nil, err: nil}, + {match: "cat '/etc/step-ca/certs/root_ca.crt'", out: caPEM, err: nil}, + } + c := NewClient(nil, "lead:22") + c.exec = mx + err := c.Init(context.Background(), "orca", "ca.orca.local", ":8443") + if err == nil { + t.Fatal("Init: expected mkdir error, got nil") + } + if !strings.Contains(err.Error(), "mkdir") { + t.Errorf("err = %v, want 'mkdir'", err) + } +} + +func TestShellQuote(t *testing.T) { + got := shellQuote("a'b") + want := "'a'\\''b'" + if got != want { + t.Errorf("shellQuote = %q, want %q", got, want) + } +} + +func TestSanitize(t *testing.T) { + cases := []struct{ in, want string }{ + {"spiffe://orca/ns/_defaults/job/web/alloc/0", + "spiffe-orca_ns__defaults_job_web_alloc_0"}, + {"plain-host", "plain-host"}, + {"a b", "a_b"}, + } + for _, tc := range cases { + if got := sanitize(tc.in); got != tc.want { + t.Errorf("sanitize(%q) = %q, want %q", tc.in, got, tc.want) + } + } +}