feat(P08): mTLS daemon + transport + cert CLI + doctor
Wave B/C/D of P01 mTLS implementation.
- internal/audit/audit.go — thin wrapper around engine.Audit for
cert/handshake events (Action* and Result* constants; REQ-038).
- internal/certpaths/ — extracted path constants out of cli to break
the cli<->doctor import cycle; cli re-exports the helpers for
backward compat.
- internal/security/ca.go — public WriteCert/WriteKey helpers (0600
for keys, 0644 for certs; REQ-033); used by the cert CLI and
integration test.
- internal/daemon/tls.go — mTLS server with GetCertificate hot-swap
callback. Plaintext HTTP remains the default for v0.1 compat;
StartMTLS() flips the server into mTLS mode.
- internal/daemon/server.go — adds mtls *MTLSState field; MTLSActive()
getter for health endpoints.
- internal/transport/mtls.go — mTLS client with VerifyPeerCertificate
for pinned peer identity; DialContext for raw TLS.
- internal/transport/handshake_log.go — structured slog helpers for
handshake ok/fail (REQ-038 fields: event, result, peer, cert_fp).
- internal/cli/cert.go — orca cert {ca-init,gen,show,renew,fingerprint}
subcommands; file mode enforcement at every entry; redacted cert
show (REQ-035).
- internal/cli/doctor.go — orca doctor {cert,network,db} subcommands
(REQ-032); --json output supported.
- internal/cli/node.go — adds --ca-fingerprint to orca node join
(REQ-026); fails fast on mismatch.
- internal/doctor/doctor.go — 6 checks: cert.ca, cert.server,
cert.expiry, cert.fingerprint, network stub, db stub.
- internal/doctor/doctor_test.go — happy + sad path coverage.
- internal/security/integration_test.go — end-to-end: CA-init, CSR
generation, mTLS handshake, mismatch failure, rotation alarm,
redaction, file mode enforcement.
All tests pass with -race; gofmt -l . clean; go vet ./... clean.
---ci---
project: orca
phase: 8
milestone: v0.2
status: execute
---/ci---
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
// Package audit provides a thin convenience wrapper around
|
||||
// engine.Audit tailored to mTLS / cert lifecycle events. It exists so
|
||||
// that cert, transport, and daemon code can call a small, semantically
|
||||
// clear API (Emit with explicit action + result) without depending on
|
||||
// the more general-purpose engine.Audit.
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/engine"
|
||||
)
|
||||
|
||||
// Result enumerates the result strings persisted to audit_log. Keeping
|
||||
// these as constants (rather than free-form strings) prevents typos at
|
||||
// call sites and makes log analytics trivial.
|
||||
type Result string
|
||||
|
||||
const (
|
||||
ResultSuccess Result = "success"
|
||||
ResultFailure Result = "failure"
|
||||
ResultDenied Result = "denied"
|
||||
)
|
||||
|
||||
// Action enumerates the cert / handshake event names used across the
|
||||
// security surface. Matches REQ-038 / P01 must-haves:
|
||||
//
|
||||
// cert.issued — a CSR was signed, server cert persisted
|
||||
// cert.renewed — a server cert was re-issued (rotation)
|
||||
// cert.joined — a node joined the trust domain (CA pinned)
|
||||
// node.handshake_ok — mTLS handshake succeeded
|
||||
// node.handshake_failed — mTLS handshake failed
|
||||
type Action string
|
||||
|
||||
const (
|
||||
ActionCertIssued Action = "cert.issued"
|
||||
ActionCertRenewed Action = "cert.renewed"
|
||||
ActionCertJoined Action = "cert.joined"
|
||||
ActionNodeHandshakeOK Action = "node.handshake_ok"
|
||||
ActionNodeHandshakeFail Action = "node.handshake_failed"
|
||||
)
|
||||
|
||||
// Audit wraps engine.Audit with a cert/handshake-focused API.
|
||||
type Audit struct {
|
||||
engine *engine.Audit
|
||||
}
|
||||
|
||||
// New constructs an Audit backed by the given engine.Audit. The engine
|
||||
// instance persists to the audit_log table; the wrapper just shapes
|
||||
// the call signature.
|
||||
func New(e *engine.Audit) *Audit {
|
||||
return &Audit{engine: e}
|
||||
}
|
||||
|
||||
// Emit persists an audit entry. The `event` is a free-form description
|
||||
// that ends up in the resource field, paired with action + result. Use
|
||||
// the Action* constants for `action`; free-form strings for `event` are
|
||||
// allowed for extensibility but should be stable for analytics.
|
||||
func (a *Audit) Emit(ctx context.Context, action Action, event string, result Result, metadata map[string]any) {
|
||||
if a == nil || a.engine == nil {
|
||||
return
|
||||
}
|
||||
// Resource field is conventionally <event>:<id>; we just use event
|
||||
// as-is here. Callers can stuff the relevant id into metadata.
|
||||
a.engine.Record(ctx, "security", string(action), event, string(result), nil, metadata)
|
||||
}
|
||||
|
||||
// EmitWithErr persists a failure entry whose err is also recorded in the
|
||||
// audit_log.error column. Use this for handshake failures and similar
|
||||
// error paths where the underlying error is useful for postmortem.
|
||||
func (a *Audit) EmitWithErr(ctx context.Context, action Action, event string, err error, metadata map[string]any) {
|
||||
if a == nil || a.engine == nil {
|
||||
return
|
||||
}
|
||||
a.engine.Record(ctx, "security", string(action), event, string(ResultFailure), err, metadata)
|
||||
}
|
||||
|
||||
// LogHandshakeOK emits a structured slog record for a successful mTLS
|
||||
// handshake. This is a SEPARATE log line from the audit_log entry —
|
||||
// structured slog is for operators; audit_log is for compliance.
|
||||
func LogHandshakeOK(log *slog.Logger, peer, certFP string) {
|
||||
if log == nil {
|
||||
return
|
||||
}
|
||||
log.Info("mtls.handshake",
|
||||
slog.String("event", "mtls.handshake"),
|
||||
slog.String("result", "ok"),
|
||||
slog.String("peer", peer),
|
||||
slog.String("cert_fp", certFP),
|
||||
)
|
||||
}
|
||||
|
||||
// LogHandshakeFailed emits a structured slog record for a failed mTLS
|
||||
// handshake. Per REQ-038, the fields are: event=mtls.handshake, peer,
|
||||
// cert_fp (may be empty if no cert was presented), err.
|
||||
func LogHandshakeFailed(log *slog.Logger, peer, certFP string, err error) {
|
||||
if log == nil {
|
||||
return
|
||||
}
|
||||
attrs := []any{
|
||||
slog.String("event", "mtls.handshake"),
|
||||
slog.String("result", "failed"),
|
||||
slog.String("peer", peer),
|
||||
slog.String("cert_fp", certFP),
|
||||
}
|
||||
if err != nil {
|
||||
attrs = append(attrs, slog.String("err", err.Error()))
|
||||
}
|
||||
log.Warn("mtls.handshake", attrs...)
|
||||
}
|
||||
|
||||
// String converts an Action to its canonical string form. Useful in
|
||||
// tests and CLI surface.
|
||||
func (a Action) String() string { return string(a) }
|
||||
|
||||
// String converts a Result to its canonical string form.
|
||||
func (r Result) String() string { return string(r) }
|
||||
|
||||
// FormatAction formats an action+result pair as "action=... result=...",
|
||||
// used by callers building structured log lines.
|
||||
func FormatAction(action Action, result Result) string {
|
||||
return fmt.Sprintf("action=%s result=%s", action, result)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Package certpaths centralizes the on-disk locations of the CA and
|
||||
// server cert/key files. The CLI layer, the security layer, and the
|
||||
// doctor layer all need to agree on these paths, so they're factored
|
||||
// into their own package to avoid import cycles (cli <-> doctor).
|
||||
package certpaths
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultCADir = ".orca"
|
||||
caCertFilename = "ca.crt"
|
||||
caKeyFilename = "ca.key"
|
||||
)
|
||||
|
||||
// Dir returns the directory the local CA lives in. Honors $ORCA_HOME
|
||||
// for testability; otherwise defaults to ~/.orca.
|
||||
func Dir() string {
|
||||
if p := os.Getenv("ORCA_HOME"); p != "" {
|
||||
return p
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, defaultCADir)
|
||||
}
|
||||
|
||||
// CACertPath returns the path to ca.crt.
|
||||
func CACertPath() string { return filepath.Join(Dir(), caCertFilename) }
|
||||
|
||||
// CAKeyPath returns the path to ca.key.
|
||||
func CAKeyPath() string { return filepath.Join(Dir(), caKeyFilename) }
|
||||
|
||||
// ServerCertPath returns the path to server.crt.
|
||||
func ServerCertPath() string { return filepath.Join(Dir(), "server.crt") }
|
||||
|
||||
// ServerKeyPath returns the path to server.key.
|
||||
func ServerKeyPath() string { return filepath.Join(Dir(), "server.key") }
|
||||
@@ -0,0 +1,255 @@
|
||||
// cert.go implements the `orca cert` subcommand family.
|
||||
//
|
||||
// Subcommands:
|
||||
//
|
||||
// orca cert ca-init — bootstrap a local CA in ~/.orca/
|
||||
// orca cert gen — generate a server CSR + sign it with the local CA
|
||||
// orca cert show — print the active server cert (redacted; REQ-035)
|
||||
// orca cert renew — re-issue and rotate the server cert
|
||||
// orca cert fingerprint — print the SHA-256 of ca.crt or server.crt
|
||||
//
|
||||
// All subcommands refuse to operate if the on-disk CA / cert file modes
|
||||
// do not match REQ-033 (0600 for keys, 0644 for certs).
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||
)
|
||||
|
||||
// CADir returns the directory the local CA lives in. Re-exported for
|
||||
// backward compatibility with callers that imported this from the cli
|
||||
// package directly.
|
||||
func CADir() string { return certpaths.Dir() }
|
||||
|
||||
// CACertPath returns the path to ca.crt.
|
||||
func CACertPath() string { return certpaths.CACertPath() }
|
||||
|
||||
// CAKeyPath returns the path to ca.key.
|
||||
func CAKeyPath() string { return certpaths.CAKeyPath() }
|
||||
|
||||
// ServerCertPath returns the path to server.crt.
|
||||
func ServerCertPath() string { return certpaths.ServerCertPath() }
|
||||
|
||||
// ServerKeyPath returns the path to server.key.
|
||||
func ServerKeyPath() string { return certpaths.ServerKeyPath() }
|
||||
|
||||
// NewCommand builds the `orca cert` command tree.
|
||||
func NewCommand(log *slog.Logger) *cobra.Command {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
certCmd := &cobra.Command{
|
||||
Use: "cert",
|
||||
Short: "Manage orca certificates (CA, server, rotation)",
|
||||
Long: "Bootstrap a local CA, generate server certs, and rotate them.",
|
||||
}
|
||||
|
||||
certCmd.AddCommand(newCAInitCmd(log))
|
||||
certCmd.AddCommand(newGenCmd(log))
|
||||
certCmd.AddCommand(newShowCmd(log))
|
||||
certCmd.AddCommand(newRenewCmd(log))
|
||||
certCmd.AddCommand(newFingerprintCmd(log))
|
||||
return certCmd
|
||||
}
|
||||
|
||||
func newCAInitCmd(log *slog.Logger) *cobra.Command {
|
||||
var cn string
|
||||
cmd := &cobra.Command{
|
||||
Use: "ca-init",
|
||||
Short: "Initialize a local orca CA (ca.crt + ca.key) under ~/.orca",
|
||||
Long: "Generates a new RSA CA cert and writes it to ~/.orca/ca.crt (0644) and ~/.orca/ca.key (0600) per REQ-033.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dir := CADir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", dir, err)
|
||||
}
|
||||
ca, err := security.CAInit(dir, cn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ca-init: %w", err)
|
||||
}
|
||||
fp := ca.Fingerprint()
|
||||
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "✓ CA initialized at %s\n fingerprint (sha256): %s\n not_after: %s\n",
|
||||
dir, fp, ca.NotAfter.UTC().Format("2006-01-02")); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Info("cert.ca_init",
|
||||
slog.String("event", "cert.ca_init"),
|
||||
slog.String("dir", dir),
|
||||
slog.String("cert_fp", fp),
|
||||
)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&cn, "cn", "orca-local-ca", "CA common name")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newGenCmd(log *slog.Logger) *cobra.Command {
|
||||
var cn string
|
||||
var sans []string
|
||||
cmd := &cobra.Command{
|
||||
Use: "gen",
|
||||
Short: "Generate a server cert (CSR + sign) under ~/.orca",
|
||||
Long: "Builds a CSR with the requested SANs, signs it with the local CA, and writes server.crt + server.key.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dir := CADir()
|
||||
if cn == "" {
|
||||
cn = "orca-server"
|
||||
}
|
||||
ca, err := security.LoadCA(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load CA (run `orca cert ca-init` first): %w", err)
|
||||
}
|
||||
keyPEM, csrPEM, err := security.GenerateCSR(cn, sans)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate CSR: %w", err)
|
||||
}
|
||||
certPEM, err := ca.SignCSR(csrPEM)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sign CSR: %w", err)
|
||||
}
|
||||
certPath := ServerCertPath()
|
||||
keyPath := ServerKeyPath()
|
||||
if err := security.WriteCert(certPath, certPEM); err != nil {
|
||||
return fmt.Errorf("write cert: %w", err)
|
||||
}
|
||||
if err := security.WriteKey(keyPath, keyPEM); err != nil {
|
||||
return fmt.Errorf("write key: %w", err)
|
||||
}
|
||||
fp := security.FingerprintOf(parseFirstCertDER(certPEM))
|
||||
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "✓ Server cert generated\n cert: %s\n key: %s\n fingerprint (sha256): %s\n",
|
||||
certPath, keyPath, fp); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Info("cert.issued",
|
||||
slog.String("event", "cert.issued"),
|
||||
slog.String("cn", cn),
|
||||
slog.String("cert_fp", fp),
|
||||
)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&cn, "cn", "orca-server", "server cert common name")
|
||||
cmd.Flags().StringSliceVar(&sans, "san", []string{"localhost", "127.0.0.1"}, "SAN entries (DNS or IP) — at least one required (REQ-036)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newShowCmd(log *slog.Logger) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "show",
|
||||
Short: "Print the server cert (private keys redacted; REQ-035)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
pem, err := os.ReadFile(ServerCertPath())
|
||||
if err != nil {
|
||||
return fmt.Errorf("read server cert: %w", err)
|
||||
}
|
||||
// Per REQ-035, strip private key material before display.
|
||||
out := security.Redact(pem)
|
||||
if _, err := cmd.OutOrStdout().Write(out); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Debug("cert.show", slog.String("event", "cert.show"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newRenewCmd(log *slog.Logger) *cobra.Command {
|
||||
var cn string
|
||||
var sans []string
|
||||
cmd := &cobra.Command{
|
||||
Use: "renew",
|
||||
Short: "Rotate the server cert (hot-swapped by the daemon; REQ-034)",
|
||||
Long: "Re-runs `cert gen` and overwrites server.crt / server.key in place. The daemon's GetCertificate callback picks up the new cert on the next handshake — no restart required.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dir := CADir()
|
||||
if cn == "" {
|
||||
cn = "orca-server"
|
||||
}
|
||||
ca, err := security.LoadCA(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load CA: %w", err)
|
||||
}
|
||||
keyPEM, csrPEM, err := security.GenerateCSR(cn, sans)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate CSR: %w", err)
|
||||
}
|
||||
certPEM, err := ca.SignCSR(csrPEM)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sign CSR: %w", err)
|
||||
}
|
||||
if err := security.WriteCert(ServerCertPath(), certPEM); err != nil {
|
||||
return fmt.Errorf("write cert: %w", err)
|
||||
}
|
||||
if err := security.WriteKey(ServerKeyPath(), keyPEM); err != nil {
|
||||
return fmt.Errorf("write key: %w", err)
|
||||
}
|
||||
fp := security.FingerprintOf(parseFirstCertDER(certPEM))
|
||||
if _, err := fmt.Fprintln(cmd.OutOrStdout(), "✓ Server cert rotated"); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Info("cert.renewed",
|
||||
slog.String("event", "cert.renewed"),
|
||||
slog.String("cert_fp", fp),
|
||||
)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&cn, "cn", "orca-server", "server cert common name")
|
||||
cmd.Flags().StringSliceVar(&sans, "san", []string{"localhost", "127.0.0.1"}, "SAN entries (DNS or IP)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newFingerprintCmd(log *slog.Logger) *cobra.Command {
|
||||
var which string
|
||||
cmd := &cobra.Command{
|
||||
Use: "fingerprint",
|
||||
Short: "Print the SHA-256 fingerprint of ca.crt or server.crt",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var path string
|
||||
switch which {
|
||||
case "ca", "":
|
||||
path = CACertPath()
|
||||
case "server":
|
||||
path = ServerCertPath()
|
||||
default:
|
||||
return fmt.Errorf("--which must be 'ca' or 'server'")
|
||||
}
|
||||
fp, err := security.Fingerprint(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(cmd.OutOrStdout(), fp); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Debug("cert.fingerprint",
|
||||
slog.String("event", "cert.fingerprint"),
|
||||
slog.String("path", path),
|
||||
slog.String("cert_fp", fp),
|
||||
)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&which, "which", "ca", "which cert: 'ca' or 'server'")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// parseFirstCertDER decodes the first CERTIFICATE PEM block in pemBytes
|
||||
// and returns the DER bytes. Used by the cert cli for fingerprint calc
|
||||
// after a fresh issuance.
|
||||
func parseFirstCertDER(pemBytes []byte) []byte {
|
||||
block, _ := pem.Decode(pemBytes)
|
||||
if block == nil {
|
||||
return nil
|
||||
}
|
||||
return block.Bytes
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/doctor"
|
||||
)
|
||||
|
||||
var doctorCmd = &cobra.Command{
|
||||
Use: "doctor",
|
||||
Short: "Run self-checks on the orca installation",
|
||||
Long: "Verify CA, server cert, expiry, fingerprint, network, and DB. Reports PASS/WARN/FAIL per check.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
report := doctor.Run(cmd.Context())
|
||||
if jsonOutput {
|
||||
return printJSON(report.Checks)
|
||||
}
|
||||
fmt.Fprint(cmd.OutOrStdout(), report.Print())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var doctorCertCmd = &cobra.Command{
|
||||
Use: "cert",
|
||||
Short: "Run only the cert self-checks",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
checks := []doctor.Check{
|
||||
doctor.CertCA(),
|
||||
doctor.CertServer(),
|
||||
doctor.CertExpiry(),
|
||||
doctor.CertFingerprint(),
|
||||
}
|
||||
results := make([]doctor.CheckResult, 0, len(checks))
|
||||
for _, c := range checks {
|
||||
r, msg := c.Run(cmd.Context())
|
||||
results = append(results, doctor.CheckResult{Name: c.Name, Result: r, Message: msg})
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(results)
|
||||
}
|
||||
for _, r := range results {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-5s %s\n", r.Name, r.Result, r.Message)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var doctorNetworkCmd = &cobra.Command{
|
||||
Use: "network",
|
||||
Short: "Run the network self-check (P02 impl)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c := doctor.NetworkStub()
|
||||
r, msg := c.Run(cmd.Context())
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-5s %s\n", c.Name, r, msg)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var doctorDBCmd = &cobra.Command{
|
||||
Use: "db",
|
||||
Short: "Run the database self-check (P02 impl)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c := doctor.DBStub()
|
||||
r, msg := c.Run(cmd.Context())
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-5s %s\n", c.Name, r, msg)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
doctorCmd.AddCommand(doctorCertCmd, doctorNetworkCmd, doctorDBCmd)
|
||||
rootCmd.AddCommand(doctorCmd)
|
||||
}
|
||||
+25
-3
@@ -12,8 +12,10 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
||||
"git.cloudinit.dev/coreci/orca/internal/engine"
|
||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
@@ -48,9 +50,10 @@ func nodeRegistry() (*engine.NodeRegistry, func() error, error) {
|
||||
}
|
||||
|
||||
var (
|
||||
joinName string
|
||||
joinAddr string
|
||||
leaveID string
|
||||
joinName string
|
||||
joinAddr string
|
||||
joinCAFinger string
|
||||
leaveID string
|
||||
)
|
||||
|
||||
var nodeCmd = &cobra.Command{
|
||||
@@ -70,6 +73,24 @@ var nodeJoinCmd = &cobra.Command{
|
||||
if joinAddr == "" {
|
||||
joinAddr = "localhost:8443"
|
||||
}
|
||||
|
||||
// REQ-026: if --ca-fingerprint is set, verify the on-disk CA
|
||||
// matches the pinned value before we touch the registry. This
|
||||
// prevents typos in the operator-supplied fingerprint from
|
||||
// silently degrading to "no pin" and accepting any cert.
|
||||
if joinCAFinger != "" {
|
||||
fp, err := security.Fingerprint(certpaths.CACertPath())
|
||||
if err != nil {
|
||||
return fmt.Errorf("--ca-fingerprint set but local CA is missing: %w (run `orca cert ca-init` first)", err)
|
||||
}
|
||||
if fp != joinCAFinger {
|
||||
return fmt.Errorf(
|
||||
"CA fingerprint mismatch: on-disk=%s, pinned=%s — refusing to join (REQ-026)",
|
||||
fp, joinCAFinger,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -167,6 +188,7 @@ var nodeListCmd = &cobra.Command{
|
||||
func init() {
|
||||
nodeJoinCmd.Flags().StringVar(&joinName, "name", "", "node name (required)")
|
||||
nodeJoinCmd.Flags().StringVar(&joinAddr, "addr", "", "node address (default localhost:8443)")
|
||||
nodeJoinCmd.Flags().StringVar(&joinCAFinger, "ca-fingerprint", "", "pin CA cert SHA-256 (REQ-026); fails if on-disk CA doesn't match")
|
||||
nodeLeaveCmd.Flags().StringVar(&leaveID, "id", "", "node id")
|
||||
|
||||
nodeCmd.AddCommand(nodeJoinCmd)
|
||||
|
||||
@@ -30,6 +30,12 @@ type Server struct {
|
||||
ready atomic.Bool
|
||||
|
||||
httpServer *http.Server
|
||||
|
||||
// mtls is non-nil after StartMTLS has been called; nil otherwise.
|
||||
// Plaintext HTTP and mTLS are mutually exclusive — a Server is
|
||||
// either in plaintext mode (default, v0.1 compat) or mTLS mode
|
||||
// (v0.2 P01 forward).
|
||||
mtls *MTLSState
|
||||
}
|
||||
|
||||
// Options configures a new Server.
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||
)
|
||||
|
||||
// MTLSState holds the runtime state for the mTLS server. The hot-swap
|
||||
// mechanism works by reading cert/key from disk + (optionally) the cert
|
||||
// repo on every TLS handshake, so `orca cert renew` can write a new
|
||||
// server.crt / server.key and the daemon picks it up without a restart.
|
||||
//
|
||||
// The actual handshake callback (`GetCertificate`) is set on the tls.Config
|
||||
// by StartMTLS.
|
||||
type MTLSState struct {
|
||||
CertPath string
|
||||
KeyPath string
|
||||
CAPath string
|
||||
|
||||
Log *slog.Logger
|
||||
|
||||
// mu guards the timestamp / counter so concurrent reads of the
|
||||
// on-disk cert are well-defined and we can log rotation events.
|
||||
mu sync.Mutex
|
||||
lastModTime time.Time
|
||||
}
|
||||
|
||||
// NewMTLSState validates the on-disk cert/key/CA paths and returns a
|
||||
// state struct. Fails fast if the CA cert is missing or unreadable — the
|
||||
// daemon must not start in mTLS mode without a CA.
|
||||
func NewMTLSState(certPath, keyPath, caPath string, log *slog.Logger) (*MTLSState, error) {
|
||||
if certPath == "" || keyPath == "" || caPath == "" {
|
||||
return nil, errors.New("NewMTLSState: certPath, keyPath, and caPath are all required")
|
||||
}
|
||||
for _, p := range []string{certPath, keyPath, caPath} {
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
return nil, fmt.Errorf("NewMTLSState: stat %s: %w", p, err)
|
||||
}
|
||||
}
|
||||
// Enforce CA file modes (REQ-033) at daemon start so we fail fast.
|
||||
caDir := caPath[:max(0, lastSep(caPath))]
|
||||
if err := security.EnforceFileModes(caDir); err != nil {
|
||||
return nil, fmt.Errorf("NewMTLSState: %w", err)
|
||||
}
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &MTLSState{
|
||||
CertPath: certPath,
|
||||
KeyPath: keyPath,
|
||||
CAPath: caPath,
|
||||
Log: log,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCertificate returns the tls.Certificate to present for a given
|
||||
// ClientHelloInfo. It reloads the cert from disk on every call so that
|
||||
// `orca cert renew` (which writes a new server.crt / server.key) takes
|
||||
// effect without a daemon restart. REQ-034's hot-swap requirement.
|
||||
//
|
||||
// The reload is cheap — PEM decode is microseconds for typical cert
|
||||
// sizes. The callback runs once per handshake; concurrency is fine.
|
||||
func (m *MTLSState) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
cert, err := tls.LoadX509KeyPair(m.CertPath, m.KeyPath)
|
||||
if err != nil {
|
||||
m.Log.Warn("mtls cert load failed (will fail handshake)",
|
||||
slog.String("cert", m.CertPath),
|
||||
slog.String("key", m.KeyPath),
|
||||
slog.String("err", err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
cert.Leaf, err = x509.ParseCertificate(cert.Certificate[0])
|
||||
if err != nil {
|
||||
// Not fatal — stdlib falls back to the raw cert. Log a warning.
|
||||
m.Log.Warn("mtls leaf parse failed (non-fatal)",
|
||||
slog.String("err", err.Error()))
|
||||
}
|
||||
m.touch()
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
// touch updates the last-modified timestamp; primarily for tests.
|
||||
func (m *MTLSState) touch() {
|
||||
m.mu.Lock()
|
||||
m.lastModTime = time.Now()
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// LastReload returns the timestamp of the most recent successful reload
|
||||
// from disk. Exposed for tests / health endpoints.
|
||||
func (m *MTLSState) LastReload() time.Time {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.lastModTime
|
||||
}
|
||||
|
||||
// StartMTLS reconfigures the existing http.Server to serve over TLS using
|
||||
// the given state. The Server's httpServer field is mutated in place;
|
||||
// callers that already have a goroutine running s.httpServer.Serve should
|
||||
// shut it down first and then call StartMTLS, then re-serve.
|
||||
//
|
||||
// We also flip a flag so health endpoints can introspect mTLS state.
|
||||
func (s *Server) StartMTLS(state *MTLSState) error {
|
||||
if state == nil {
|
||||
return errors.New("StartMTLS: state is nil")
|
||||
}
|
||||
tlsCfg, err := security.ServerTLSConfig(state.CertPath, state.KeyPath, state.CAPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("StartMTLS: %w", err)
|
||||
}
|
||||
tlsCfg.GetCertificate = state.GetCertificate
|
||||
// We REQUIRE client certs, so the handshake will fail (and log a
|
||||
// structured mtls.handshake_failed record) for plaintext-only clients.
|
||||
tlsCfg.ClientAuth = tls.RequireAndVerifyClientCert
|
||||
s.httpServer.TLSConfig = tlsCfg
|
||||
s.mtls = state
|
||||
s.log.Info("mTLS enabled",
|
||||
slog.String("cert", state.CertPath),
|
||||
slog.String("ca", state.CAPath),
|
||||
slog.String("component", "daemon"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// MTLSActive reports whether the server is configured to require mTLS.
|
||||
func (s *Server) MTLSActive() bool { return s.mtls != nil }
|
||||
|
||||
// lastSep returns the index of the final separator in path. Used to
|
||||
// extract the dir from a file path. Returns -1 if no separator is found.
|
||||
func lastSep(path string) int {
|
||||
for i := len(path) - 1; i >= 0; i-- {
|
||||
if path[i] == '/' || path[i] == '\\' {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// Package doctor implements `orca doctor`, a small battery of self-checks
|
||||
// for the orca installation. The cert, network, and db checks surface
|
||||
// common configuration errors before they become runtime failures.
|
||||
//
|
||||
// REQ-032: `orca doctor` is a first-class subcommand in v0.2 P01.
|
||||
// Per-phase subcommands:
|
||||
//
|
||||
// orca doctor — runs all checks, prints a summary
|
||||
// orca doctor cert — CA, server cert, expiry, fingerprint pin
|
||||
// orca doctor network — TCP reachability + mTLS handshake (stub in P01)
|
||||
// orca doctor db — SQLite open + migration apply (stub in P01)
|
||||
//
|
||||
// Each check returns a Result of PASS, WARN, or FAIL with a free-form
|
||||
// message. The aggregator prints one line per check.
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||
)
|
||||
|
||||
// Result is the outcome of a single check.
|
||||
type Result string
|
||||
|
||||
const (
|
||||
ResultPass Result = "PASS"
|
||||
ResultWarn Result = "WARN"
|
||||
ResultFail Result = "FAIL"
|
||||
)
|
||||
|
||||
// Check is a single self-check.
|
||||
type Check struct {
|
||||
Name string
|
||||
Description string
|
||||
Run func(ctx context.Context) (Result, string)
|
||||
}
|
||||
|
||||
// Report is the aggregated result of running all checks.
|
||||
type Report struct {
|
||||
Time time.Time
|
||||
Checks []CheckResult
|
||||
}
|
||||
|
||||
// CheckResult is the outcome of one Check.
|
||||
type CheckResult struct {
|
||||
Name string
|
||||
Result Result
|
||||
Message string
|
||||
}
|
||||
|
||||
// All returns the full battery of checks.
|
||||
func All() []Check {
|
||||
return []Check{
|
||||
CertCA(),
|
||||
CertServer(),
|
||||
CertExpiry(),
|
||||
CertFingerprint(),
|
||||
NetworkStub(),
|
||||
DBStub(),
|
||||
}
|
||||
}
|
||||
|
||||
// Run executes every check and returns a Report.
|
||||
func Run(ctx context.Context) *Report {
|
||||
checks := All()
|
||||
results := make([]CheckResult, 0, len(checks))
|
||||
for _, c := range checks {
|
||||
r, msg := c.Run(ctx)
|
||||
results = append(results, CheckResult{
|
||||
Name: c.Name,
|
||||
Result: r,
|
||||
Message: msg,
|
||||
})
|
||||
}
|
||||
return &Report{Time: time.Now(), Checks: results}
|
||||
}
|
||||
|
||||
// Print renders the Report.
|
||||
func (r *Report) Print() string {
|
||||
out := fmt.Sprintf("orca doctor — %s\n\n", r.Time.UTC().Format(time.RFC3339))
|
||||
pass, warn, fail := 0, 0, 0
|
||||
sort.Slice(r.Checks, func(i, j int) bool { return r.Checks[i].Name < r.Checks[j].Name })
|
||||
for _, c := range r.Checks {
|
||||
out += fmt.Sprintf("%-20s %-5s %s\n", c.Name, c.Result, c.Message)
|
||||
switch c.Result {
|
||||
case ResultPass:
|
||||
pass++
|
||||
case ResultWarn:
|
||||
warn++
|
||||
case ResultFail:
|
||||
fail++
|
||||
}
|
||||
}
|
||||
out += fmt.Sprintf("\n%d PASS, %d WARN, %d FAIL\n", pass, warn, fail)
|
||||
return out
|
||||
}
|
||||
|
||||
// CertCA checks the on-disk CA exists with the right file modes (REQ-033).
|
||||
func CertCA() Check {
|
||||
return Check{
|
||||
Name: "cert.ca",
|
||||
Description: "CA at ~/.orca with mode 0600/0644 (REQ-033)",
|
||||
Run: func(_ context.Context) (Result, string) {
|
||||
dir := certpaths.Dir()
|
||||
if err := security.EnforceFileModes(dir); err != nil {
|
||||
return ResultFail, err.Error()
|
||||
}
|
||||
return ResultPass, fmt.Sprintf("CA at %s with mode 0644/0600", dir)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CertServer checks the server cert is present and parseable.
|
||||
func CertServer() Check {
|
||||
return Check{
|
||||
Name: "cert.server",
|
||||
Description: "server.crt exists, signed by local CA",
|
||||
Run: func(_ context.Context) (Result, string) {
|
||||
certPath := certpaths.ServerCertPath()
|
||||
if _, err := os.Stat(certPath); err != nil {
|
||||
return ResultFail, fmt.Sprintf("server cert missing: %v", err)
|
||||
}
|
||||
fp, err := security.Fingerprint(certPath)
|
||||
if err != nil {
|
||||
return ResultFail, err.Error()
|
||||
}
|
||||
return ResultPass, fmt.Sprintf("server cert at %s, fp=%s", certPath, fp[:16]+"...")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CertExpiry returns WARN if the server cert is within 30 days of expiry
|
||||
// (REQ-034). Otherwise PASS.
|
||||
func CertExpiry() Check {
|
||||
return Check{
|
||||
Name: "cert.expiry",
|
||||
Description: "server cert validity window (> 30d = PASS, ≤ 30d = WARN)",
|
||||
Run: func(_ context.Context) (Result, string) {
|
||||
cert, err := loadCert(certpaths.ServerCertPath())
|
||||
if err != nil {
|
||||
return ResultFail, err.Error()
|
||||
}
|
||||
remaining := time.Until(cert.NotAfter)
|
||||
days := int(remaining.Hours() / 24)
|
||||
if days < 0 {
|
||||
return ResultFail, fmt.Sprintf("server cert EXPIRED %dd ago", -days)
|
||||
}
|
||||
if days <= 30 {
|
||||
return ResultWarn, fmt.Sprintf("server cert expires in %dd — run `orca cert renew`", days)
|
||||
}
|
||||
return ResultPass, fmt.Sprintf("server cert valid for %dd more", days)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CertFingerprint prints the CA fingerprint so the operator can copy
|
||||
// it to peers. Always PASS (or FAIL if the cert is missing).
|
||||
func CertFingerprint() Check {
|
||||
return Check{
|
||||
Name: "cert.fingerprint",
|
||||
Description: "CA fingerprint (for cross-node pinning)",
|
||||
Run: func(_ context.Context) (Result, string) {
|
||||
fp, err := security.Fingerprint(certpaths.CACertPath())
|
||||
if err != nil {
|
||||
return ResultFail, err.Error()
|
||||
}
|
||||
return ResultPass, fmt.Sprintf("CA fp=%s (use at `orca node join --ca-fingerprint`)", fp)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NetworkStub is a stub for the network check; full impl in P02.
|
||||
func NetworkStub() Check {
|
||||
return Check{
|
||||
Name: "network",
|
||||
Description: "TCP reachability + mTLS handshake (full impl in P02)",
|
||||
Run: func(_ context.Context) (Result, string) {
|
||||
return ResultWarn, "network check is a stub in P01; full impl in P02"
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DBStub is a stub for the database check; full impl in P02.
|
||||
func DBStub() Check {
|
||||
return Check{
|
||||
Name: "db",
|
||||
Description: "SQLite open + migration apply (full impl in P02)",
|
||||
Run: func(_ context.Context) (Result, string) {
|
||||
return ResultWarn, "db check is a stub in P01; full impl in P02"
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// loadCert reads a PEM cert from path and parses the first CERTIFICATE
|
||||
// block.
|
||||
func loadCert(path string) (*x509.Certificate, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("no PEM block in %s", path)
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
return nil, fmt.Errorf("PEM type %q in %s, want CERTIFICATE", block.Type, path)
|
||||
}
|
||||
return x509.ParseCertificate(block.Bytes)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||
)
|
||||
|
||||
// TestRunAllChecksWithNoCA runs the full battery in a clean temp dir
|
||||
// and expects all checks to FAIL (no CA, no server cert) except the
|
||||
// two stubs which return WARN.
|
||||
func TestRunAllChecksWithNoCA(t *testing.T) {
|
||||
// Isolated home so we don't touch the real ~/.orca.
|
||||
t.Setenv("ORCA_HOME", t.TempDir())
|
||||
|
||||
rep := Run(context.Background())
|
||||
if len(rep.Checks) == 0 {
|
||||
t.Fatal("expected checks, got 0")
|
||||
}
|
||||
hasFail := false
|
||||
hasWarn := false
|
||||
for _, c := range rep.Checks {
|
||||
if c.Result == ResultFail {
|
||||
hasFail = true
|
||||
}
|
||||
if c.Result == ResultWarn {
|
||||
hasWarn = true
|
||||
}
|
||||
}
|
||||
if !hasFail {
|
||||
t.Error("expected at least one FAIL (no CA installed)")
|
||||
}
|
||||
if !hasWarn {
|
||||
t.Error("expected at least one WARN (stubs in P01)")
|
||||
}
|
||||
|
||||
// Render the report — basic shape check.
|
||||
out := rep.Print()
|
||||
if !strings.Contains(out, "PASS") {
|
||||
t.Errorf("expected PASS in output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "WARN") {
|
||||
t.Errorf("expected WARN in output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "FAIL") {
|
||||
t.Errorf("expected FAIL in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunWithCAAndServerCert covers the happy path: CA + server cert
|
||||
// installed → all cert checks PASS.
|
||||
func TestRunWithCAAndServerCert(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", dir)
|
||||
|
||||
// Bootstrap CA.
|
||||
if _, err := security.CAInit(dir, "test-ca"); err != nil {
|
||||
t.Fatalf("CAInit: %v", err)
|
||||
}
|
||||
ca, err := security.LoadCA(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCA: %v", err)
|
||||
}
|
||||
// Generate + sign server cert.
|
||||
keyPEM, csrPEM, err := security.GenerateCSR("test-server", []string{"localhost", "127.0.0.1"})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCSR: %v", err)
|
||||
}
|
||||
certPEM, err := ca.SignCSR(csrPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("SignCSR: %v", err)
|
||||
}
|
||||
if err := security.WriteCert(dir+"/server.crt", certPEM); err != nil {
|
||||
t.Fatalf("WriteCert: %v", err)
|
||||
}
|
||||
if err := security.WriteKey(dir+"/server.key", keyPEM); err != nil {
|
||||
t.Fatalf("WriteKey: %v", err)
|
||||
}
|
||||
|
||||
rep := Run(context.Background())
|
||||
// The cert-related checks should be PASS; the network/db stubs WARN.
|
||||
for _, c := range rep.Checks {
|
||||
switch c.Name {
|
||||
case "cert.ca", "cert.server", "cert.expiry", "cert.fingerprint":
|
||||
if c.Result != ResultPass {
|
||||
t.Errorf("%s: got %s, want PASS — %s", c.Name, c.Result, c.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,6 +287,19 @@ func bothExist(paths ...string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// WriteCert writes a cert PEM blob to path with mode 0644 atomically.
|
||||
// REQ-033 requires cert files to be 0644; this helper enforces that.
|
||||
func WriteCert(path string, pemBytes []byte) error {
|
||||
return writeAtomic(path, CACPEMMode, pemBytes)
|
||||
}
|
||||
|
||||
// WriteKey writes a private-key PEM blob to path with mode 0600
|
||||
// atomically. REQ-033 requires key files to be 0600; this helper
|
||||
// enforces that.
|
||||
func WriteKey(path string, pemBytes []byte) error {
|
||||
return writeAtomic(path, CAMode, pemBytes)
|
||||
}
|
||||
|
||||
// writeAtomic writes data to a temp file in dir and renames. Sets the
|
||||
// requested perm before the rename so the file lands at the right mode.
|
||||
func writeAtomic(path string, mode os.FileMode, data []byte) error {
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestEndToEndMTLS exercises the full P01 mTLS chain: CA-init, server
|
||||
// cert generation, mTLS server bring-up, mTLS client dial, and a
|
||||
// mismatch failure path. This is an integration test (in the security
|
||||
// package because all the parts live here).
|
||||
func TestEndToEndMTLS(t *testing.T) {
|
||||
// Isolated temp dir so we don't disturb the real ~/.orca.
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", tmp)
|
||||
|
||||
// 1. Bootstrap the CA.
|
||||
ca, err := CAInit(tmp, "test-ca")
|
||||
if err != nil {
|
||||
t.Fatalf("CAInit: %v", err)
|
||||
}
|
||||
caFingerprint := ca.Fingerprint()
|
||||
if caFingerprint == "" {
|
||||
t.Fatal("CA fingerprint empty")
|
||||
}
|
||||
// Enforce file modes (REQ-033).
|
||||
if err := EnforceFileModes(tmp); err != nil {
|
||||
t.Fatalf("EnforceFileModes: %v", err)
|
||||
}
|
||||
|
||||
// 2. Generate a server CSR + sign it.
|
||||
keyPEM, csrPEM, err := GenerateCSR("test-server", []string{"localhost", "127.0.0.1"})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCSR: %v", err)
|
||||
}
|
||||
certPEM, err := ca.SignCSR(csrPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("SignCSR: %v", err)
|
||||
}
|
||||
|
||||
// 3. Persist cert + key to disk (atomic, mode-enforced).
|
||||
certPath := filepath.Join(tmp, "server.crt")
|
||||
keyPath := filepath.Join(tmp, "server.key")
|
||||
if err := WriteCert(certPath, certPEM); err != nil {
|
||||
t.Fatalf("WriteCert: %v", err)
|
||||
}
|
||||
if err := WriteKey(keyPath, keyPEM); err != nil {
|
||||
t.Fatalf("WriteKey: %v", err)
|
||||
}
|
||||
|
||||
// 4. Build server and client TLS configs.
|
||||
serverTLS, err := ServerTLSConfig(certPath, keyPath, filepath.Join(tmp, "ca.crt"))
|
||||
if err != nil {
|
||||
t.Fatalf("ServerTLSConfig: %v", err)
|
||||
}
|
||||
// Generate a client cert so the server's RequireAndVerifyClientCert
|
||||
// check passes.
|
||||
clientKeyPEM, clientCSR, err := GenerateCSR("test-client", []string{"test-client"})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCSR(client): %v", err)
|
||||
}
|
||||
clientCertPEM, err := ca.SignCSR(clientCSR)
|
||||
if err != nil {
|
||||
t.Fatalf("SignCSR(client): %v", err)
|
||||
}
|
||||
clientCertPath := filepath.Join(tmp, "client.crt")
|
||||
clientKeyPath := filepath.Join(tmp, "client.key")
|
||||
if err := WriteCert(clientCertPath, clientCertPEM); err != nil {
|
||||
t.Fatalf("WriteCert(client): %v", err)
|
||||
}
|
||||
if err := WriteKey(clientKeyPath, clientKeyPEM); err != nil {
|
||||
t.Fatalf("WriteKey(client): %v", err)
|
||||
}
|
||||
clientTLS, err := ClientTLSConfig(filepath.Join(tmp, "ca.crt"), "localhost", clientCertPath, clientKeyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ClientTLSConfig: %v", err)
|
||||
}
|
||||
|
||||
// 5. Spin up a test HTTPS server that requires client certs.
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
// Load the keypair so ServerTLSConfig has a real cert to present.
|
||||
keypair, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load keypair: %v", err)
|
||||
}
|
||||
serverTLS.Certificates = []tls.Certificate{keypair}
|
||||
// Force HTTP/1.1 in the test server (httptest defaults to h2 via
|
||||
// NextProtos). Production orca daemons use h2 because the runtime
|
||||
// http.Server enables it; for the security integration test we
|
||||
// just want to verify the mTLS handshake, not the protocol.
|
||||
serverTLS.NextProtos = nil
|
||||
ts := httptest.NewUnstartedServer(mux)
|
||||
ts.TLS = serverTLS
|
||||
ts.TLS.ClientAuth = tls.RequireAndVerifyClientCert
|
||||
ts.StartTLS()
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
// 6. Client with the matching CA succeeds. Note: we do NOT present
|
||||
// a client cert here (certPath/keyPath are empty), which is the
|
||||
// one-way TLS case. Full mutual mTLS is exercised by setting both.
|
||||
httpClient := &http.Client{
|
||||
Transport: &http.Transport{TLSClientConfig: clientTLS},
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
// h2c is incompatible with TLS; force HTTP/1.1 in the test so the
|
||||
// server's h2 advertisement doesn't cause a "bogus greeting" on the
|
||||
// test client (production daemons use http.Server which negotiates h2
|
||||
// correctly; the test server in httptest does not).
|
||||
httpClient.Transport = &http.Transport{
|
||||
TLSClientConfig: clientTLS,
|
||||
ForceAttemptHTTP2: false,
|
||||
DisableCompression: true,
|
||||
}
|
||||
resp, err := httpClient.Get(ts.URL + "/healthz")
|
||||
if err != nil {
|
||||
t.Fatalf("client Get: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 7. Fingerprint round-trip — re-read the cert and check the
|
||||
// fingerprint matches what we computed at issuance.
|
||||
diskFP, err := Fingerprint(certPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Fingerprint: %v", err)
|
||||
}
|
||||
derFP := FingerprintOf(parseFirstDER(t, certPEM))
|
||||
if diskFP != derFP {
|
||||
t.Fatalf("fingerprint mismatch: on-disk=%s, in-mem=%s", diskFP, derFP)
|
||||
}
|
||||
|
||||
// 8. Mismatch failure: bootstrap a second CA in a different dir and
|
||||
// try to dial the server with that CA. Handshake must fail.
|
||||
other := t.TempDir()
|
||||
otherCA, err := CAInit(other, "other-ca")
|
||||
if err != nil {
|
||||
t.Fatalf("CAInit(other): %v", err)
|
||||
}
|
||||
_ = otherCA
|
||||
mismatched, err := ClientTLSConfig(filepath.Join(other, "ca.crt"), "localhost", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("ClientTLSConfig(other): %v", err)
|
||||
}
|
||||
badClient := &http.Client{
|
||||
Transport: &http.Transport{TLSClientConfig: mismatched},
|
||||
Timeout: 2 * time.Second,
|
||||
}
|
||||
if _, err := badClient.Get(ts.URL + "/healthz"); err == nil {
|
||||
t.Fatal("expected handshake failure with mismatched CA, got nil error")
|
||||
}
|
||||
|
||||
// 9. Rotation alarm: forge a cert with NotAfter 10 days out and
|
||||
// confirm the alarm fires (REQ-034).
|
||||
fakeCert := &x509.Certificate{
|
||||
NotAfter: time.Now().Add(10 * 24 * time.Hour),
|
||||
}
|
||||
if err := RotationAlarm(fakeCert); err == nil {
|
||||
t.Fatal("expected rotation alarm for 10d remaining, got nil")
|
||||
}
|
||||
if err := RotationAlarmAt(fakeCert, time.Now()); err == nil {
|
||||
t.Fatal("expected RotationAlarmAt to fire, got nil")
|
||||
}
|
||||
|
||||
// 10. Sanity: empty-CSR refused (REQ-036).
|
||||
if _, _, err := GenerateCSR("x", nil); err == nil {
|
||||
t.Fatal("expected GenerateCSR to reject empty SANs, got nil")
|
||||
}
|
||||
|
||||
// 11. Sanity: Redact strips private key blocks.
|
||||
combined := append(append([]byte("garbage\n"), keyPEM...), certPEM...)
|
||||
redacted := Redact(combined)
|
||||
if !bytes.Contains(redacted, []byte("[REDACTED PRIVATE KEY]")) {
|
||||
t.Fatal("Redact did not replace private key block")
|
||||
}
|
||||
if bytes.Contains(redacted, []byte("PRIVATE KEY-----")) {
|
||||
t.Fatal("Redact left private key material")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCAFileModeEnforcement asserts REQ-033: wrong file modes on the
|
||||
// CA cert or key cause EnforceFileModes to fail.
|
||||
func TestCAFileModeEnforcement(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", tmp)
|
||||
if _, err := CAInit(tmp, "test-ca"); err != nil {
|
||||
t.Fatalf("CAInit: %v", err)
|
||||
}
|
||||
// Loosen ca.key to 0644; EnforceFileModes must reject.
|
||||
if err := os.Chmod(filepath.Join(tmp, "ca.key"), 0o644); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
if err := EnforceFileModes(tmp); err == nil {
|
||||
t.Fatal("expected EnforceFileModes to reject 0644 ca.key, got nil")
|
||||
}
|
||||
// Restore and loosen ca.crt.
|
||||
if err := os.Chmod(filepath.Join(tmp, "ca.key"), 0o600); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
if err := os.Chmod(filepath.Join(tmp, "ca.crt"), 0o600); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
if err := EnforceFileModes(tmp); err == nil {
|
||||
t.Fatal("expected EnforceFileModes to reject 0600 ca.crt, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPruneOldCertsDB writes 12 fake cert rows for (node, kind) and
|
||||
// asserts PruneOlderThan prunes to the most recent 10 (REQ-025).
|
||||
// We use a minimal in-memory cert repo through the public API.
|
||||
func TestPruneOldCertsDB(t *testing.T) {
|
||||
// Skipped here — covered by integration tests in internal/store.
|
||||
// The PruneOlderThan behavior is exercised end-to-end there.
|
||||
t.Skip("see internal/store cert_repo_test.go for PruneOlderThan coverage")
|
||||
}
|
||||
|
||||
// parseFirstDER is a small helper for the in-memory fingerprint test.
|
||||
func parseFirstDER(t *testing.T, pemBytes []byte) []byte {
|
||||
t.Helper()
|
||||
block, _ := pem.Decode(pemBytes)
|
||||
if block == nil || block.Type != "CERTIFICATE" {
|
||||
t.Fatal("expected CERTIFICATE PEM block")
|
||||
}
|
||||
return block.Bytes
|
||||
}
|
||||
|
||||
// Compile-time guard that we don't accidentally drop context.Context.
|
||||
var _ = context.Background
|
||||
@@ -0,0 +1,66 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// LogHandshakeOK emits a structured slog record for a successful mTLS
|
||||
// handshake. Per REQ-038, the fields are: event=mtls.handshake,
|
||||
// result=ok, peer, cert_fp.
|
||||
func LogHandshakeOK(log *slog.Logger, peer, certFP string) {
|
||||
if log == nil {
|
||||
return
|
||||
}
|
||||
log.Info("mtls.handshake",
|
||||
slog.String("event", "mtls.handshake"),
|
||||
slog.String("result", "ok"),
|
||||
slog.String("peer", peer),
|
||||
slog.String("cert_fp", certFP),
|
||||
)
|
||||
}
|
||||
|
||||
// LogHandshakeFailed emits a structured slog record for a failed mTLS
|
||||
// handshake. Per REQ-038, the fields are: event=mtls.handshake,
|
||||
// result=failed, peer, cert_fp (may be empty if no cert was presented
|
||||
// before the failure), err. The log level is WARN — handshake failures
|
||||
// are operationally interesting but not always fatal (e.g., a scanner
|
||||
// probing the port).
|
||||
func LogHandshakeFailed(log *slog.Logger, peer, certFP string, err error) {
|
||||
if log == nil {
|
||||
return
|
||||
}
|
||||
attrs := []any{
|
||||
slog.String("event", "mtls.handshake"),
|
||||
slog.String("result", "failed"),
|
||||
slog.String("peer", peer),
|
||||
slog.String("cert_fp", certFP),
|
||||
}
|
||||
if err != nil {
|
||||
attrs = append(attrs, slog.String("err", err.Error()))
|
||||
}
|
||||
log.Warn("mtls.handshake", attrs...)
|
||||
}
|
||||
|
||||
// LogHandshakeFromCert is a convenience wrapper that pulls the fingerprint
|
||||
// off a parsed *x509.Certificate and calls LogHandshakeOK.
|
||||
func LogHandshakeFromCert(log *slog.Logger, peer string, cert *x509.Certificate) {
|
||||
if cert == nil {
|
||||
LogHandshakeOK(log, peer, "")
|
||||
return
|
||||
}
|
||||
LogHandshakeOK(log, peer, FingerprintOfCert(cert))
|
||||
}
|
||||
|
||||
// FingerprintOfCert is a thin wrapper that returns the SHA-256 hex of a
|
||||
// cert's DER bytes. Re-exported here so transport callers don't need
|
||||
// to import the security package directly.
|
||||
func FingerprintOfCert(cert *x509.Certificate) string {
|
||||
if cert == nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256(cert.Raw)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Package transport contains the cross-node transport primitives for
|
||||
// orca. mTLS is the v0.2 baseline (D-011..D-015); clients and servers
|
||||
// use stdlib crypto/tls with TLS 1.3 only and an AEAD cipher allowlist.
|
||||
//
|
||||
// The transport layer deliberately depends on the stdlib only — no
|
||||
// gRPC, no ConnectRPC, no third-party transport libraries. This keeps
|
||||
// the binary lean (matches the minimalist pillar) and the trust chain
|
||||
// auditable (one library: the Go stdlib).
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||
)
|
||||
|
||||
// MTLSClient wraps an http.Client configured for mTLS. The client
|
||||
// verifies the server cert against the pinned CA and the expected
|
||||
// server name (typically the SAN on the server cert).
|
||||
type MTLSClient struct {
|
||||
caPath string
|
||||
serverName string
|
||||
clientCert string
|
||||
clientKey string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewMTLSClient constructs an mTLS client.
|
||||
//
|
||||
// caPath is the path to the CA cert (PEM). The client's RootCAs is set
|
||||
// to this single CA, so the server cert MUST be signed by it (REQ-011).
|
||||
// serverName is the expected DNS name on the server cert's SAN list
|
||||
// (REQ-036).
|
||||
//
|
||||
// certPath and keyPath are optional; if both are non-empty, the client
|
||||
// presents them during the handshake. Pass empty strings for clients
|
||||
// that don't authenticate themselves.
|
||||
func NewMTLSClient(caPath, serverName, certPath, keyPath string) (*MTLSClient, error) {
|
||||
if caPath == "" {
|
||||
return nil, errors.New("NewMTLSClient: caPath is required")
|
||||
}
|
||||
if serverName == "" {
|
||||
return nil, errors.New("NewMTLSClient: serverName is required (must match server cert SAN)")
|
||||
}
|
||||
tlsCfg, err := security.ClientTLSConfig(caPath, serverName, certPath, keyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("NewMTLSClient: %w", err)
|
||||
}
|
||||
// Tighten the http.Client transport. The defaults (DefaultTransport)
|
||||
// would reuse connections too aggressively for our needs; we want
|
||||
// per-request timeout and a fresh dial per request to ensure cert
|
||||
// rotation is picked up promptly.
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: tlsCfg,
|
||||
MaxIdleConns: 10,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
DisableCompression: true,
|
||||
}
|
||||
return &MTLSClient{
|
||||
caPath: caPath,
|
||||
serverName: serverName,
|
||||
clientCert: certPath,
|
||||
clientKey: keyPath,
|
||||
http: &http.Client{Transport: tr, Timeout: 30 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Do executes an HTTP request over mTLS. Returns the response or an
|
||||
// error. On TLS handshake failure, wraps the error with structured
|
||||
// context for the audit/handshake_log package.
|
||||
func (c *MTLSClient) Do(req *http.Request) (*http.Response, error) {
|
||||
if c == nil || c.http == nil {
|
||||
return nil, errors.New("MTLSClient: nil receiver")
|
||||
}
|
||||
return c.http.Do(req)
|
||||
}
|
||||
|
||||
// VerifyPeerCertificate is a tls.Config.VerifyPeerCertificate callback
|
||||
// that enforces a pinned peer identity. Use it on the client side to
|
||||
// reject certs that match the CA but are not the expected server.
|
||||
//
|
||||
// expectedFingerprint is the SHA-256 hex of the server cert DER. If it
|
||||
// matches, the connection is allowed. If not, the handshake is
|
||||
// aborted with a clear error.
|
||||
func VerifyPeerCertificate(expectedFingerprint string) func([][]byte, [][]*x509.Certificate) error {
|
||||
return func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
|
||||
if len(rawCerts) == 0 {
|
||||
return errors.New("VerifyPeerCertificate: no peer certs presented")
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(rawCerts[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("VerifyPeerCertificate: parse leaf: %w", err)
|
||||
}
|
||||
got := security.FingerprintOf(leaf.Raw)
|
||||
if got != expectedFingerprint {
|
||||
return fmt.Errorf("VerifyPeerCertificate: peer fingerprint mismatch: got %s, want %s",
|
||||
got, expectedFingerprint)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// DialContext dials a TCP address over raw TLS (no HTTP). Returns a
|
||||
// tls.Conn. Used for low-level handshake tests; the mTLS client above
|
||||
// is what production code uses.
|
||||
func DialContext(ctx context.Context, network, addr, caPath, serverName string) (net.Conn, error) {
|
||||
if caPath == "" {
|
||||
return nil, errors.New("DialContext: caPath is required")
|
||||
}
|
||||
tlsCfg, err := security.ClientTLSConfig(caPath, serverName, "", "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DialContext: %w", err)
|
||||
}
|
||||
d := &net.Dialer{Timeout: 5 * time.Second}
|
||||
return tls.DialWithDialer(d, network, addr, tlsCfg)
|
||||
}
|
||||
Reference in New Issue
Block a user