Files
orca/internal/daemon/tls.go
T
ciagent 31ccb52114 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---
2026-06-03 21:33:41 +00:00

145 lines
4.6 KiB
Go

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
}