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 }