Files
orca/internal/doctor/doctor.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

218 lines
6.0 KiB
Go

// 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)
}