31ccb52114
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---
145 lines
4.6 KiB
Go
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
|
|
}
|