Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fbc33fa47 | |||
| 48cd101ff2 | |||
| e1b538575c | |||
| 07b8ad2cea | |||
| b06458d313 | |||
| 708d983429 | |||
| 30c523c0c7 | |||
| 759b1b519d | |||
| b25e074e1d | |||
| bb6b5b3e83 | |||
| 857f756319 | |||
| f9a9873341 | |||
| 78334f1f74 | |||
| c7dbcef958 | |||
| 9580f347c6 | |||
| 46e929e4c6 | |||
| 503923bf1e | |||
| e3f6e1df82 | |||
| aa3cccead5 | |||
| c2038952c7 | |||
| 65eb2e601b | |||
| 6f34f1794b | |||
| bc7ce1caf6 |
+53
-409
@@ -2,193 +2,66 @@
|
||||
|
||||
## System Overview
|
||||
|
||||
Orca is a single-binary, offline-first orchestration engine. The system consists
|
||||
of three logical layers (CLI, Daemon, Engine) compiled into one `orca` binary
|
||||
and selected via subcommands. v0.2 adds a **cross-node transport layer** (mTLS)
|
||||
and a **dispatcher** for multi-node job execution.
|
||||
Orca is a single-binary, offline-first orchestration engine. The system consists of three logical components, all compiled into one `orca` binary and selected via subcommands.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ orca (single binary, v0.2) │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ CLI Layer (Cobra) │
|
||||
│ ├── orca version │
|
||||
│ ├── orca init # local node bootstrap │
|
||||
│ ├── orca cert {init,join,renew,show} # NEW (P01) │
|
||||
│ ├── orca status │
|
||||
│ ├── orca node {join,leave,list} # join = mTLS handshake (P01) │
|
||||
│ │ └── orca node list --watch # NEW iter.Seq (P04) │
|
||||
│ ├── orca job {run,list,stop,logs} │
|
||||
│ │ └── orca job list --watch # NEW iter.Seq (P04) │
|
||||
│ ├── orca doctor # NEW (P01) — diagnostics │
|
||||
│ │ ├── orca doctor cert │
|
||||
│ │ ├── orca doctor network │
|
||||
│ │ └── orca doctor db │
|
||||
│ └── orca daemon │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Daemon Layer (net/http over h2c, mTLS in P01) │
|
||||
│ ├── /healthz (liveness) │
|
||||
│ ├── /readyz (readiness) │
|
||||
│ ├── /v1/jobs/* (job control API) │
|
||||
│ ├── /v1/nodes/* (node registry API) │
|
||||
│ ├── /v1/tasks/* (task lifecycle API) │
|
||||
│ ├── /orca.v1.Dispatch/... # NEW (P02) — cross-node dispatch │
|
||||
│ └── /orca.v1.Register/... # NEW (P02) — peer join ack │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Transport Layer (NEW — internal/transport) │
|
||||
│ ├── mTLS client (dialer pool per peer) │
|
||||
│ ├── mTLS server config (TLS 1.3 only, AEAD allowlist) │
|
||||
│ ├── Retry+backoff (exponential, jittered, capped) │
|
||||
│ └── Graceful disconnect (ctx-aware Conn.Close) │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Core Engine │
|
||||
│ ├── Node Registry (in-memory + SQLite persistence) │
|
||||
│ ├── Task Executor (os/exec with WaitDelay, Go 1.25+) │
|
||||
│ ├── Job Scheduler (single-node FIFO; bin-pack P02) │
|
||||
│ ├── Dispatcher # NEW internal/engine/dispatcher.go │
|
||||
│ │ ├── Local decision (does this job fit on this node?) │
|
||||
│ │ ├── Remote dispatch (POST to peer via transport) │
|
||||
│ │ └── Streaming callback (iter.Seq[DispatchResult] for CLI) │
|
||||
│ ├── Security Manager # NEW internal/security (P01) │
|
||||
│ │ ├── CA lifecycle (init, fingerprint, sign CSR) │
|
||||
│ │ ├── Server cert lifecycle (issue, renew, rotate) │
|
||||
│ │ ├── mTLS config builder │
|
||||
│ │ └── Cert store (filesystem + SQLite metadata) │
|
||||
│ └── Audit Logger (log/slog JSON handler) │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ State Store (modernc/sqlite, CGO-free) │
|
||||
│ ~/.orca/orca.db │
|
||||
│ ├── nodes, jobs, tasks, audit_log (v0.1) │
|
||||
│ └── certs # NEW (P01) — CA + server certs │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ orca (single binary) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ CLI Layer (Cobra) │
|
||||
│ ├── orca version │
|
||||
│ ├── orca init │
|
||||
│ ├── orca status │
|
||||
│ ├── orca node {join,leave,list} │
|
||||
│ └── orca job {run,list,stop,logs} │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Daemon Layer (net/http server) │
|
||||
│ ├── /healthz (liveness) │
|
||||
│ ├── /readyz (readiness) │
|
||||
│ ├── /v1/jobs/* (job control API) │
|
||||
│ ├── /v1/nodes/* (node registry API) │
|
||||
│ └── /v1/tasks/* (task lifecycle API) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Core Engine │
|
||||
│ ├── Node Registry (in-memory + SQLite persistence) │
|
||||
│ ├── Task Executor (os/exec with WaitDelay, Go 1.25+) │
|
||||
│ ├── Job Scheduler (single-node for v0.1) │
|
||||
│ └── Audit Logger (log/slog JSON handler) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ State Store (modernc/sqlite, CGO-free) │
|
||||
│ ~/.orca/orca.db │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Component Details
|
||||
|
||||
### 1. CLI Layer (`cmd/orca`, `internal/cli`)
|
||||
|
||||
- **Framework**: Cobra (industry standard, familiar to operators)
|
||||
- **v0.1 subcommands**: `version`, `init`, `status`, `node`, `job`, `daemon`
|
||||
- **v0.2 additions (P01)**: `orca cert {init,join,renew,show}`
|
||||
- **v0.2 additions (P04)**: `--watch` flag on `orca job list` and `orca node list`
|
||||
- **v0.2 additions (P01)**: `orca doctor` subcommand (see §5 below)
|
||||
- **Subcommands**: `version`, `init`, `status`, `node`, `job`
|
||||
- **Output**: Human-readable by default; `--json` flag for machine consumption
|
||||
- **Discovery**: All subcommands self-document via Cobra's auto-generated help
|
||||
- **Watch semantics (P04)**: `--watch` consumes `iter.Seq[Job|Node]`, exits on
|
||||
ctrl-c (via `signal.NotifyContext`), refreshes on internal change events.
|
||||
|
||||
### 2. Daemon Layer (`internal/daemon`)
|
||||
|
||||
- **Server**: `net/http` with `http.ServeMux` (no external router)
|
||||
- **TLS (P01)**: `crypto/tls` with `MinVersion=tls.VersionTLS13` and
|
||||
AEAD cipher allowlist
|
||||
(`TLS_AES_256_GCM_SHA384`, `TLS_CHACHA20_POLY1305_SHA256`,
|
||||
`TLS_AES_128_GCM_SHA256`)
|
||||
- **Ports**: Configurable (default `:8443` for API+mTLS, `:8080` for health)
|
||||
- **Server**: `net/http` with `http.ServeMux` (no external router for v0.1)
|
||||
- **TLS**: `crypto/tls` with self-signed certs (mTLS-ready)
|
||||
- **Ports**: Configurable (default `:8443` for API, `:8080` for health)
|
||||
- **Graceful Shutdown**: `signal.NotifyContext` with SIGINT/SIGTERM
|
||||
- **v0.2 endpoints (P02)**:
|
||||
- `POST /orca.v1.Dispatch/Submit` — receive cross-node job submission
|
||||
- `POST /orca.v1.Dispatch/Status` — query dispatched job status
|
||||
- `POST /orca.v1.Register/Hello` — peer join ack (used during `orca node join`)
|
||||
|
||||
### 3. Transport Layer (`internal/transport`, NEW in P01/P02)
|
||||
|
||||
- **Client**: `http.Client` with `http.Transport.TLSClientConfig` populated
|
||||
from `internal/security.NewClientTLSConfig`
|
||||
- **Server**: `http.Server.TLSConfig` populated from
|
||||
`internal/security.NewServerTLSConfig`
|
||||
- **Retry policy**: exponential backoff with jitter (start 100ms, x2, cap 5s,
|
||||
max 5 attempts); only idempotent verbs (`GET`, `HEAD`, `OPTIONS`) are
|
||||
retried automatically; `POST` retries require an explicit
|
||||
`X-Orca-Idempotency-Key` header
|
||||
- **Conn lifecycle**: `context.Context`-aware dials and reads; graceful
|
||||
`Close` on `ctx.Done()`
|
||||
- **Peer dial pool**: small `sync.Map` of `peerID → *http.Client` to reuse
|
||||
TLS handshakes (TCP keep-alive) within a session
|
||||
|
||||
### 4. Core Engine (`internal/engine`)
|
||||
|
||||
### 3. Core Engine (`internal/engine`)
|
||||
- **Node Registry**: In-memory map of node IDs → metadata, persisted to SQLite
|
||||
(CPU/memory capacity, available slots, last-seen)
|
||||
- **Task Executor**: `os/exec.CommandContext` with `WaitDelay` (Go 1.25+) for
|
||||
clean process termination
|
||||
- **Job Scheduler (v0.2 P02)**:
|
||||
- **Algorithm**: best-fit bin-packing by `available_cpu` and `available_memory`
|
||||
- **Within-node ordering**: FIFO queue
|
||||
- **Cross-node**: if local node is full, `Dispatcher.Submit(peer, job)` is
|
||||
invoked; peers are tried in round-robin order
|
||||
- **Fallback**: if all peers reject, return `ErrNoFit` and requeue
|
||||
- **Dispatcher (NEW, P02)**:
|
||||
- `Submit(peerID, spec) (jobID, error)` — blocking call with retry
|
||||
- `Watch(peerID) iter.Seq[DispatchEvent]` — pull-style event stream for the
|
||||
CLI's `--watch` flag
|
||||
- Stateless: every call uses the latest mTLS client config and peer address
|
||||
- **Security Manager (NEW, P01)**:
|
||||
- `InitCA(commonName) (*CA, error)` — generates a self-signed CA, writes
|
||||
`ca.crt` (0644) and `ca.key` (0600) to `~/.orca/`
|
||||
- `Fingerprint(certPath) (sha256hex, error)` — used by `orca cert join`
|
||||
- `SignServerCert(csr, validity) (*cert, error)` — signs a CSR with the CA
|
||||
- `IssueServerCert(nodeName, dnsNames, ips) (*cert, *key, error)` — generates
|
||||
a keypair + CSR + signs it, returns PEM bytes for `orca cert join --server`
|
||||
- `ServerTLSConfig() (*tls.Config, error)` — loads `server.crt`/`server.key`
|
||||
and the CA pool from disk
|
||||
- `ClientTLSConfig(caPath) (*tls.Config, error)` — returns a client config
|
||||
pinned to the supplied CA
|
||||
- **Rotation policy**: server certs valid 90d; CA cert valid 10y. On
|
||||
`orca cert renew`, `IssueServerCert` is called and the daemon
|
||||
gracefully reloads the in-process `tls.Config` via `GetCertificate`
|
||||
hot-swap (no restart required)
|
||||
- **Task Executor**: `os/exec.CommandContext` with `WaitDelay` (Go 1.25+) for clean process termination
|
||||
- **Job Scheduler**: Single-node FIFO queue (multi-node deferred to v0.2+)
|
||||
- **Audit Logger**: `slog.NewJSONHandler(os.Stderr, ...)` with structured fields
|
||||
|
||||
### 5. Doctor (`internal/doctor`, NEW in P01)
|
||||
|
||||
- **Purpose**: operator-facing diagnostics; runs read-only checks against
|
||||
the local state and reports PASS/WARN/FAIL.
|
||||
- **Subcommands**:
|
||||
- `orca doctor` — runs all checks
|
||||
- `orca doctor cert` — cert/CA health (file modes, expiry windows, SAN
|
||||
presence, fingerprint pinning match — see REQ-026, REQ-033,
|
||||
REQ-034, REQ-036)
|
||||
- `orca doctor network` — peer reachability over mTLS (per-peer handshake
|
||||
sanity, last-seen delta)
|
||||
- `orca doctor db` — SQLite integrity check (`PRAGMA integrity_check`)
|
||||
+ migration version
|
||||
- **Output**: human-readable by default; `--json` for machine consumption
|
||||
- **No state changes**: doctor is strictly read-only. It can be run
|
||||
while the daemon is down (where possible) or while it's up.
|
||||
- **Initial implementation in P01** (cert checks only); `network` and
|
||||
`db` checks land in subsequent phases as their state becomes
|
||||
available.
|
||||
|
||||
### 6. State Store (`internal/store`)
|
||||
|
||||
### 4. State Store (`internal/store`)
|
||||
- **Driver**: `modernc.org/sqlite` (pure Go, CGO-free)
|
||||
- **Location**: `~/.orca/orca.db` (user-mode) or `/var/lib/orca/orca.db` (system-mode)
|
||||
- **Schema (v0.1)**: `nodes`, `jobs`, `tasks`, `audit_log` tables
|
||||
- **Schema (v0.2 P01)**: NEW `certs` table
|
||||
```sql
|
||||
CREATE TABLE certs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL, -- 'ca' | 'server'
|
||||
node_id TEXT, -- NULL for CA
|
||||
serial_hex TEXT NOT NULL, -- x509.SerialNumber.Hex()
|
||||
subject_cn TEXT NOT NULL,
|
||||
issuer_cn TEXT NOT NULL,
|
||||
not_before INTEGER NOT NULL, -- unix seconds
|
||||
not_after INTEGER NOT NULL, -- unix seconds
|
||||
fingerprint TEXT NOT NULL, -- sha256 of DER, hex
|
||||
source_path TEXT NOT NULL, -- on-disk PEM path
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_certs_node_kind ON certs(node_id, kind);
|
||||
CREATE INDEX idx_certs_not_after ON certs(not_after);
|
||||
```
|
||||
- **Schema**: `nodes`, `jobs`, `tasks`, `audit_log` tables
|
||||
- **Migrations**: Embedded SQL files, applied on startup
|
||||
- **Migration 0004** is added in P01 with the schema above
|
||||
|
||||
## Data Model
|
||||
|
||||
### Node (v0.1, extended in v0.2 P02)
|
||||
### Node
|
||||
```go
|
||||
type Node struct {
|
||||
ID string
|
||||
@@ -198,22 +71,10 @@ type Node struct {
|
||||
JoinedAt time.Time
|
||||
LastSeen time.Time
|
||||
Metadata map[string]string
|
||||
// v0.2 P02 — capacity for bin-packing
|
||||
Capacity NodeCapacity
|
||||
}
|
||||
|
||||
type NodeCapacity struct {
|
||||
CPUMillicores int // total, e.g. 4000 = 4 cores
|
||||
MemoryBytes int64 // total RAM
|
||||
CPUUsed int // currently allocated
|
||||
MemoryUsed int64 // currently allocated
|
||||
}
|
||||
|
||||
func (c NodeCapacity) AvailableCPU() int { return c.CPUMillicores - c.CPUUsed }
|
||||
func (c NodeCapacity) AvailableMemory() int64 { return c.MemoryBytes - c.MemoryUsed }
|
||||
```
|
||||
|
||||
### Job (v0.1)
|
||||
### Job
|
||||
```go
|
||||
type Job struct {
|
||||
ID string
|
||||
@@ -226,7 +87,7 @@ type Job struct {
|
||||
}
|
||||
```
|
||||
|
||||
### Task (v0.1)
|
||||
### Task
|
||||
```go
|
||||
type Task struct {
|
||||
ID string
|
||||
@@ -243,211 +104,23 @@ type Task struct {
|
||||
}
|
||||
```
|
||||
|
||||
### Certificate (NEW, v0.2 P01)
|
||||
```go
|
||||
type Cert struct {
|
||||
Kind CertKind // CertCA | CertServer
|
||||
NodeID string // empty for CA
|
||||
SerialHex string
|
||||
SubjectCN string
|
||||
IssuerCN string
|
||||
NotBefore time.Time
|
||||
NotAfter time.Time
|
||||
Fingerprint string // sha256 of DER (hex)
|
||||
SourcePath string // PEM path on disk
|
||||
CreatedAt time.Time
|
||||
}
|
||||
```
|
||||
|
||||
## v0.2 Component Graph (ASCII)
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Operator Host │
|
||||
│ (orca CLI) │
|
||||
└────────┬────────┘
|
||||
│ 1. cert join --ca-fingerprint <sha>
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ NODE A (Bootstrap / CA holder) │
|
||||
│ │
|
||||
│ ┌──────────────┐ CSR ┌────────────────────┐ PEM sign ┌────────┐ │
|
||||
│ │ orca cert │──────────▶│ internal/security │─────────────▶│ CA │ │
|
||||
│ │ {init,join} │ │ .SignServerCert() │ │ key │ │
|
||||
│ └──────────────┘ └────────────────────┘ │ 0600 │ │
|
||||
│ ┌──└────────┘ │
|
||||
│ ┌─────────────────┐ │ │
|
||||
│ │ internal/daemon │ ◀──tls.Config── internal/security │ │
|
||||
│ │ (http.Server) │ .ServerTLSConfig() │ │
|
||||
│ │ │ │ │
|
||||
│ │ /v1/jobs/* │ │ │
|
||||
│ │ /v1/nodes/* │ │ │
|
||||
│ │ /orca.v1.* │ │ │
|
||||
│ └────────┬────────┘ │ │
|
||||
│ │ Submit(job) (bin-pack) │ │
|
||||
│ ▼ │ │
|
||||
│ ┌─────────────────┐ │ │
|
||||
│ │ internal/engine │ │ │
|
||||
│ │ .scheduler │──── if local fits → executor │ │
|
||||
│ │ .dispatcher │──── else → Submit(peer, job) ───────────┼──┐ │
|
||||
│ └─────────────────┘ │ │ │
|
||||
│ │ │ mTLS │
|
||||
└──────────────────────────────────────────────────────────────┼──┼───────────┘
|
||||
│ │
|
||||
ORCA NODE NETWORK │ │
|
||||
│ │
|
||||
┌──────────────────────────────────────────────────────────────┼──┼───────────┐
|
||||
│ NODE B (Peer) │ │ │
|
||||
│ │ │ │
|
||||
│ ┌─────────────────┐ ◀── TLS 1.3 handshake ─────────────────┘ │ │
|
||||
│ │ internal/daemon │ │ │
|
||||
│ │ (http.Server) │ POST /orca.v1.Dispatch/Submit │ │
|
||||
│ │ │──── 200 + jobID │ │
|
||||
│ └────────┬────────┘ │ │
|
||||
│ │ │ │
|
||||
│ ▼ │ │
|
||||
│ ┌─────────────────┐ │ │
|
||||
│ │ internal/engine │ │ │
|
||||
│ │ .scheduler (FIFO) │ │
|
||||
│ │ .executor │ │
|
||||
│ └─────────────────┘ │ │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## v0.2 Flows
|
||||
|
||||
### Flow 1: Cert Issuance (CA-init → CSR → sign → install) — P01
|
||||
|
||||
```
|
||||
Operator (Node A) Operator (Node B)
|
||||
───────────────── ─────────────────
|
||||
orca cert init
|
||||
↳ InitCA("orca-ca")
|
||||
↳ write ca.crt (0644), ca.key (0600)
|
||||
↳ record in certs table (kind='ca')
|
||||
orca cert join --ca-fingerprint <sha>
|
||||
↳ operator copies ca.crt → Node B
|
||||
↳ verifies fingerprint matches local
|
||||
--ca-fingerprint arg
|
||||
↳ IssueServerCert("node-b", SANs)
|
||||
↳ generate 2048-bit RSA key
|
||||
↳ build CSR with SANs
|
||||
↳ read ca.crt + ca.key
|
||||
↳ sign CSR (90d validity)
|
||||
↳ write server.crt (0644),
|
||||
server.key (0600)
|
||||
↳ record in certs table
|
||||
(kind='server', node_id='node-b')
|
||||
```
|
||||
|
||||
### Flow 2: mTLS Handshake at `node join` — P01
|
||||
|
||||
```
|
||||
Node B (joiner) Node A (CA holder)
|
||||
──────────────── ─────────────────
|
||||
orca node join --name node-b
|
||||
--ca-fingerprint <sha>
|
||||
--peer node-a:8443
|
||||
↳ load ca.crt → verify sha256 == --ca-fingerprint
|
||||
↳ load server.crt + server.key
|
||||
↳ tls.Config{MinVersion: TLS1.3, ...}
|
||||
↳ ClientHello (SNI=node-a)
|
||||
◀── ServerHello (TLS 1.3)
|
||||
◀── Certificate (Node A's cert)
|
||||
↳ verify Node A's cert chains to ca.crt ◀── CertificateRequest
|
||||
↳ send Certificate (Node B's cert) ◀── Finished
|
||||
↳ Finished
|
||||
↳ GET /healthz (over mTLS) — sanity check
|
||||
↳ POST /v1/nodes (over mTLS) — register
|
||||
↳ insert into nodes table
|
||||
↳ audit log
|
||||
↳ 200 OK
|
||||
↳ record node_a in peers table
|
||||
↳ audit log
|
||||
```
|
||||
|
||||
### Flow 3: Job Dispatch (CLI → dispatcher → peer) — P02
|
||||
|
||||
```
|
||||
User (Node A CLI) Node A (scheduler) Node B (peer)
|
||||
────────────────── ─────────────────── ─────────────
|
||||
orca job run spec.hcl
|
||||
↳ parse HCL
|
||||
↳ POST /v1/jobs (mTLS, local)
|
||||
↳ scheduler.Submit(spec)
|
||||
↳ bin-pack: spec.cpu + spec.mem
|
||||
↳ local node A has 2000mc + 4GiB free → fit!
|
||||
↳ executor.Run(spec)
|
||||
↳ 202 Accepted + jobID
|
||||
↳ returns jobID
|
||||
```
|
||||
|
||||
```
|
||||
User (Node A CLI) Node A (scheduler) Node B (peer)
|
||||
────────────────── ─────────────────── ─────────────
|
||||
↳ scheduler.Submit(spec)
|
||||
↳ bin-pack: spec.cpu + spec.mem
|
||||
↳ local node A has 0 free → NO FIT
|
||||
↳ dispatcher.Submit(peer="node-b", spec)
|
||||
↳ load transport.Client("node-b")
|
||||
↳ POST /orca.v1.Dispatch/Submit
|
||||
↳ mTLS handshake
|
||||
↳ authenticate cert
|
||||
↳ scheduler.Submit(spec)
|
||||
↳ executor.Run(spec)
|
||||
↳ 200 OK + jobID
|
||||
↳ 200 OK + jobID
|
||||
↳ return jobID to local caller
|
||||
↳ returns jobID
|
||||
```
|
||||
|
||||
### Flow 4: iter.Seq Streaming (`--watch`) — P04
|
||||
|
||||
```
|
||||
User orca job list --watch
|
||||
──── ─────────────────────
|
||||
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
|
||||
defer cancel()
|
||||
seq := store.Jobs().Watch(ctx)
|
||||
for job := range seq {
|
||||
print(job) // human or --json
|
||||
}
|
||||
// ctrl-c → ctx.Done() → seq stops yielding
|
||||
```
|
||||
|
||||
Internally `store.Jobs().Watch(ctx) iter.Seq[Job]` polls the
|
||||
`jobs` table on a 1s ticker (or subscribes to an in-process
|
||||
notifier channel) and yields the current snapshot of each job
|
||||
until `ctx.Done()`. The store repo implements `iter.Seq[Job]`
|
||||
as a function that takes a `yield func(Job) bool` callback.
|
||||
|
||||
## Security Architecture
|
||||
|
||||
### Authentication
|
||||
- **v0.1**: mTLS for all API endpoints (self-signed CA)
|
||||
- **v0.2 P01**: Internal CA with CSR join (see Flow 1 + 2)
|
||||
- **v0.2+**: Token-based auth deferred to v0.3+
|
||||
|
||||
### Cert Rotation
|
||||
- Server certs: 90-day validity, rotate at 60 days (30d before expiry)
|
||||
- CA cert: 10-year validity, manual rotation
|
||||
- Hot-swap: `tls.Config.GetCertificate` callback re-reads the
|
||||
`server.crt`/`server.key` files on each handshake so `orca cert renew`
|
||||
takes effect without a daemon restart.
|
||||
- **v0.2+**: Token-based auth as alternative
|
||||
|
||||
### Audit Logging
|
||||
- All state-changing operations emit structured log records
|
||||
- Fields: `timestamp`, `actor`, `action`, `resource`, `result`, `error`
|
||||
- Stored in SQLite `audit_log` table and stderr (JSON)
|
||||
- **v0.2 P01 additions**: `cert.issued`, `cert.renewed`, `cert.joined`,
|
||||
`node.handshake_ok`, `node.handshake_failed`
|
||||
|
||||
### Input Validation
|
||||
- All CLI inputs validated via Cobra's `Args`/`ValidArgs` functions
|
||||
- All API inputs validated at handler boundary
|
||||
- HCL/YAML specs parsed with strict schemas
|
||||
|
||||
## Key Architectural Decisions (v0.1 + v0.2)
|
||||
## Key Architectural Decisions
|
||||
|
||||
| ID | Decision | Rationale |
|
||||
|----|----------|-----------|
|
||||
@@ -459,14 +132,6 @@ as a function that takes a `yield func(Job) bool` callback.
|
||||
| AD-006 | slog for logging | Native to Go 1.21+, no external dependency |
|
||||
| AD-007 | HCL for job specs | Familiar to Nomad/HashiCorp users |
|
||||
| AD-008 | Single-node scheduling (v0.1) | Multi-node scheduling deferred to v0.2+ |
|
||||
| AD-009 | Internal CA, no external PKI (v0.2) | Self-contained, no operational PKI requirement |
|
||||
| AD-010 | Roll-our-own CA in `crypto/x509` (v0.2) | step-ca/cfssl/vault-pki too heavyweight for Orca's footprint |
|
||||
| AD-011 | Operator-mediated CA cert distribution (v0.2) | No secret distribution over the wire; matches offline-first |
|
||||
| AD-012 | TLS 1.3 only, AEAD allowlist (v0.2) | Modern crypto only; no downgrade risk |
|
||||
| AD-013 | Eager mTLS at `node join` (v0.2) | Fail fast; don't defer handshake to first request |
|
||||
| AD-014 | `orca.v1.Dispatch` via stdlib h2c (v0.2) | ConnectRPC not in go.mod; stdlib suffices for a single-RPC service |
|
||||
| AD-015 | Best-fit bin-packing (v0.2) | Simple, deterministic, optimal for small fleets |
|
||||
| AD-016 | iter.Seq for streaming lists (v0.2) | Go 1.25+ native, context-aware, pull semantics |
|
||||
|
||||
## Anti-Patterns (Explicitly Avoided)
|
||||
|
||||
@@ -479,11 +144,9 @@ as a function that takes a `yield func(Job) bool` callback.
|
||||
- No cloud provider integrations
|
||||
- No auto-scaling
|
||||
- No admission controllers
|
||||
- No complex scheduling algorithms (best-fit only)
|
||||
- No gRPC framework dependency (stdlib net/http with h2c, Go 1.25+ native)
|
||||
- No external PKI / no cert transparency logs (offline-first)
|
||||
- No complex scheduling algorithms
|
||||
|
||||
## Dependency Map (minimal — v0.2 adds zero direct deps)
|
||||
## Dependency Map (minimal)
|
||||
|
||||
```
|
||||
github.com/spf13/cobra # CLI framework
|
||||
@@ -492,37 +155,18 @@ modernc.org/sqlite # SQLite (pure Go)
|
||||
github.com/google/uuid # UUID generation
|
||||
```
|
||||
|
||||
Total: ~4 direct dependencies. No web framework, no ORM, no RPC framework,
|
||||
no PKI library. mTLS via `crypto/tls` and `crypto/x509` (stdlib).
|
||||
Total: ~4 direct dependencies. No web framework, no ORM, no RPC framework.
|
||||
|
||||
> **ConnectRPC note**: `.ciagent/config.json` lists `connectrpc` in
|
||||
> `frameworks`, but the actual `go.mod` does not depend on
|
||||
> `connectrpc.com/connect`. v0.2 falls back to plain `net/http` with
|
||||
> HTTP/2 cleartext (h2c) for `orca.v1.Dispatch`. The protocol is a
|
||||
> simple JSON-over-HTTP POST: client sends
|
||||
> `{"spec": "..."}` to `/orca.v1.Dispatch/Submit`; server replies
|
||||
> `{"job_id": "..."}`. This keeps the zero-new-dep promise and the
|
||||
> codebase coherent with the rest of the daemon's `http.ServeMux`.
|
||||
|
||||
## Deployment Model (v0.2)
|
||||
## Deployment Model
|
||||
|
||||
```
|
||||
Operator Machine Node A (CA holder) Node B (Peer)
|
||||
──────────────── ───────────────── ─────────────
|
||||
orca CLI orca daemon orca daemon
|
||||
│ │ ▲ │ ▲
|
||||
│ mTLS handshake │ │ mTLS │ │
|
||||
│ at `node join` ────────────┼──┘ │ │
|
||||
│ │ │ │
|
||||
│ submit job ───POST────────▶│ POST (cross-node) ──────────▶│
|
||||
│ │ mTLS only │ │
|
||||
│ │ │ │
|
||||
│ ◀───────jobID──────────────│ ◀─────jobID (200 OK)─────────│
|
||||
│ │ │
|
||||
│ orca job list --watch │ │
|
||||
│ (iter.Seq stream) ◀────────│── polls local + stream events│
|
||||
User Machine Server Node
|
||||
┌──────────┐ ┌──────────────────┐
|
||||
│ orca CLI │─────── mTLS ──────────▶│ orca daemon │
|
||||
│ │ │ ├── API server │
|
||||
│ │ │ ├── Engine │
|
||||
│ │ │ └── SQLite store │
|
||||
└──────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
For v0.2, one node must be the CA holder (`orca cert init` was run
|
||||
on it). The CA holder's `ca.crt` is copied to each peer manually by
|
||||
the operator; peers do not auto-fetch it.
|
||||
For v0.1, the CLI and daemon can be the same binary on the same machine. Multi-node is deferred.
|
||||
|
||||
+51
-172
@@ -1,186 +1,65 @@
|
||||
# Ideation: Orca v0.2
|
||||
# Ideation: Orca v0.1
|
||||
|
||||
Full autonomy mode: all ideas with confidence >= 0.60 are auto-accepted. The
|
||||
RESEARCH stage (commit `08d321f`) surfaced 6 REQ candidates (REQ-cand-A..F)
|
||||
which are assessed individually below in addition to the 29 new ideas
|
||||
generated by this stage.
|
||||
|
||||
Total generated: 29 ideas (10 Tier 1 + 11 Tier 2 + 8 Tier 3) plus 6 inherited
|
||||
research candidates = 35 considered. 34 accepted (29 generated + 6
|
||||
research - 1 deferred = 34), 1 explicitly deferred to v0.3 (I-308 pprof).
|
||||
Zero dropped below the 0.60 confidence threshold.
|
||||
Full autonomy mode: all ideas auto-accepted. Three tiers explored.
|
||||
|
||||
## Tier 1: Mechanical (security/quality, automated)
|
||||
|
||||
| ID | Idea | Source | Confidence | Status | Maps to REQ |
|
||||
|-------|----------------------------------------------------------------------|-------------------------|------------|----------|---------------|
|
||||
| I-101 | `govulncheck` runs in offline mode in CI (REQ-cand-C) | mechanical + REQ-cand-C | 0.90 | Accepted | REQ-027 |
|
||||
| I-102 | `gitleaks` baseline file checked into repo for pre-existing .env leak (REQ-cand-E) | mechanical + REQ-cand-E | 0.85 | Accepted | REQ-029 |
|
||||
| I-103 | `go test -race` enabled in CI for all v0.2 packages | mechanical | 0.95 | Accepted | REQ-031 |
|
||||
| I-104 | Cert file mode enforcement: 0600 for keys, 0644 for certs | mechanical | 0.90 | Accepted | REQ-033 |
|
||||
| I-105 | `orca cert show` redacts private key material from output | mechanical | 0.80 | Accepted | REQ-035 |
|
||||
| I-106 | Server certs must carry SAN entries (DNS + IP), enforced at sign-time | mechanical | 0.85 | Accepted | REQ-036 |
|
||||
| I-107 | Cert `serial_hex` UNIQUE constraint in `certs` table | mechanical | 0.80 | Accepted | (refinement of REQ-014's audit-log discipline; no new REQ) |
|
||||
| I-108 | `gofmt` and `goimports` enforced in CI (carry over from v0.1) | mechanical | 0.90 | Accepted | (refinement of REQ-024; no new REQ) |
|
||||
| I-109 | `gosec` baseline JSON (`gosec.json`) committed; CI fails on new findings | mechanical | 0.90 | Accepted | (refinement of REQ-014; no new REQ) |
|
||||
| I-110 | `govulncheck -format json` + wrapper script gates on findings via `jq` | mechanical | 0.90 | Accepted | (implementation detail of REQ-027; no new REQ) |
|
||||
|
||||
### Tier 1 rationale
|
||||
|
||||
- I-103 (race detector) is mechanical and high-impact: v0.2 introduces
|
||||
concurrent mTLS handshakes, the cert hot-swap callback, and the
|
||||
dispatcher queue. Race conditions in any of these would be silent and
|
||||
severe. `-race` adds <2x to test time; the cost is trivial.
|
||||
- I-104 (file mode enforcement) is non-optional for keys: a 0644 server
|
||||
key would be a CVE. Catches `umask 022` and copy-paste mistakes.
|
||||
- I-105 (`orca cert show` redaction) is defensive UI: cert operators
|
||||
often pipe output into chat/email for handoff. Private key bytes
|
||||
must never appear in any default `orca cert` output.
|
||||
- I-106 (SAN enforcement) prevents the operator from issuing a cert
|
||||
with no DNS / IP, which would make it useless for hostname-based
|
||||
mTLS verification.
|
||||
- I-109 (gosec baseline JSON) is already specified in D-016 and the
|
||||
research commit's notes. I-110 (govulncheck exit-on-known) is the
|
||||
same — but a known issue is that the default `govulncheck` mode calls
|
||||
`vuln.go.dev`, which conflicts with offline-first (REQ-003). REQ-027
|
||||
captures the resolution: the CI image must either pre-mirror the DB
|
||||
(GOVULNCHECK_DB env) or use `-format json` + a wrapper that gates on
|
||||
findings (no network).
|
||||
- I-101 and I-102 inherit from the research stage and are explicitly
|
||||
REQ candidates — accepted as REQ-027 and REQ-029.
|
||||
| ID | Idea | Source | Confidence |
|
||||
|----|------|--------|------------|
|
||||
| I-001 | Add `gosec` to CI pipeline | mechanical | 0.95 |
|
||||
| I-002 | Add `govulncheck` to CI pipeline | mechanical | 0.95 |
|
||||
| I-003 | Enable `gofmt` and `goimports` pre-commit checks | mechanical | 0.90 |
|
||||
| I-004 | Pin Go version in `go.mod` (`go 1.25`) | mechanical | 0.95 |
|
||||
| I-005 | Use `log/slog` for all logging (no `fmt.Println` in production) | mechanical | 0.95 |
|
||||
| I-006 | Add `.gitignore` for `bin/`, `coverage.out`, `*.test` | mechanical | 0.95 |
|
||||
| I-007 | Add `LICENSE` (MIT) | mechanical | 0.90 |
|
||||
| I-008 | Add `README.md` with quickstart | mechanical | 0.90 |
|
||||
| I-009 | Use `context.Context` for all I/O | mechanical | 0.95 |
|
||||
| I-010 | Wrap errors with `fmt.Errorf("...: %w", err)` | mechanical | 0.95 |
|
||||
|
||||
## Tier 2: Backend-Enriched (architecture/coverage)
|
||||
|
||||
| ID | Idea | Source | Confidence | Status | Maps to REQ |
|
||||
|-------|----------------------------------------------------------------------|---------|------------|----------|---------------|
|
||||
| I-201 | Bounded cert rotation history: retain last N=3 server certs per node (REQ-cand-A) | backend + REQ-cand-A | 0.85 | Accepted | REQ-025 |
|
||||
| I-202 | Trusted-CA fingerprint pinned in config; daemon refuses to start on mismatch (REQ-cand-B) | backend + REQ-cand-B | 0.85 | Accepted | REQ-026 |
|
||||
| I-203 | HCL/YAML schema for `NodeCapacity` declaration on `orca node join` and/or `~/.orca/node.hcl` (REQ-cand-D) | backend + REQ-cand-D | 0.90 | Accepted | REQ-028 |
|
||||
| I-204 | `--watch` output format mode: table (default) vs streaming one-line JSON (REQ-cand-F) | backend + REQ-cand-F | 0.75 | Accepted | REQ-030 |
|
||||
| I-205 | Cert proactive rotation alarm: audit log + slog WARN when `not_after - now < 30d` | backend | 0.85 | Accepted | REQ-034 |
|
||||
| I-206 | `X-Orca-Idempotency-Key` header on POST; dispatcher retries only when header present | backend | 0.80 | Accepted | REQ-037 |
|
||||
| I-207 | `tls.Config.GetCertificate` hot-swap: atomic file read + sync.Mutex around `*tls.Certificate` | backend | 0.90 | Accepted | (refinement of REQ-011; no new REQ) |
|
||||
| I-208 | CA cert in-memory cache with disk-watcher fallback (avoids disk read on every handshake) | backend | 0.75 | Accepted | (optimization; no new REQ) |
|
||||
| I-209 | Bin-packing with `sort.Slice` on `[]Node` by `AvailableMemory() desc` (best-fit variant) | backend | 0.85 | Accepted | (refinement of P02 bin-pack; no new REQ) |
|
||||
| I-210 | Dispatcher bounded queue: `make(chan SubmitRequest, N)` with N=256; backpressure via channel send | backend | 0.75 | Accepted | (refinement of P02 dispatcher; no new REQ) |
|
||||
| I-211 | `iter.Seq` watch stream polls SQLite + emits; cancellation via `ctx.Done()` | backend | 0.85 | Accepted | (refinement of REQ-022; no new REQ) |
|
||||
| ID | Idea | Source | Confidence |
|
||||
|----|------|--------|------------|
|
||||
| I-011 | Use Cobra for CLI (industry standard) | backend | 0.95 |
|
||||
| I-012 | Use `viper` for config OR hand-rolled HCL parser | backend | 0.85 |
|
||||
| I-013 | Use `hashicorp/hcl` for HCL parsing | backend | 0.90 |
|
||||
| I-014 | Use `modernc.org/sqlite` (CGO-free) | backend | 0.92 |
|
||||
| I-015 | Repository pattern for state access | backend | 0.85 |
|
||||
| I-016 | Use `os/exec` for task execution with `cmd.WaitDelay` (Go 1.25+) | backend | 0.95 |
|
||||
| I-017 | Use `iter.Seq` (Go 1.25+) for streaming job lists | backend | 0.90 |
|
||||
| I-018 | Use `crypto/tls` with self-signed cert generation for mTLS | backend | 0.80 |
|
||||
| I-019 | Use `slog.NewJSONHandler` for structured logs | backend | 0.95 |
|
||||
| I-020 | Add health check HTTP endpoint on configurable port | backend | 0.90 |
|
||||
|
||||
### Tier 2 rationale
|
||||
## Tier 3: Cross-Project (from backlog/coreci patterns)
|
||||
|
||||
- I-201, I-202, I-203, I-204 are research-stage candidates. All are
|
||||
net-new requirements. I-203 is especially important: P02's
|
||||
bin-packing is impossible without an operator-declared capacity.
|
||||
- I-205 (proactive rotation alarm) is operationally important: without
|
||||
it, a node can run on an expired cert (mTLS will fail) and the
|
||||
operator gets paged at the worst time. Emitting a structured
|
||||
WARN-level audit record 30 days out gives `log/slog` JSON consumers
|
||||
a clean alert.
|
||||
- I-206 (`Idempotency-Key`) is already mentioned in ARCHITECTURE.md
|
||||
("only idempotent verbs retried automatically; POST retries require
|
||||
X-Orca-Idempotency-Key"). This stage elevates it to a REQ.
|
||||
- I-207, I-208, I-209, I-210, I-211 are implementation details /
|
||||
refinements of existing REQs (REQ-011, REQ-022, the P02 bin-pack
|
||||
scope, etc.). They are recorded here for the PLAN stage's benefit
|
||||
but do not require new REQs.
|
||||
|
||||
## Tier 3: Cross-Project (from CoreCI patterns)
|
||||
|
||||
| ID | Idea | Source | Confidence | Status | Maps to REQ |
|
||||
|-------|----------------------------------------------------------------------|---------------|------------|----------|---------------|
|
||||
| I-301 | `orca doctor` subcommand: diagnostics for CA/cert health, db integrity, peer reachability | cross-project | 0.85 | Accepted | REQ-032 |
|
||||
| I-302 | Structured log fields for mTLS failures: `event=mtls.handshake`, `peer`, `cert_fp`, `err` | cross-project | 0.85 | Accepted | REQ-038 |
|
||||
| I-303 | `.gitleaks.toml` extended with stopwords for test data paths and CA cert PEM block | cross-project | 0.80 | Accepted | REQ-039 |
|
||||
| I-304 | `.golangci.yml` (or `.golangci.yaml`) for unified lint config superseding per-tool invocations | cross-project | 0.70 | Accepted | REQ-040 |
|
||||
| I-305 | Pre-push hook extended to run `gitleaks protect --staged` and `gosec -no-fail` before push | cross-project | 0.80 | Accepted | (refinement of REQ-013; no new REQ) |
|
||||
| I-306 | Baseline JSON files for gosec and gitleaks committed to `.ciagent/baselines/` | cross-project | 0.85 | Accepted | (implementation detail of REQ-014 / REQ-029) |
|
||||
| I-307 | `orca version --json` outputs structured `{version, commit, go_version, build_time}` | cross-project | 0.70 | Accepted | (refinement of REQ-010; no new REQ) |
|
||||
| I-308 | pprof endpoint on configurable port for `orca daemon` (opt-in via `--pprof :6060`) | cross-project | 0.70 | Deferred (v0.3) | — |
|
||||
|
||||
### Tier 3 rationale
|
||||
|
||||
- I-301 (`orca doctor`) is high-leverage: every cert/CA/network question
|
||||
operators ask maps cleanly to a doctor subcommand. Adds
|
||||
`internal/doctor/` component (see ARCHITECTURE.md update). Examples:
|
||||
`orca doctor` (all checks), `orca doctor cert`, `orca doctor network`.
|
||||
- I-302, I-303, I-304 are CoreCI-pattern cross-pollination: coreci's
|
||||
pipelines all use structured log fields and per-tool config files
|
||||
with stopwords / allowlists. Mirroring that discipline keeps Orca's
|
||||
CI output consumable by humans AND by `jq`/`grep` tools.
|
||||
- I-305 extends the existing v0.1 pre-push hook (REQ-013) with
|
||||
v0.2-relevant checks. Already in D-016 ("gitleaks in pre-commit
|
||||
opt-in"), so this is a refinement, not a new REQ.
|
||||
- I-308 (pprof) is useful for P02 debugging but conflicts with the
|
||||
"minimalist" pillar: it adds a port, an opt-in flag, and a code
|
||||
path. Parked for v0.3 unless the PLAN stage finds a 1-line way to
|
||||
add it. Confidence is 0.70 but the simplicity cost is non-zero.
|
||||
|
||||
## Research-stage REQ candidates (assessed)
|
||||
|
||||
| Candidate | Idea | Verdict | Maps to |
|
||||
|-----------|------|---------|---------|
|
||||
| REQ-cand-A | Bounded cert rotation history (N=3) | **Accepted** (I-201) | REQ-025 (P01) |
|
||||
| REQ-cand-B | Trusted-CA fingerprint pinning in config | **Accepted** (I-202) | REQ-026 (P01) |
|
||||
| REQ-cand-C | govulncheck offline mode | **Accepted** (I-101) | REQ-027 (P03) |
|
||||
| REQ-cand-D | HCL/YAML schema for NodeCapacity | **Accepted** (I-203) | REQ-028 (P02) |
|
||||
| REQ-cand-E | gitleaks baseline for pre-existing .env leak | **Accepted** (I-102) | REQ-029 (P03) |
|
||||
| REQ-cand-F | `--watch` output format mode | **Accepted** (I-204) | REQ-030 (P04) |
|
||||
|
||||
All 6 candidates assessed on their merits. None were rejected; all map
|
||||
to net-new REQs (REQ-025..REQ-030) and to specific phases (P01/P02/P03/P04).
|
||||
|
||||
## Dropped ideas (confidence < 0.60 or non-requirements)
|
||||
|
||||
None. The lowest-confidence accepted idea is I-308 (pprof) at 0.70,
|
||||
which is auto-accepted under full autonomy but explicitly deferred to
|
||||
v0.3 to keep v0.2 lean. The lowest-confidence idea that became a
|
||||
net-new REQ is I-204 (--watch --json mode) at 0.75.
|
||||
| ID | Idea | Source | Confidence |
|
||||
|----|------|--------|------------|
|
||||
| I-021 | Mirror `.coreci.yml` pattern from coreci (validate/build/test/release) | cross-project | 0.95 |
|
||||
| I-022 | Mirror `tea` CLI integration for releases | cross-project | 0.90 |
|
||||
| I-023 | Mirror `lead-developer` persona-driven decomposition | cross-project | 0.90 |
|
||||
| I-024 | Mirror `phase/NN-*` → `milestone/*` → `main` branching | cross-project | 0.95 |
|
||||
| I-025 | Mirror `---ci---` commit block discipline | cross-project | 0.95 |
|
||||
| I-026 | Mirror pre-push hook pattern from coreci (if exists) | cross-project | 0.85 |
|
||||
| I-027 | Mirror Go module structure: `cmd/orca`, `internal/`, `pkg/` | cross-project | 0.95 |
|
||||
| I-028 | Mirror persona territory enforcement (`warn` mode) | cross-project | 0.90 |
|
||||
| I-029 | Mirror security audit logging in all write paths | cross-project | 0.90 |
|
||||
| I-030 | Mirror `Makefile` with `build`, `test`, `lint`, `fmt` targets | cross-project | 0.95 |
|
||||
|
||||
## Accepted Ideas (auto-accepted, full autonomy)
|
||||
|
||||
34 ideas accepted (10 Tier 1 + 11 Tier 2 + 8 Tier 3 + 6 research
|
||||
candidates - 1 deferred = 34). I-308 is recorded as accepted under the
|
||||
full-autonomy rule but explicitly deferred to v0.3 to keep v0.2 lean
|
||||
per the simplicity pillar.
|
||||
All 30 ideas accepted. Implementation in subsequent EXECUTE phases.
|
||||
|
||||
## Resulting REQ Additions
|
||||
|
||||
| New REQ | Title | Phase | Source ideas |
|
||||
|----------|------------------------------------------------|-------|--------------|
|
||||
| REQ-025 | Bounded cert rotation history (N=3) | P01 | I-201 / REQ-cand-A |
|
||||
| REQ-026 | Trusted-CA fingerprint pinning in config | P01 | I-202 / REQ-cand-B |
|
||||
| REQ-027 | govulncheck offline mode in CI | P03 | I-101 / REQ-cand-C |
|
||||
| REQ-028 | HCL/YAML `NodeCapacity` declaration surface | P02 | I-203 / REQ-cand-D |
|
||||
| REQ-029 | gitleaks baseline for pre-existing .env leak | P03 | I-102 / REQ-cand-E |
|
||||
| REQ-030 | `--watch --json` streaming output mode | P04 | I-204 / REQ-cand-F |
|
||||
| REQ-031 | `go test -race` enabled in CI | P01-P04 (cross-cutting) | I-103 |
|
||||
| REQ-032 | `orca doctor` subcommand for diagnostics | P01 (initial), reusable all phases | I-301 |
|
||||
| REQ-033 | Cert file mode enforcement (0600 keys, 0644 certs) | P01 | I-104 |
|
||||
| REQ-034 | Cert proactive rotation alarm (30d before expiry) | P01 | I-205 |
|
||||
| REQ-035 | `orca cert show` redaction of private key material | P01 | I-105 |
|
||||
| REQ-036 | Cert SAN validation (DNS + IP entries) | P01 | I-106 |
|
||||
| REQ-037 | `X-Orca-Idempotency-Key` header on POST | P02 | I-206 |
|
||||
| REQ-038 | Structured log fields for mTLS failures | P01 | I-302 |
|
||||
| REQ-039 | `.gitleaks.toml` extension with stopwords | P03 | I-303 |
|
||||
| REQ-040 | `.golangci.yml` unified lint config | P03 | I-304 |
|
||||
|
||||
**Total net-new REQs**: 16 (REQ-025..REQ-040). 16 new requirements on
|
||||
top of the 4 v0.2 REQs carried over from v0.1 (REQ-011, REQ-014,
|
||||
REQ-022, REQ-023) = 20 v0.2 requirements total.
|
||||
|
||||
## Deferred (recorded but not v0.2)
|
||||
|
||||
- I-308: pprof endpoint on `orca daemon` (deferred to v0.3 — keep v0.2 lean).
|
||||
|
||||
## Followup notes for PLAN stage
|
||||
|
||||
- The PLAN stage should pair REQ-031 (race detector) with the test
|
||||
scaffolding in P01 — even P01 needs `-race` because the cert hot-swap
|
||||
path is concurrent.
|
||||
- REQ-027 (govulncheck offline mode) needs a decision in PLAN: pre-mirror
|
||||
the DB inside the CoreCI image, or use the `-format json` + `jq`
|
||||
wrapper. The research notes both are viable; PLAN chooses.
|
||||
- REQ-028 (NodeCapacity) is a P02 enabler; the PLAN entry for P02 must
|
||||
land REQ-028's HCL schema before the bin-packing code can be written.
|
||||
- REQ-032 (orca doctor) is small but touches multiple components; PLAN
|
||||
should sequence it after P01's cert code lands so the doctor checks
|
||||
can actually inspect cert state.
|
||||
- REQ-014: `gosec` + `govulncheck` in CI (I-001, I-002)
|
||||
- REQ-015: MIT LICENSE (I-007)
|
||||
- REQ-016: README.md with quickstart (I-008)
|
||||
- REQ-017: `context.Context` propagation (I-009)
|
||||
- REQ-018: Error wrapping with `%w` (I-010)
|
||||
- REQ-019: Cobra CLI framework (I-011)
|
||||
- REQ-020: HCL parser integration (I-013)
|
||||
- REQ-021: `os/exec` with `WaitDelay` (I-016)
|
||||
- REQ-022: `iter.Seq` for streaming (I-017)
|
||||
- REQ-023: Self-signed mTLS cert generation (I-018)
|
||||
- REQ-024: `Makefile` with standard targets (I-030)
|
||||
|
||||
+8
-49
@@ -5,14 +5,10 @@ active_personas:
|
||||
- data-engineer
|
||||
- cli-engineer
|
||||
- security-engineer
|
||||
- network-engineer
|
||||
deactivated_personas:
|
||||
- frontend-engineer
|
||||
- devops-sre
|
||||
phase_specific:
|
||||
- security-engineer
|
||||
- network-engineer
|
||||
- cli-engineer
|
||||
phase_specific: []
|
||||
reason: |
|
||||
Orca is a CLI-first, offline-first orchestration engine with no web UI and
|
||||
a single-binary distribution model. The persona roster reflects this:
|
||||
@@ -21,18 +17,12 @@ reason: |
|
||||
- backend-engineer: core engine and API handlers
|
||||
- data-engineer: SQLite state store and migrations
|
||||
- cli-engineer: Cobra subcommands and CLI UX
|
||||
- security-engineer: mTLS, cert lifecycle, audit logging, input validation
|
||||
- network-engineer: transport layer, dispatcher, peer-to-peer resilience
|
||||
- security-engineer: mTLS, audit logging, input validation
|
||||
|
||||
Deactivated:
|
||||
- frontend-engineer: no web UI in v0.1
|
||||
- devops-sre: no container/cloud integrations; release flow is
|
||||
handled by CoreCI (not a persona territory)
|
||||
|
||||
Phase-specific (v0.2):
|
||||
- security-engineer: P01 (mTLS/CA) + P02 (peer transport hardening)
|
||||
- network-engineer: P02 only (multi-node scheduling & dispatch)
|
||||
- cli-engineer: P04 only (--watch flag is a CLI concern)
|
||||
---
|
||||
|
||||
# Personas: Orca
|
||||
@@ -57,7 +47,7 @@ reason: |
|
||||
- **Domain**: data
|
||||
- **Frameworks**: `modernc/sqlite`
|
||||
- **Constraints**: `schema-first`, `migration-safe`, `local-storage-only`
|
||||
- **Territory**: `**/store/**`, `**/model.go`, `**/migration*`, `migrations/**`, `internal/store/migrations/0004_certs.sql`
|
||||
- **Territory**: `**/store/**`, `**/model.go`, `**/migration*`, `migrations/**`
|
||||
- **Active**: true
|
||||
|
||||
### cli-engineer (custom)
|
||||
@@ -70,21 +60,11 @@ reason: |
|
||||
|
||||
### security-engineer (custom)
|
||||
- **Domain**: security
|
||||
- **Frameworks**: `crypto/tls`, `crypto/x509`, `slog`
|
||||
- **Constraints**: `no-panic-in-production`, `structured-audit-logging`, `no-secret-in-logs`, `input-validation`, `least-privilege`
|
||||
- **Territory**: `**/auth/**`, `**/audit/**`, `internal/security/**`, `internal/transport/**` (TLS config only)
|
||||
- **Frameworks**: `crypto/tls`, `slog`
|
||||
- **Constraints**: `no-panic-in-production`, `structured-audit-logging`, `no-secret-in-logs`, `input-validation`
|
||||
- **Territory**: `**/auth/**`, `**/audit/**`, `internal/security/**`
|
||||
- **Active**: true
|
||||
- **Reason**: mTLS, audit logging, and input validation are first-class concerns.
|
||||
- **Phase scope**: P01 (mTLS + internal CA), P02 (transport hardening for peer handshakes). Deactivates after P02 ships — P03/P04 have lighter security needs.
|
||||
|
||||
### network-engineer (custom, NEW in v0.2)
|
||||
- **Domain**: networking
|
||||
- **Frameworks**: `net/http`, `crypto/tls` (via `internal/security`), `iter`
|
||||
- **Constraints**: `connection-resilience`, `retry-with-backoff`, `graceful-disconnect`, `context-propagation`
|
||||
- **Territory**: `**/transport/**`, `**/engine/dispatcher*`, `**/engine/peer*`, `internal/engine/dispatcher.go`, `internal/transport/**`
|
||||
- **Active**: true
|
||||
- **Reason**: v0.2 introduces cross-node dispatch and peer-to-peer transport. This persona owns the transport layer, dispatcher, and peer lifecycle concerns that are distinct from the API-handler territory of `backend-engineer`.
|
||||
- **Phase scope**: P02 only. Deactivates after P02 ships.
|
||||
|
||||
### frontend-engineer
|
||||
- **Active**: false
|
||||
@@ -100,27 +80,6 @@ reason: |
|
||||
- **Behavior**: Out-of-territory file changes log a warning but do not block.
|
||||
- **Rationale**: Allows flexibility during early development; tighten to `strict` post-v0.1.
|
||||
|
||||
## Phase-Specific Personas (v0.2)
|
||||
## Phase-Specific Personas
|
||||
|
||||
| Persona | Active in | Reason |
|
||||
|---------|-----------|--------|
|
||||
| `security-engineer` | P01, P02 | mTLS/CA in P01, transport hardening in P02. Lighter security needs in P03 (CI scanning) and P04 (streaming UX). |
|
||||
| `network-engineer` | P02 | Multi-node dispatch is a P02 concern only. P01 builds the transport primitives but P02 wires them into cross-node scheduling. |
|
||||
| `cli-engineer` | P04 | The `--watch` flag is a CLI surface; P01-P03 don't add new CLI commands. |
|
||||
|
||||
In full-autonomy mode, all personas are auto-accepted and the phase-scope
|
||||
assignments are applied automatically when a phase is committed.
|
||||
|
||||
## Migration from v0.1
|
||||
|
||||
- `backend-engineer` territory unchanged: `internal/daemon/**` still owns HTTP
|
||||
handlers. The new `internal/transport/**` package is shared with
|
||||
`network-engineer` but `transport` owns the *connection lifecycle* (dial,
|
||||
retry, close) while `daemon` owns the *request handlers*.
|
||||
- `data-engineer` territory expanded to include the new
|
||||
`internal/store/migrations/0004_certs.sql` migration in P01.
|
||||
- `security-engineer` territory extended from `internal/security/**` to
|
||||
include the TLS-config portion of `internal/transport/**` (the
|
||||
`NewServerTLSConfig` / `NewClientTLSConfig` helpers).
|
||||
- `cli-engineer` territory unchanged; the new `orca cert` subcommands in P01
|
||||
fall under the existing `internal/cli/**` glob.
|
||||
None for v0.1. All personas persist across all 6 phases.
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
---
|
||||
description: P07 Layer-3 security audit finding — pre-existing .env secret leak in git history at 0cba1aa
|
||||
---
|
||||
|
||||
# Phase 7 Security Audit Finding
|
||||
|
||||
**Severity**: P0 (secret in git history)
|
||||
**Status**: Mitigated going forward; full remediation requires human action
|
||||
**Found by**: ciagent verify (Layer 3 — security) during P07 EXECUTE
|
||||
**Commit in history**: `0cba1aa` — `chore(P00): set autonomy level to full`
|
||||
|
||||
## Finding
|
||||
|
||||
The `.env` file (containing `GITEA_TOKEN=795e...67aa` and `GITEA_USER=cloudinit-bot`)
|
||||
was committed in `0cba1aa` during P00 and has remained in git history since.
|
||||
It is reachable on the `main` branch and all descendant branches.
|
||||
|
||||
The pre-P07 `.gitignore` listed only `.env.local`, so `.env` was tracked.
|
||||
|
||||
## Immediate Mitigations Applied in P07
|
||||
|
||||
1. Added `.env` to `.gitignore` (matches `.env.local` discipline).
|
||||
2. Confirmed `scripts/backfill_releases.sh` does not echo the token, does
|
||||
not pass it as a CLI argument to `tea`, and sources it from `.env` only.
|
||||
3. Confirmed `tea` is configured to use this token via its own config and
|
||||
the script invokes `tea releases create` without `--token` flags.
|
||||
4. Documented the leak here for human review.
|
||||
|
||||
## Required Human Actions (out of CI scope)
|
||||
|
||||
1. **Rotate the Gitea token**: the leaked value is in the public-on-this-forge
|
||||
git history. Treat it as compromised; generate a new token at
|
||||
<https://git.cloudinit.dev/user/settings/applications> and update `.env`.
|
||||
2. **Rewrite history to scrub the secret** (optional but recommended):
|
||||
- `git filter-repo --invert-paths --path .env` and force-push all
|
||||
branches, OR
|
||||
- use `git-filter-repo` via BFG Repo-Cleaner.
|
||||
- This is a destructive operation; coordinate with all consumers.
|
||||
3. **Audit Gitea access logs** for the period the token was exposed to
|
||||
detect any unauthorized use.
|
||||
4. **Add CI secret scanning**: integrate `gitleaks` or `trufflehog` into
|
||||
the `validate` pipeline (deferred to v0.2 alongside REQ-014
|
||||
`gosec`+`govulncheck`).
|
||||
|
||||
## P07 Continues
|
||||
|
||||
P07 EXECUTE continues (no P0 code change required for the milestone tag
|
||||
itself; the backfill script is safe and the existing token still works for
|
||||
its purpose). The P07 ship → v0.1 milestone → main flow proceeds, but
|
||||
REVIEW/AUDIT must flag this for the milestone close-out.
|
||||
|
||||
## Forward-Looking Rule (proposed for v0.2)
|
||||
|
||||
- `pre-commit` hook runs `gitleaks protect --staged` and rejects any
|
||||
commit that adds a secret.
|
||||
- `.env*` is in `.gitignore` from the first commit of v0.2 onward.
|
||||
- `ciagent-init` warns loudly if `git log --all -- .env` returns anything.
|
||||
@@ -163,179 +163,3 @@ For v0.1, `parallelization.enabled=false` — phases run sequentially.
|
||||
- **Milestone type**: `feature` (Phases 1-6 all produce features)
|
||||
- **Patch per phase**: `v0.1.1`, `v0.1.2`, ..., `v0.1.6`
|
||||
- **Final tag on COMPLETE**: `v0.2.0` (next minor per `run.md` versioning logic)
|
||||
|
||||
---
|
||||
|
||||
# Phase Plans: Orca v0.2
|
||||
|
||||
All 4 phases with vertical-slice structure, wave ordering, and REQ-ID mapping.
|
||||
v0.2 scope: **Networking, Observability, Security Hardening** — extends v0.1
|
||||
with secure cross-node transport, multi-node scheduling, richer CI security
|
||||
scanning, and streaming I/O.
|
||||
|
||||
Branching convention: branches are numbered after v0.2's milestone branch
|
||||
`milestone/v0.2-networking-observability-security`. v0.2's P01 uses phase
|
||||
number `08`, P02 uses `09`, etc., to avoid colliding with v0.1's
|
||||
`phase/01..07` branches (see `RELEASE_POLICY.md` and `run.md` for tag
|
||||
hygiene). Milestone branch name in heading reflects the v0.1 retcon where
|
||||
P00-P07 are the v0.1 work; v0.2's first phase is the eighth phase of the
|
||||
project overall.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: mTLS Handshake + Internal CA with CSR Join (Wave 1)
|
||||
|
||||
**Branch**: `phase/08-mtls`
|
||||
**REQ Coverage**: REQ-011, REQ-023, REQ-025, REQ-026, REQ-032, REQ-033, REQ-034, REQ-035, REQ-036, REQ-038
|
||||
|
||||
### Must-Haves
|
||||
|
||||
- [ ] `internal/security/ca.go` — CA init, sign CSR, CA cert persistence to `~/.orca/ca.crt` (0644) and `~/.orca/ca.key` (0600) per REQ-033
|
||||
- [ ] `internal/security/csr.go` — CSR generation from a private key with SANs populated (REQ-036)
|
||||
- [ ] `internal/security/certgen_test.go` — round-trip test: CA-init → build CSR → sign → verify the chain programmatically
|
||||
- [ ] `internal/security/fingerprint.go` — `Fingerprint(certPath) (sha256hex, error)` (used by `orca cert join --ca-fingerprint`)
|
||||
- [ ] `internal/security/tls_config.go` — `ServerTLSConfig()` and `ClientTLSConfig(caPath)` builders, with `MinVersion = tls.VersionTLS13` and AEAD cipher allowlist
|
||||
- [ ] `internal/security/rotation.go` — proactive rotation alarm: returns WARN 30d before `not_after` (REQ-034); history table bounded at 10 generations per cert kind (REQ-025)
|
||||
- [ ] `internal/security/redact.go` — `orca cert show` redaction: strips private key material from default and `--json` output (REQ-035)
|
||||
- [ ] `internal/store/migrations/0004_certs.sql` — `certs` table (`id`, `kind`, `node_id`, `serial_hex`, `subject_cn`, `issuer_cn`, `not_before`, `not_after`, `fingerprint`, `source_path`, `created_at`) plus indexes
|
||||
- [ ] `internal/store/cert_repo.go` — CRUD for the `certs` table; rotation history pruning helper (REQ-025)
|
||||
- [ ] `internal/daemon/tls.go` — mTLS server bootstrap; `GetCertificate` hot-swap callback so `orca cert renew` takes effect without daemon restart
|
||||
- [ ] `internal/transport/mtls.go` — mTLS client with cipher allowlist; SAN validation against the pinned peer identity (REQ-036)
|
||||
- [ ] `internal/transport/handshake_log.go` — structured slog fields on mTLS failure: `event=mtls.handshake`, `peer`, `cert_fp`, `err` (REQ-038)
|
||||
- [ ] `internal/audit/audit.go` — emit `cert.issued`, `cert.renewed`, `cert.joined`, `node.handshake_ok`, `node.handshake_failed` entries
|
||||
- [ ] `internal/cli/cert.go` — `orca cert {gen,ca-init,csr,show,renew}` subcommands
|
||||
- [ ] `internal/cli/node_join.go` (extend v0.1 stub) — `orca node join --ca-fingerprint <sha256>` verifies on-disk CA matches the pinned value (REQ-026); refuses to start the daemon on mismatch
|
||||
- [ ] `internal/cli/doctor.go` (NEW package `internal/doctor`) — `orca doctor`, `orca doctor cert`, `orca doctor network`, `orca doctor db` subcommands (REQ-032; `network` and `db` checks may stub in P01, full impl in later phases)
|
||||
- [ ] Config surface: `~/.orca/orca.hcl` gains a `trusted_ca_fingerprint` field consumed at daemon start (REQ-026)
|
||||
- [ ] Unit tests for: file mode enforcement (REFUSE on wrong mode, REQ-033), rotation alarm firing at 30d, redaction in `cert show`, fingerprint mismatch at `node join`
|
||||
- [ ] Integration test: two-node mTLS handshake — node A signs node B's CSR; node B dials node A and `/healthz` returns 200; cross-signed with a non-matching CA returns a structured `mtls.handshake` failure log line
|
||||
|
||||
### Verification
|
||||
|
||||
- `go build ./...` PASS
|
||||
- `go test ./internal/security/... ./internal/store/... ./internal/transport/...` PASS
|
||||
- `go test -race ./...` PASS (REQ-031 cross-cutting)
|
||||
- `orca cert ca-init` produces a valid CA; `ca.crt` is 0644, `ca.key` is 0600; daemon refuses to start if either is wrong (REQ-033)
|
||||
- `orca cert gen` produces a server cert signed by the CA, with DNS and IP SANs present (REQ-036); CSR without SANs is rejected at sign-time
|
||||
- `orca node join --ca-fingerprint <sha>` dials over mTLS; handshake succeeds when CA matches, fails (and logs `event=mtls.handshake peer=... cert_fp=... err=...`) when it does not (REQ-026, REQ-038)
|
||||
- `orca cert renew` rotates the cert without daemon restart (hot-swap via `GetCertificate`); new connections use the new cert
|
||||
- `orca cert show` (default and `--json`) never prints private key material (REQ-035)
|
||||
- Cert rotation history is bounded: inserting an 11th cert per `(node_id, kind)` prunes the oldest (REQ-025)
|
||||
- Proactive rotation alarm: a cert with `not_after` 30d from now triggers a structured WARN at daemon start (REQ-034)
|
||||
- `orca doctor cert` reports PASS/WARN/FAIL for CA, server cert, expiry window, and fingerprint pin match (REQ-032)
|
||||
- Every cert issuance produces an `audit_log` row with `event` and `cert_fp`
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Multi-Node Scheduling & Job Dispatch (Wave 1)
|
||||
|
||||
**Branch**: `phase/09-scheduling`
|
||||
**REQ Coverage**: REQ-004 (expansion), REQ-017, REQ-021, REQ-028, REQ-037
|
||||
|
||||
### Must-Haves
|
||||
|
||||
- [ ] `internal/transport/dispatch.go` — JSON-over-HTTP `orca.v1.Dispatch` service via stdlib h2c (no ConnectRPC — not in go.mod per research); routes `POST /orca.v1.Dispatch/Submit` and `POST /orca.v1.Dispatch/Status`
|
||||
- [ ] `internal/transport/idempotency.go` — `X-Orca-Idempotency-Key` header parsing; server-side dedupe store (REQ-037); client-side retry only when header is present
|
||||
- [ ] `internal/transport/retry.go` — exponential backoff with jitter (100ms, x2, cap 5s, max 5 attempts); only idempotent verbs auto-retry without the key
|
||||
- [ ] `internal/engine/dispatcher.go` — `Submit(peerID, spec) (jobID, error)` blocking call; bin-pack selector falls through to remote peer when local node cannot fit
|
||||
- [ ] `internal/engine/scheduler.go` (extend v0.1) — best-fit bin-packing by `available_cpu` and `available_memory`; within-node FIFO queue
|
||||
- [ ] `internal/engine/peer.go` — peer registry: in-memory map plus SQLite-persisted; records `last_seen`, address, capacity snapshot
|
||||
- [ ] `internal/store/migrations/0005_node_capacity.sql` — `node_capacity` table (`node_id`, `cpu_millicores`, `memory_mib`, `disk_mib`, `updated_at`)
|
||||
- [ ] `internal/store/capacity_repo.go` — capacity CRUD
|
||||
- [ ] HCL schema for `NodeCapacity` (REQ-028): `cpu_millicores`, `memory_mib`, `disk_mib`; loaded from `~/.orca/node.hcl` at `orca node join` and CLI flags
|
||||
- [ ] `internal/cli/node_capacity.go` — `orca node capacity --set` and `orca node capacity` subcommands
|
||||
- [ ] `internal/cli/job_run.go` (extend v0.1) — `orca job run --target <node-id>` explicit target (overrides bin-pack); `orca job run` (no target) lets the dispatcher pick best-fit
|
||||
- [ ] `internal/daemon/dispatch_handler.go` — mTLS-protected endpoints for `Submit` and `Status`; honors `X-Orca-Idempotency-Key` for dedupe
|
||||
- [ ] Cancellation propagation: `context.Context` flows from CLI → daemon → executor → transport → peer; ctrl-c aborts the local task AND the in-flight dispatch call (REQ-017)
|
||||
- [ ] Unit tests: bin-pack scoring (3 jobs across 2 nodes picks the node with the most free capacity each time); idempotency dedupe; retry only on transient errors; cancellation teardown
|
||||
- [ ] Integration test: two-node dispatch — job submitted to node A with no local capacity, dispatched to node B over mTLS, returns the job ID issued by node B; ctrl-c mid-run aborts both sides cleanly
|
||||
|
||||
### Verification
|
||||
|
||||
- `go build ./...` PASS
|
||||
- `go test ./internal/engine/... ./internal/transport/... ./internal/store/...` PASS
|
||||
- `go test -race ./...` PASS (REQ-031 cross-cutting)
|
||||
- Two-node integration test: `orca job run spec.hcl` on node A with insufficient local capacity dispatches to node B and returns node B's job ID
|
||||
- Cancellation: `Ctrl-C` during a dispatched job aborts the local call AND the in-flight `POST /orca.v1.Dispatch/Submit`; no orphan goroutines (assert with `goleak`)
|
||||
- Idempotency: same `X-Orca-Idempotency-Key` submitted twice within the dedupe window returns the same job ID and does NOT create a duplicate row
|
||||
- Bin-packing: 3 jobs across 2 nodes, each picks the node with the most free capacity (deterministic test)
|
||||
- `orca node capacity --set` updates the persisted `node_capacity` row; subsequent dispatches see the new value
|
||||
- `X-Orca-Idempotency-Key` header is REQUIRED for `POST /orca.v1.Dispatch/Submit` retries; absent header + transient error → no retry
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: `gosec` + `govulncheck` + `gitleaks` in CI (Wave 2)
|
||||
|
||||
**Branch**: `phase/10-security-scan`
|
||||
**REQ Coverage**: REQ-014, REQ-027, REQ-029, REQ-031, REQ-039, REQ-040
|
||||
|
||||
### Must-Haves
|
||||
|
||||
- [ ] `.coreci.yml` `validate` pipeline: add `gosec`, `govulncheck`, `gitleaks` stages in this order; `make security-scan` is the local equivalent
|
||||
- [ ] `scripts/security_scan.sh` — wrapper that runs all three tools, exits non-zero on any unsuppressed finding
|
||||
- [ ] `gosec.json` baseline: initial run via `gosec -fmt json -no-fail > gosec.json`; committed to the repo; empty baseline (clean repo) so any new G101 (hardcoded credentials) finding fails the build
|
||||
- [ ] `govulncheck` invocation: runs in **offline mode** per REQ-027 — use `GOFLAGS=-mod=mod` and `GOVULNDB=offline` (or pre-mirrored DB via `GOVULNCHECK_DB`); the chosen mechanism is documented in `docs/security-scanning.md`
|
||||
- [ ] `govulncheck` output gate: `govulncheck -format json ./...` piped through a small Go program (or `jq`) that exits non-zero on any unsuppressed finding
|
||||
- [ ] `.gitleaks.toml` (REQ-039) — allowlist `-----BEGIN CERTIFICATE-----` PEM blocks; flag `-----BEGIN RSA PRIVATE KEY-----`; stopwords for `internal/security/testdata/` paths
|
||||
- [ ] `.gitleaks-baseline.json` (REQ-029) — baseline file committed to suppress the pre-existing `.env` SHA-1 leak from v0.1 history (rotated forward; baseline gates future re-leaks)
|
||||
- [ ] `.golangci.yml` (REQ-040) — unified lint config: `gosec`, `govet`, `gofmt`, `ineffassign`, `misspell` linters; supersedes any per-tool invocations
|
||||
- [ ] `.githooks/pre-commit` — gitleaks protect; commits remain allowed when gitleaks is not installed (gate, not block)
|
||||
- [ ] `Makefile` — add `make test-race` target that runs `go test -race ./...` (REQ-031); wire into `.coreci.yml` `validate` pipeline
|
||||
- [ ] `Makefile` — add `make security-scan` target that invokes `scripts/security_scan.sh`
|
||||
- [ ] `docs/security-scanning.md` — operator-facing doc: what each tool checks, how the offline mode is achieved, how to add a baseline entry
|
||||
|
||||
### Verification
|
||||
|
||||
- `.coreci.yml` parses (yaml validation) and `make validate` is green locally
|
||||
- `make security-scan` runs all three tools and returns 0 on a clean working tree
|
||||
- `gosec`: introducing a new `G101` (hardcoded credential) finding in a Go file causes `make security-scan` to fail
|
||||
- `govulncheck`: with `GOFLAGS=-mod=mod`, the run completes without network access (offline mode) — verified by running the CI step under a network namespace that blocks outbound HTTPS to `vuln.go.dev`; unsuppressed CVE in a dep still fails the build
|
||||
- `gitleaks`: a sample secret injected into a test file is detected; a `-----BEGIN CERTIFICATE-----` PEM block in `internal/security/testdata/` is allowed (not flagged)
|
||||
- `make test-race` passes against the current test suite (REQ-031 cross-cutting)
|
||||
- `.gitleaks-baseline.json` round-trips: re-running the gitleaks pre-commit hook does not re-flag the historical `.env` SHA-1
|
||||
- `.golangci.yml` `make lint` is green against the current code
|
||||
|
||||
---
|
||||
|
||||
## Phase 11: `iter.Seq` Streaming Job/Node Lists (Wave 2)
|
||||
|
||||
**Branch**: `phase/11-iter-seq`
|
||||
**REQ Coverage**: REQ-022, REQ-030, REQ-032 (expansion)
|
||||
|
||||
### Must-Haves
|
||||
|
||||
- [ ] `internal/store/iter.go` — `Watch(ctx, query) iter.Seq[T]` for jobs and nodes; poll-based at 500ms initially, with an internal notify channel hook so a future event-driven source can replace the poll without API churn
|
||||
- [ ] `internal/store/iter_test.go` — round-trip: insert N rows, range over `Watch`, assert all N are yielded; cancel mid-stream and assert the seq stops cleanly with no goroutine leak (`goleak` or `runtime.NumGoroutine` snapshot)
|
||||
- [ ] `internal/cli/job_list.go` (extend v0.1) — `orca job list --watch` returns `iter.Seq[Job]`; default output is a human-readable table that updates; `orca job list --watch --json` outputs one JSON object per line for piping (REQ-030)
|
||||
- [ ] `internal/cli/node_list.go` (extend v0.1) — `orca node list --watch` returns `iter.Seq[Node]`; same table/JSON split as jobs
|
||||
- [ ] Cancellation wiring: `signal.NotifyContext(parent, os.Interrupt)` — ctrl-c stops the stream cleanly without orphan goroutines
|
||||
- [ ] `internal/doctor/` (extend P01 stub) — `orca doctor jobs`, `orca doctor nodes`, `orca doctor certs` stream results as `iter.Seq[DoctorResult]`; each row carries a status (`PASS|WARN|FAIL`) and a human-readable message (REQ-032 expansion)
|
||||
- [ ] Unit tests: `--watch` mode yields on insert; `--watch --json` produces one JSON object per line (line-by-line parse); `orca doctor certs` lists all certs with expiry and rotation status
|
||||
- [ ] Integration test: start `orca job list --watch` as a subprocess, insert a new job, assert the subprocess output contains the new job's ID
|
||||
|
||||
### Verification
|
||||
|
||||
- `go build ./...` PASS
|
||||
- `go test ./internal/store/... ./internal/cli/... ./internal/doctor/...` PASS
|
||||
- `go test -race ./...` PASS (REQ-031 cross-cutting)
|
||||
- `orca job list --watch` streams and updates on new job insertion (integration test, two-process or two-goroutine)
|
||||
- `orca job list --watch --json` produces one JSON object per line (NDJSON); validatable by piping through `jq -c .`
|
||||
- `orca doctor certs` lists every cert with its `not_after`, days-until-expiry, and rotation status (REQ-032 expansion; ties into P01's cert health checks)
|
||||
- `Ctrl-C` during a watch cleanly cancels the seq; `runtime.NumGoroutine()` returns to the pre-watch baseline (asserted in tests via `goleak.VerifyNone` or a manual snapshot diff)
|
||||
- `orca node list --watch --json` behaves identically to the jobs variant
|
||||
|
||||
---
|
||||
|
||||
## Wave Ordering
|
||||
|
||||
- **Wave 1** (Phases 8-9): Networking & scheduling — mTLS handshake and internal CA (P01) is a hard prerequisite for cross-node dispatch (P02), since the dispatch endpoints are mTLS-protected. Both phases run sequentially because `parallelization.enabled=false`.
|
||||
- **Wave 2** (Phases 10-11): Security scan & streaming I/O — security scanning (P03) and `iter.Seq` streaming (P04) are independent; `parallelization.enabled=false` so they run sequentially, but either order is technically viable. P03 first keeps the security baseline in place while P04 lands the new CLI surface.
|
||||
|
||||
Phases within a wave can be parallelized if `parallelization.enabled=true`.
|
||||
For v0.2, `parallelization.enabled=false` — phases run sequentially.
|
||||
|
||||
## Versioning
|
||||
|
||||
- **Milestone type**: `feature` (all 4 phases ship features)
|
||||
- **Patch per phase**: `v0.2.1` (P01 mTLS), `v0.2.2` (P02 scheduling), `v0.2.3` (P03 security scan), `v0.2.4` (P04 iter.Seq)
|
||||
- **Final tag on COMPLETE**: `v0.3.0` (next minor per `run.md` versioning logic; per `RELEASE_POLICY.md`, every per-phase tag also produces a Gitea release)
|
||||
|
||||
+1
-35
@@ -35,46 +35,12 @@ Build a lightweight system to manage and execute workloads across a set of nodes
|
||||
| D-008 | Container runtime? | **Direct process execution (no container runtime) for v0.1** | Avoids the Docker/container dependency. Pure process management. | 0.85 |
|
||||
| D-009 | Configuration file location? | **`~/.orca/config.hcl` and `/etc/orca/orca.hcl`** | Standard XDG-style paths. | 0.90 |
|
||||
| D-010 | Logging format? | **Structured JSON via `log/slog`** | Native Go 1.21+ slog, no external dependency. | 0.95 |
|
||||
| D-011 | v0.2 mTLS cert authority model? | **Internal CA with CSR join** | One node bootstraps a local CA; peers generate CSRs and submit them to the CA for signing. CA cert is the trust anchor. More secure than self-signed per-node (single trust root) without the operational complexity of an external PKI. | 0.92 |
|
||||
| D-012 | v0.2 CA bootstrap & cert distribution? | **Operator-mediated, fingerprint-verified** | Bootstrap node writes `~/.orca/ca.crt` and `~/.orca/ca.key` (mode 0600). Operator copies `ca.crt` to peers; peers verify by SHA-256 fingerprint at `orca node join --ca-fingerprint <sha256>`. No automated secret distribution. | 0.85 |
|
||||
| D-013 | v0.2 cert validity & rotation? | **Server certs 90 days, CA cert 10 years, rotate 30 days before expiry** | Server certs are short-lived (compromise window small); CA is long-lived (manual rotation is expensive). `orca cert renew` reissues server certs automatically. | 0.90 |
|
||||
| D-014 | v0.2 mTLS handshake timing? | **Eager — at `orca node join` time** | Fail fast on bad certs, misconfigurations, or CA mismatches. Lazy handshake would let stale configs run until first request, complicating debugging. | 0.88 |
|
||||
| D-015 | v0.2 minimum TLS version & cipher suites? | **TLS 1.3 only; AEAD cipher allowlist** | MinVersion=tls.VersionTLS13, CipherSuites limited to TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256, TLS_AES_128_GCM_SHA256. No TLS 1.2 fallback. | 0.92 |
|
||||
| D-016 | v0.2 security-scanning placement? | **`validate` pipeline of `.coreci.yml`, gates merges to main** | `gosec` baseline JSON checked into repo; new findings fail the build. `govulncheck ./...` exit-on-known. Pre-commit hook with `gitleaks` is opt-in (developer machine). | 0.85 |
|
||||
| D-017 | v0.2 `iter.Seq` API surface? | **`orca job list --watch` and `orca node list --watch`** | Pull-based `iter.Seq[Job]` / `iter.Seq[Node]`; cancellation via `context.Context`; `signal.NotifyContext` on ctrl-c. Backpressure is implicit (consumer-driven). | 0.90 |
|
||||
| D-018 | v0.2 multi-node scheduling algorithm? | **Bin-packing by available CPU/memory, FIFO within a node** | Simple, deterministic, matches D-004 minimalism. Cross-node dispatch via ConnectRPC `orca.v1.Dispatch` service. Retry on transient failures with exponential backoff. | 0.85 |
|
||||
|
||||
## Out of Scope
|
||||
- Full-blown Kubernetes-compatible API.
|
||||
- Complex cloud-provider integrations.
|
||||
- GUI-based management consoles.
|
||||
- Multi-node scheduling.
|
||||
- Container runtime integration.
|
||||
- Service mesh / sidecar injection.
|
||||
- Auto-scaling / horizontal pod autoscaler.
|
||||
- External PKI / Let's Encrypt / cert transparency logs.
|
||||
- gRPC framework dependency (ConnectRPC in `config.json` frameworks but
|
||||
not in `go.mod`; v0.2 uses stdlib `net/http` with h2c for
|
||||
`orca.v1.Dispatch` — see ARCHITECTURE.md AD-014).
|
||||
|
||||
## v0.2 Scope Summary
|
||||
|
||||
v0.2 is a focused 4-phase milestone that turns Orca from a single-node
|
||||
process executor into a small cluster engine with strong transport
|
||||
security and richer I/O. The 4 phases are:
|
||||
|
||||
- **P01 — mTLS handshake + internal CA with CSR join.** Internal CA, CSR
|
||||
join, eager handshake at `node join`, TLS 1.3 + AEAD allowlist.
|
||||
See ARCHITECTURE.md Flow 1 + Flow 2.
|
||||
- **P02 — Multi-node scheduling & job dispatch.** Best-fit bin-packing by
|
||||
CPU/memory, FIFO within a node, `orca.v1.Dispatch` over mTLS. See
|
||||
ARCHITECTURE.md Flow 3.
|
||||
- **P03 — `gosec` + `govulncheck` + `gitleaks` in CI.** `gosec` baseline
|
||||
JSON in repo, `govulncheck ./...` in `validate` pipeline, `gitleaks`
|
||||
in pre-commit (opt-in).
|
||||
- **P04 — `iter.Seq` streaming for `--watch` flags.** Go 1.25+ range-over-func
|
||||
semantics, `context.Context` cancellation, `signal.NotifyContext` on
|
||||
ctrl-c. See ARCHITECTURE.md Flow 4.
|
||||
|
||||
The vision ("minimalist, offline-first, CLI-first orchestration engine")
|
||||
is unchanged. v0.2 is a hardening + small-cluster extension, not a
|
||||
direction change.
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
description: CIAgent release and shipping policy — applies to v0.2+ and all subsequent milestones.
|
||||
---
|
||||
|
||||
# Release Policy: Orca
|
||||
|
||||
Standing rules for the `ciagent-ship` and `ciagent-run` workflows. These apply to **v0.2+ and every future milestone** of Orca.
|
||||
|
||||
## Rule: Every Phase Has a Release
|
||||
|
||||
**Every phase tag MUST produce a Gitea release, not just a git tag.**
|
||||
|
||||
- A `git tag` alone is a pointer, not a release. Releases carry the built artifact (tarball) and notes.
|
||||
- For each `vX.Y.Z` phase tag, `ciagent-ship` must invoke `scripts/release.sh vX.Y.Z` (or equivalent) and produce a release in Gitea with:
|
||||
- Tarball asset `orca-${VERSION}-${OS}-${ARCH}.tar.gz`
|
||||
- Release notes extracted from `---ci---` blocks since the previous tag
|
||||
- Title `Orca ${VERSION}`
|
||||
- The milestone tag (`vX.(Y+1).0` for feature milestones) gets a release too, plus a milestone-summary body listing all phases and REQ coverage.
|
||||
|
||||
## Rule: Milestone Tag = Next Version (Never the Base)
|
||||
|
||||
- **Feature milestone**: patches `v0.5.1`…`v0.5.N` → milestone tag is `v0.(Y+1).0` (NOT `v0.Y.0`).
|
||||
- **Major milestone**: minors `v0.Z.0` → milestone tag is `v1.0.0`.
|
||||
- **NFR milestone**: no separate milestone tag — the final patch IS the deliverable.
|
||||
- Tags must be strictly greater than all existing tags on the same `major.minor` line.
|
||||
|
||||
## Rule: One Tag, One Release, One Push
|
||||
|
||||
For each ship, the sequence is:
|
||||
1. `git tag -a vX.Y.Z -m "..."`
|
||||
2. `scripts/release.sh vX.Y.Z` (builds, packages, creates Gitea release with tarball)
|
||||
3. `git push origin <branch> --tags`
|
||||
|
||||
The release step is NOT optional. Skipping the release is a ship failure.
|
||||
|
||||
## Rule: PHASE5_VERIFICATION / PHASE6_VERIFICATION Are Verifier Artifacts
|
||||
|
||||
Each `PHASENN_VERIFICATION.md` in `.ciagent/` is the verifier's report for that phase. These are committed alongside the verification commit and remain in `.ciagent/` as historical evidence for the milestone. They are referenced by the milestone release notes.
|
||||
|
||||
## Rule: PHASE##_VERIFICATION.md Naming
|
||||
|
||||
Phase verification reports are committed as `.ciagent/PHASE##_VERIFICATION.md` (zero-padded, e.g. `PHASE5_VERIFICATION.md`, `PHASE6_VERIFICATION.md`) and are part of the ship record.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
Tags are cheap. Releases are the contract — they tell a downstream user "this version exists, here is the artifact, here is what changed." Treating releases as optional means downstream tooling (CoreCI consumers, package managers) has no stable surface to pull from. Every ship creates a release. No exceptions.
|
||||
+24
-74
@@ -4,77 +4,27 @@
|
||||
|
||||
| ID | Requirement | Priority | Status |
|
||||
|----|-------------|----------|--------|
|
||||
| REQ-001 | Go 1.25+ toolchain support | High | **Complete** |
|
||||
| REQ-002 | CLI-first interface for all operations (single binary) | High | **Complete** |
|
||||
| REQ-003 | Offline-first operational mode (no cloud deps) | High | **Complete** |
|
||||
| REQ-004 | Basic task deployment (single-node process execution) | Medium | **Complete** |
|
||||
| REQ-005 | Local state storage via modernc/sqlite (CGO-free) | Medium | **Complete** |
|
||||
| REQ-006 | Security-first audit logging via `log/slog` | High | **Complete** |
|
||||
| REQ-007 | CoreCI full release flow integration via `.coreci.yml` | High | **Complete** |
|
||||
| REQ-008 | Structured JSON logging (slog) | High | **Complete** |
|
||||
| REQ-009 | HCL/YAML job spec parsing | Medium | **Complete** |
|
||||
| REQ-010 | `--json` output flag for machine consumption | High | **Complete** |
|
||||
| REQ-011 | mTLS for inter-node communication | Medium | Pending (v0.2 P01) |
|
||||
| REQ-012 | `~/.orca/config.hcl` and `/etc/orca/orca.hcl` config locations | Low | **Complete** (CLI uses ~/.orca/ + ORCA_DB env) |
|
||||
| REQ-013 | Pre-push git hook triggers CoreCI on every push | High | **Complete** |
|
||||
| REQ-014 | `gosec` + `govulncheck` in CI pipeline | High | Pending (v0.2 P03) |
|
||||
| REQ-015 | MIT LICENSE | Low | **Complete** |
|
||||
| REQ-016 | README.md with quickstart | Medium | **Complete** |
|
||||
| REQ-017 | `context.Context` propagation in all I/O | High | **Complete** |
|
||||
| REQ-018 | Error wrapping with `fmt.Errorf("...: %w", err)` | High | **Complete** |
|
||||
| REQ-019 | Cobra CLI framework | High | **Complete** |
|
||||
| REQ-020 | HCL parser integration (`hashicorp/hcl`) | Medium | **Complete** |
|
||||
| REQ-021 | `os/exec` with `WaitDelay` (Go 1.25+) | Medium | **Complete** |
|
||||
| REQ-022 | `iter.Seq` for streaming job lists (Go 1.25+) | Low | Pending (v0.2 P04) |
|
||||
| REQ-023 | Self-signed mTLS cert generation | Medium | Pending (v0.2 P01, paired with REQ-011) |
|
||||
| REQ-024 | `Makefile` with standard targets | High | **Complete** |
|
||||
|
||||
## Milestone v0.1: Summary
|
||||
|
||||
**Status: Complete** — all 6 phases shipped (P00–P06), 4-layer verification passed at every phase, tagged `v0.2.0` for next-minor promotion per `run.md` versioning logic.
|
||||
|
||||
**Coverage**: 21/24 requirements complete; 4 deferred to v0.2 (REQ-011, REQ-014, REQ-022, REQ-023) — all paired with multi-node networking, richer I/O scanning, or streaming I/O which are explicitly out of scope for v0.1.
|
||||
|
||||
## Milestone v0.2: Networking, Observability, Security Hardening
|
||||
|
||||
**Status: In Progress** — IDEATE stage complete on `main`. 4 phases (P01–P04) covering mTLS, multi-node scheduling, security scanning, and streaming I/O.
|
||||
|
||||
### v0.2 requirements (carried over from v0.1 deferral)
|
||||
|
||||
| ID | Requirement | Priority | Phase | Source |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-011 | mTLS for inter-node communication | Medium | P01 | v0.1 deferral |
|
||||
| REQ-014 | `gosec` + `govulncheck` in CI pipeline | High | P03 | v0.1 deferral |
|
||||
| REQ-022 | `iter.Seq` for streaming job lists (Go 1.25+) | Low | P04 | v0.1 deferral |
|
||||
| REQ-023 | Self-signed mTLS cert generation | Medium | P01 (paired with REQ-011) | v0.1 deferral |
|
||||
|
||||
### v0.2 requirements (added by IDEATE stage, commit pending)
|
||||
|
||||
| ID | Requirement | Priority | Phase | Source idea |
|
||||
|----|-------------|----------|-------|-------------|
|
||||
| REQ-025 | Bounded cert rotation history: retain last N=3 server certs per node for rollback | Medium | P01 | I-201 (REQ-cand-A) |
|
||||
| REQ-026 | Trusted-CA fingerprint pinned in config; daemon refuses to start on mismatch | High | P01 | I-202 (REQ-cand-B) |
|
||||
| REQ-027 | `govulncheck` runs in offline mode in CI (no `vuln.go.dev` calls; pre-mirrored DB or `-format json` + `jq` gate) | High | P03 | I-101 (REQ-cand-C) |
|
||||
| REQ-028 | HCL/YAML schema for `NodeCapacity` declaration (`orca node join` flag and/or `~/.orca/node.hcl`) | High | P02 | I-203 (REQ-cand-D) |
|
||||
| REQ-029 | `gitleaks` baseline file committed to repo to suppress pre-existing `.env` SHA-1 leak in git history | Medium | P03 | I-102 (REQ-cand-E) |
|
||||
| REQ-030 | `--watch` output format mode: table (default) vs streaming one-line JSON per event | Low | P04 | I-204 (REQ-cand-F) |
|
||||
| REQ-031 | `go test -race` enabled in CI for all v0.2 packages | High | P01–P04 (cross-cutting) | I-103 |
|
||||
| REQ-032 | `orca doctor` subcommand for diagnostics (CA/cert health, db integrity, peer reachability) | Medium | P01 (initial) | I-301 |
|
||||
| REQ-033 | Cert file mode enforcement: 0600 for keys, 0644 for certs (refuses to start on violation) | High | P01 | I-104 |
|
||||
| REQ-034 | Cert proactive rotation alarm: structured slog WARN 30 days before `not_after` | Medium | P01 | I-205 |
|
||||
| REQ-035 | `orca cert show` redacts private key material from default and `--json` output | High | P01 | I-105 |
|
||||
| REQ-036 | Server cert SAN validation: SAN entries (DNS + IP) populated at sign-time; refuses to sign a CSR without them | High | P01 | I-106 |
|
||||
| REQ-037 | `X-Orca-Idempotency-Key` header on cross-node POST; dispatcher retries only when header is present | Medium | P02 | I-206 |
|
||||
| REQ-038 | Structured slog fields for mTLS failures: `event=mtls.handshake`, `peer`, `cert_fp`, `err` | Medium | P01 | I-302 |
|
||||
| REQ-039 | `.gitleaks.toml` extended with stopwords for test data paths and CA cert PEM blocks | Medium | P03 | I-303 |
|
||||
| REQ-040 | `.golangci.yml` unified lint config superseding per-tool invocations | Low | P03 | I-304 |
|
||||
|
||||
### v0.2 totals
|
||||
|
||||
- 4 carried over from v0.1 (REQ-011, REQ-014, REQ-022, REQ-023)
|
||||
- 16 net-new from IDEATE (REQ-025..REQ-040)
|
||||
- **20 total v0.2 requirements**
|
||||
|
||||
### v0.2 deferred to v0.3
|
||||
|
||||
- pprof endpoint on `orca daemon` (idea I-308, 0.70 confidence): deferred to keep v0.2 lean; revisit in v0.3 once P02's dispatcher is stable.
|
||||
| REQ-001 | Go 1.25+ toolchain support | High | Pending |
|
||||
| REQ-002 | CLI-first interface for all operations (single binary) | High | Pending |
|
||||
| REQ-003 | Offline-first operational mode (no cloud deps) | High | Pending |
|
||||
| REQ-004 | Basic task deployment (single-node process execution) | Medium | Pending |
|
||||
| REQ-005 | Local state storage via modernc/sqlite (CGO-free) | Medium | Pending |
|
||||
| REQ-006 | Security-first audit logging via `log/slog` | High | Pending |
|
||||
| REQ-007 | CoreCI full release flow integration via `.coreci.yml` | High | Pending |
|
||||
| REQ-008 | Structured JSON logging (slog) | High | Pending |
|
||||
| REQ-009 | HCL/YAML job spec parsing | Medium | Pending |
|
||||
| REQ-010 | `--json` output flag for machine consumption | High | Pending |
|
||||
| REQ-011 | mTLS for inter-node communication | Medium | Deferred (v0.2) |
|
||||
| REQ-012 | `~/.orca/config.hcl` and `/etc/orca/orca.hcl` config locations | Low | Pending |
|
||||
| REQ-013 | Pre-push git hook triggers CoreCI on every push | High | Pending |
|
||||
| REQ-014 | `gosec` + `govulncheck` in CI pipeline | High | Pending |
|
||||
| REQ-015 | MIT LICENSE | Low | Pending |
|
||||
| REQ-016 | README.md with quickstart | Medium | Pending |
|
||||
| REQ-017 | `context.Context` propagation in all I/O | High | Pending |
|
||||
| REQ-018 | Error wrapping with `fmt.Errorf("...: %w", err)` | High | Pending |
|
||||
| REQ-019 | Cobra CLI framework | High | Pending |
|
||||
| REQ-020 | HCL parser integration (`hashicorp/hcl`) | Medium | Pending |
|
||||
| REQ-021 | `os/exec` with `WaitDelay` (Go 1.25+) | Medium | Pending |
|
||||
| REQ-022 | `iter.Seq` for streaming job lists (Go 1.25+) | Low | Pending |
|
||||
| REQ-023 | Self-signed mTLS cert generation | Medium | Pending |
|
||||
| REQ-024 | `Makefile` with standard targets | High | Pending |
|
||||
|
||||
+8
-79
@@ -1,81 +1,10 @@
|
||||
# Roadmap: Orca
|
||||
|
||||
## Milestone v0.1: Foundation — **COMPLETE**
|
||||
|
||||
- [x] Phase 0: Project Initialization & Specification
|
||||
- [x] Phase 1: Core CLI Skeleton & Command Parsing
|
||||
- [x] Phase 2: Basic Node Management (Join/Leave)
|
||||
- [x] Phase 3: Simple Task Execution Engine
|
||||
- [x] Phase 4: Local State Persistence
|
||||
- [x] Phase 5: Basic Health Checking
|
||||
- [x] Phase 6: CoreCI Full Release Flow
|
||||
|
||||
**Tagged `v0.2.0`** (next-minor per feature-milestone promotion rule).
|
||||
|
||||
## Deferred to v0.2 (out of scope for v0.1)
|
||||
|
||||
- Multi-node scheduling (D-004 decision: single-node only in v0.1)
|
||||
- mTLS for inter-node communication (REQ-011, REQ-023)
|
||||
- `gosec` + `govulncheck` in CI pipeline (REQ-014)
|
||||
- `iter.Seq` streaming job lists (REQ-022)
|
||||
- Frontend / devops personas (no web UI; CoreCI handles release)
|
||||
|
||||
## Milestone v0.2: Networking, Observability, Security Hardening — **IN PROGRESS**
|
||||
|
||||
Scope: extend v0.1 with secure cross-node transport, multi-node scheduling,
|
||||
richer CI security scanning, and streaming I/O.
|
||||
|
||||
- [ ] Phase 8: mTLS handshake + internal CA with CSR join (Wave 1)
|
||||
- [ ] Phase 9: Multi-node scheduling & job dispatch (Wave 1)
|
||||
- [ ] Phase 10: `gosec` + `govulncheck` + gitleaks in CI (Wave 2)
|
||||
- [ ] Phase 11: `iter.Seq` streaming job/node lists (Wave 2)
|
||||
|
||||
**Target milestone tag**: `v0.3.0` (next-minor per feature-milestone promotion rule).
|
||||
|
||||
Per-phase tags: `v0.2.1` (P01), `v0.2.2` (P02), `v0.2.3` (P03), `v0.2.4` (P04).
|
||||
Per `.ciagent/RELEASE_POLICY.md`, every phase tag produces a Gitea release.
|
||||
|
||||
### Per-phase REQ coverage (post-IDEATE)
|
||||
|
||||
- **P01 — mTLS handshake + internal CA with CSR join** (Wave 1)
|
||||
- REQ-011, REQ-023 (carried over from v0.1)
|
||||
- REQ-025 (cert rotation history), REQ-026 (CA fingerprint pinning),
|
||||
REQ-033 (file mode enforcement), REQ-034 (rotation alarm),
|
||||
REQ-035 (cert show redaction), REQ-036 (SAN validation),
|
||||
REQ-038 (mTLS failure log fields)
|
||||
- REQ-032 (orca doctor — initial implementation; checks CA/cert state)
|
||||
|
||||
- **P02 — Multi-node scheduling & job dispatch** (Wave 1)
|
||||
- REQ-028 (NodeCapacity HCL schema — P02 enabler; lands first)
|
||||
- REQ-037 (X-Orca-Idempotency-Key on cross-node POST)
|
||||
|
||||
- **P03 — `gosec` + `govulncheck` + gitleaks in CI** (Wave 2)
|
||||
- REQ-014 (carried over)
|
||||
- REQ-027 (govulncheck offline mode — new in v0.2 IDEATE, per REQ-cand-C;
|
||||
this changes P03's scope: CI must not call `vuln.go.dev` by default;
|
||||
resolve via pre-mirrored DB or `-format json` + `jq` wrapper. PLAN
|
||||
stage decides between the two options.)
|
||||
- REQ-029 (gitleaks baseline for pre-existing `.env` leak in history,
|
||||
per REQ-cand-E)
|
||||
- REQ-039 (`.gitleaks.toml` stopwords), REQ-040 (`.golangci.yml`)
|
||||
|
||||
- **P04 — `iter.Seq` streaming job/node lists** (Wave 2)
|
||||
- REQ-022 (carried over)
|
||||
- REQ-030 (`--watch --json` streaming output mode, per REQ-cand-F)
|
||||
|
||||
- **Cross-cutting (P01–P04)**
|
||||
- REQ-031 (`go test -race` enabled in CI for all v0.2 packages)
|
||||
|
||||
### P03 scope change (vs. pre-IDEATE plan)
|
||||
|
||||
REQ-027 (govulncheck offline mode) adds explicit work to P03: the CI
|
||||
job must be configured to NOT make outbound calls to `vuln.go.dev`
|
||||
(default `govulncheck` behavior). Two implementation paths are viable;
|
||||
PLAN chooses:
|
||||
- Pre-mirror the vulnerability database inside the CoreCI image
|
||||
(`GOVULNCHECK_DB=/path/to/local.db`).
|
||||
- Use `govulncheck -format json` (which always exits 0) and gate
|
||||
merges via a wrapper that parses the JSON and returns non-zero on
|
||||
unsuppressed findings.
|
||||
|
||||
Either path keeps the offline-first invariant (REQ-003) intact.
|
||||
## Milestone v0.1: Foundation
|
||||
- [ ] Phase 0: Project Initialization & Specification
|
||||
- [ ] Phase 1: Core CLI Skeleton & Command Parsing
|
||||
- [ ] Phase 2: Basic Node Management (Join/Leave)
|
||||
- [ ] Phase 3: Simple Task Execution Engine
|
||||
- [ ] Phase 4: Local State Persistence
|
||||
- [ ] Phase 5: Basic Health Checking
|
||||
- [ ] Phase 6: CoreCI Full Release Flow
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
GITEA_TOKEN=795e2f875dcd23dff830fab8301ec52e4c9d67aa
|
||||
GITEA_USER=cloudinit-bot
|
||||
@@ -8,6 +8,5 @@ orca
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
.env
|
||||
.env.local
|
||||
*.tar.gz
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := cli.Execute(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
// 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") }
|
||||
@@ -1,255 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
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)
|
||||
}
|
||||
+3
-25
@@ -12,10 +12,8 @@ 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"
|
||||
)
|
||||
|
||||
@@ -50,10 +48,9 @@ func nodeRegistry() (*engine.NodeRegistry, func() error, error) {
|
||||
}
|
||||
|
||||
var (
|
||||
joinName string
|
||||
joinAddr string
|
||||
joinCAFinger string
|
||||
leaveID string
|
||||
joinName string
|
||||
joinAddr string
|
||||
leaveID string
|
||||
)
|
||||
|
||||
var nodeCmd = &cobra.Command{
|
||||
@@ -73,24 +70,6 @@ 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()
|
||||
|
||||
@@ -188,7 +167,6 @@ 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,12 +30,6 @@ 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.
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CAValidity is how long a CA cert is valid. Per D-013, the CA is long-lived
|
||||
// (10 years) because manual rotation is expensive.
|
||||
const CAValidity = 10 * 365 * 24 * time.Hour
|
||||
|
||||
// ServerCertValidity is the default validity window for server certs. D-013
|
||||
// says server certs are short-lived (90 days) to limit the compromise window.
|
||||
const ServerCertValidity = 90 * 24 * time.Hour
|
||||
|
||||
// CAKeySize is the RSA key size used for both CA and server certs. 3072 is
|
||||
// the minimum we accept for v0.2 — matches REQ-033 spirit and Go's stdlib
|
||||
// defaults for new RSA keys are typically 2048 or 4096. 3072 is the
|
||||
// sweet spot for balance of safety and key-gen latency.
|
||||
const CAKeySize = 3072
|
||||
|
||||
// CAMode is the file mode used when persisting the CA private key. REQ-033
|
||||
// requires 0600.
|
||||
const CAMode os.FileMode = 0o600
|
||||
|
||||
// CACPEMMode is the file mode used when persisting the CA public cert.
|
||||
// REQ-033 requires 0644 (public, but still mode-pinned).
|
||||
const CACPEMMode os.FileMode = 0o644
|
||||
|
||||
// File names used inside the CA directory.
|
||||
const (
|
||||
CACertFile = "ca.crt"
|
||||
CAKeyFile = "ca.key"
|
||||
)
|
||||
|
||||
// CA wraps a loaded CA. Use CAInit to mint a new one, LoadCA to read an
|
||||
// existing one from disk.
|
||||
type CA struct {
|
||||
Cert *x509.Certificate
|
||||
Key *rsa.PrivateKey
|
||||
CertPEM []byte
|
||||
Dir string
|
||||
NotBefore time.Time
|
||||
NotAfter time.Time
|
||||
}
|
||||
|
||||
// CAInit creates a fresh self-signed CA and persists it to dir/ca.crt and
|
||||
// dir/ca.key with the required file modes (REQ-033). If the CA files already
|
||||
// exist with valid content, the existing CA is returned — idempotent.
|
||||
//
|
||||
// commonName is the CA's CommonName (typically an org/cluster identifier).
|
||||
// Returns a *CA wrapping the loaded cert + key. The CA is valid for
|
||||
// CAValidity from now.
|
||||
func CAInit(dir, commonName string) (*CA, error) {
|
||||
if dir == "" {
|
||||
return nil, errors.New("CAInit: dir is required")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("CAInit: mkdir: %w", err)
|
||||
}
|
||||
certPath := filepath.Join(dir, CACertFile)
|
||||
keyPath := filepath.Join(dir, CAKeyFile)
|
||||
|
||||
// Fast path: existing CA — load and return.
|
||||
if ok, err := bothExist(certPath, keyPath); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
// Verify file modes on the existing CA (REQ-033).
|
||||
if err := EnforceFileModes(dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return LoadCA(dir)
|
||||
}
|
||||
|
||||
// Generate key.
|
||||
key, err := rsa.GenerateKey(rand.Reader, CAKeySize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CAInit: generate key: %w", err)
|
||||
}
|
||||
|
||||
// Self-signed cert. We use x509.Certificate directly to set the CA
|
||||
// extensions. Serial number is random 128 bits.
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CAInit: serial: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{
|
||||
CommonName: commonName,
|
||||
Organization: []string{"orca-internal-ca"},
|
||||
},
|
||||
NotBefore: now.Add(-1 * time.Hour),
|
||||
NotAfter: now.Add(CAValidity),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
MaxPathLen: 1,
|
||||
MaxPathLenZero: false,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CAInit: create cert: %w", err)
|
||||
}
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CAInit: marshal key: %w", err)
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||
|
||||
// Atomic write: temp file + rename. This avoids leaving a half-written
|
||||
// ca.key on disk if the process crashes mid-write.
|
||||
if err := writeAtomic(certPath, CACPEMMode, certPEM); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeAtomic(keyPath, CAMode, keyPEM); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return LoadCA(dir)
|
||||
}
|
||||
|
||||
// LoadCA reads a previously-initialized CA from disk. Returns a *CA or an
|
||||
// error. Verifies file modes (REQ-033).
|
||||
func LoadCA(dir string) (*CA, error) {
|
||||
if dir == "" {
|
||||
return nil, errors.New("LoadCA: dir is required")
|
||||
}
|
||||
certPath := filepath.Join(dir, CACertFile)
|
||||
keyPath := filepath.Join(dir, CAKeyFile)
|
||||
|
||||
if err := EnforceFileModes(dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
certPEM, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LoadCA: read cert: %w", err)
|
||||
}
|
||||
keyPEM, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LoadCA: read key: %w", err)
|
||||
}
|
||||
|
||||
certBlock, _ := pem.Decode(certPEM)
|
||||
if certBlock == nil {
|
||||
return nil, fmt.Errorf("LoadCA: cert PEM decode failed")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(certBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LoadCA: parse cert: %w", err)
|
||||
}
|
||||
keyBlock, _ := pem.Decode(keyPEM)
|
||||
if keyBlock == nil {
|
||||
return nil, fmt.Errorf("LoadCA: key PEM decode failed")
|
||||
}
|
||||
keyAny, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LoadCA: parse key: %w", err)
|
||||
}
|
||||
key, ok := keyAny.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("LoadCA: key is %T, not *rsa.PrivateKey", keyAny)
|
||||
}
|
||||
|
||||
return &CA{
|
||||
Cert: cert,
|
||||
Key: key,
|
||||
CertPEM: certPEM,
|
||||
Dir: dir,
|
||||
NotBefore: cert.NotBefore,
|
||||
NotAfter: cert.NotAfter,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EnforceFileModes refuses to operate if ca.crt / ca.key do not have the
|
||||
// required modes (REQ-033). Returns nil on success. Callers (daemon start,
|
||||
// CA loaders) MUST call this and abort on error.
|
||||
func EnforceFileModes(dir string) error {
|
||||
certPath := filepath.Join(dir, CACertFile)
|
||||
keyPath := filepath.Join(dir, CAKeyFile)
|
||||
certInfo, err := os.Stat(certPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("EnforceFileModes: stat %s: %w", certPath, err)
|
||||
}
|
||||
keyInfo, err := os.Stat(keyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("EnforceFileModes: stat %s: %w", keyPath, err)
|
||||
}
|
||||
if certInfo.Mode().Perm() != CACPEMMode {
|
||||
return fmt.Errorf(
|
||||
"REQ-033 violation: %s has mode %04o, want %04o — fix with `chmod %04o %s`",
|
||||
certPath, certInfo.Mode().Perm(), CACPEMMode, CACPEMMode, certPath,
|
||||
)
|
||||
}
|
||||
if keyInfo.Mode().Perm() != CAMode {
|
||||
return fmt.Errorf(
|
||||
"REQ-033 violation: %s has mode %04o, want %04o — fix with `chmod %04o %s`",
|
||||
keyPath, keyInfo.Mode().Perm(), CAMode, CAMode, keyPath,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SignCSR signs a PEM-encoded CSR with the CA and returns the issued cert
|
||||
// in PEM form. The resulting cert is valid for ServerCertValidity and
|
||||
// inherits the SANs from the CSR (DNS, IP). If the CSR has no SANs, the
|
||||
// call fails — REQ-036 requires server certs to have identifying SANs.
|
||||
func (c *CA) SignCSR(csrPEM []byte) ([]byte, error) {
|
||||
if c == nil || c.Cert == nil || c.Key == nil {
|
||||
return nil, errors.New("SignCSR: nil CA")
|
||||
}
|
||||
block, _ := pem.Decode(csrPEM)
|
||||
if block == nil {
|
||||
return nil, errors.New("SignCSR: CSR PEM decode failed")
|
||||
}
|
||||
if block.Type != "CERTIFICATE REQUEST" && block.Type != "NEW CERTIFICATE REQUEST" {
|
||||
return nil, fmt.Errorf("SignCSR: unexpected PEM type %q", block.Type)
|
||||
}
|
||||
csr, err := x509.ParseCertificateRequest(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SignCSR: parse CSR: %w", err)
|
||||
}
|
||||
if err := csr.CheckSignature(); err != nil {
|
||||
return nil, fmt.Errorf("SignCSR: CSR signature invalid: %w", err)
|
||||
}
|
||||
// REQ-036: refuse CSRs without SANs. A server cert needs at least
|
||||
// one DNS or IP SAN so the peer can verify it against a pinned identity.
|
||||
if len(csr.DNSNames) == 0 && len(csr.IPAddresses) == 0 {
|
||||
return nil, errors.New("SignCSR: CSR has no DNS or IP SANs (REQ-036) — must include at least one")
|
||||
}
|
||||
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SignCSR: serial: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: csr.Subject,
|
||||
NotBefore: now.Add(-1 * time.Hour),
|
||||
NotAfter: now.Add(ServerCertValidity),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
|
||||
DNSNames: csr.DNSNames,
|
||||
IPAddresses: csr.IPAddresses,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, c.Cert, csr.PublicKey, c.Key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SignCSR: create cert: %w", err)
|
||||
}
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), nil
|
||||
}
|
||||
|
||||
// Fingerprint returns the SHA-256 hex fingerprint of the CA cert. Useful
|
||||
// for the operator to communicate to peers out-of-band; peers then pin
|
||||
// this value at `orca node join --ca-fingerprint <sha>`.
|
||||
func (c *CA) Fingerprint() string {
|
||||
return FingerprintOf(c.Cert.Raw)
|
||||
}
|
||||
|
||||
// bothExist returns true if both paths exist (regular files).
|
||||
func bothExist(paths ...string) (bool, error) {
|
||||
for _, p := range paths {
|
||||
info, err := os.Stat(p)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return false, fmt.Errorf("not a regular file: %s", p)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := os.CreateTemp(dir, ".tmp-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("writeAtomic: create temp: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
// Best-effort cleanup if we fail before rename.
|
||||
defer func() {
|
||||
_ = os.Remove(tmpName)
|
||||
}()
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("writeAtomic: write: %w", err)
|
||||
}
|
||||
if err := tmp.Chmod(mode); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("writeAtomic: chmod: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("writeAtomic: sync: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("writeAtomic: close: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("writeAtomic: rename: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestRoundTrip exercises the full CA → CSR → SignCSR → x509.Verify chain
|
||||
// in a single test. The point is to catch protocol mismatches early: if
|
||||
// SignCSR produces a cert that doesn't chain to the CA, the verification
|
||||
// step will fail and this test will surface the bug.
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// 1. Init a CA.
|
||||
ca, err := CAInit(dir, "orca-test-ca")
|
||||
if err != nil {
|
||||
t.Fatalf("CAInit: %v", err)
|
||||
}
|
||||
if ca == nil || ca.Cert == nil {
|
||||
t.Fatal("CAInit returned nil cert")
|
||||
}
|
||||
if !ca.Cert.IsCA {
|
||||
t.Error("CA cert IsCA is false")
|
||||
}
|
||||
if got := ca.Cert.KeyUsage & x509.KeyUsageCertSign; got == 0 {
|
||||
t.Error("CA cert missing KeyUsageCertSign")
|
||||
}
|
||||
|
||||
// 2. Generate a server CSR with SANs.
|
||||
commonName := "test.orca.local"
|
||||
sans := []string{"test.orca.local", "127.0.0.1"}
|
||||
keyPEM, csrPEM, err := GenerateCSR(commonName, sans)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCSR: %v", err)
|
||||
}
|
||||
if len(keyPEM) == 0 || len(csrPEM) == 0 {
|
||||
t.Fatal("GenerateCSR returned empty PEM")
|
||||
}
|
||||
|
||||
// 3. Sign the CSR.
|
||||
signedPEM, err := ca.SignCSR(csrPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("SignCSR: %v", err)
|
||||
}
|
||||
if len(signedPEM) == 0 {
|
||||
t.Fatal("SignCSR returned empty cert")
|
||||
}
|
||||
|
||||
// 4. Verify the chain programmatically with x509.Verify.
|
||||
caPool := x509.NewCertPool()
|
||||
caPool.AddCert(ca.Cert)
|
||||
leafBlock, _ := pem.Decode(signedPEM)
|
||||
if leafBlock == nil {
|
||||
t.Fatal("pem.Decode: no cert block")
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(leafBlock.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCertificate (leaf): %v", err)
|
||||
}
|
||||
_, err = leaf.Verify(x509.VerifyOptions{
|
||||
Roots: caPool,
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
CurrentTime: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("leaf.Verify: %v", err)
|
||||
}
|
||||
|
||||
// 5. Sanity-check the SANs survived signing.
|
||||
if len(leaf.DNSNames) != 1 || leaf.DNSNames[0] != "test.orca.local" {
|
||||
t.Errorf("expected DNS SAN [test.orca.local], got %v", leaf.DNSNames)
|
||||
}
|
||||
if len(leaf.IPAddresses) != 1 || leaf.IPAddresses[0].String() != "127.0.0.1" {
|
||||
t.Errorf("expected IP SAN [127.0.0.1], got %v", leaf.IPAddresses)
|
||||
}
|
||||
if leaf.Subject.CommonName != commonName {
|
||||
t.Errorf("expected CN %q, got %q", commonName, leaf.Subject.CommonName)
|
||||
}
|
||||
|
||||
// 6. CA fingerprint pin should match the on-disk ca.crt.
|
||||
caFingerprint, err := Fingerprint(filepath.Join(dir, CACertFile))
|
||||
if err != nil {
|
||||
t.Fatalf("Fingerprint: %v", err)
|
||||
}
|
||||
if caFingerprint != ca.Fingerprint() {
|
||||
t.Errorf("Fingerprint mismatch: file=%q CA.Fingerprint()=%q", caFingerprint, ca.Fingerprint())
|
||||
}
|
||||
if len(caFingerprint) != 64 {
|
||||
t.Errorf("expected 64 hex chars, got %d (%q)", len(caFingerprint), caFingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCAFileModes verifies REQ-033: ca.crt must be 0644, ca.key must be 0600.
|
||||
func TestCAFileModes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := CAInit(dir, "orca-mode-test"); err != nil {
|
||||
t.Fatalf("CAInit: %v", err)
|
||||
}
|
||||
certInfo, err := os.Stat(filepath.Join(dir, CACertFile))
|
||||
if err != nil {
|
||||
t.Fatalf("stat ca.crt: %v", err)
|
||||
}
|
||||
keyInfo, err := os.Stat(filepath.Join(dir, CAKeyFile))
|
||||
if err != nil {
|
||||
t.Fatalf("stat ca.key: %v", err)
|
||||
}
|
||||
if got := certInfo.Mode().Perm(); got != CACPEMMode {
|
||||
t.Errorf("ca.crt mode = %04o, want %04o (REQ-033)", got, CACPEMMode)
|
||||
}
|
||||
if got := keyInfo.Mode().Perm(); got != CAMode {
|
||||
t.Errorf("ca.key mode = %04o, want %04o (REQ-033)", got, CAMode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCAEnforceFileModes verifies that EnforceFileModes refuses to load a CA
|
||||
// whose file modes are wrong (e.g., ca.key is world-readable).
|
||||
func TestCAEnforceFileModes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := CAInit(dir, "orca-enforce-test"); err != nil {
|
||||
t.Fatalf("CAInit: %v", err)
|
||||
}
|
||||
// Make ca.key world-readable — should fail EnforceFileModes.
|
||||
if err := os.Chmod(filepath.Join(dir, CAKeyFile), 0o644); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
if err := EnforceFileModes(dir); err == nil {
|
||||
t.Error("expected EnforceFileModes to fail with world-readable ca.key")
|
||||
}
|
||||
// And LoadCA should refuse too.
|
||||
if _, err := LoadCA(dir); err == nil {
|
||||
t.Error("expected LoadCA to fail with world-readable ca.key")
|
||||
}
|
||||
// Restore mode; should pass again.
|
||||
if err := os.Chmod(filepath.Join(dir, CAKeyFile), CAMode); err != nil {
|
||||
t.Fatalf("chmod restore: %v", err)
|
||||
}
|
||||
if err := EnforceFileModes(dir); err != nil {
|
||||
t.Errorf("EnforceFileModes after restore: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRotationAlarmFiresAt30Days verifies REQ-034: a cert with NotAfter
|
||||
// 30 days from now triggers RotationAlarm; a cert with 31 days does not.
|
||||
func TestRotationAlarmFiresAt30Days(t *testing.T) {
|
||||
now := time.Now()
|
||||
tests := []struct {
|
||||
name string
|
||||
notAfter time.Time
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "31 days remaining",
|
||||
notAfter: now.Add(31 * 24 * time.Hour),
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "30 days remaining (boundary, fires)",
|
||||
notAfter: now.Add(30 * 24 * time.Hour),
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "15 days remaining (fires)",
|
||||
notAfter: now.Add(15 * 24 * time.Hour),
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "expired (fires, days=0)",
|
||||
notAfter: now.Add(-1 * time.Hour),
|
||||
wantError: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cert := &x509.Certificate{NotAfter: tc.notAfter}
|
||||
err := RotationAlarmAt(cert, now)
|
||||
if tc.wantError && err == nil {
|
||||
t.Errorf("expected alarm, got nil")
|
||||
}
|
||||
if !tc.wantError && err != nil {
|
||||
t.Errorf("expected no alarm, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactStripsPrivateKey verifies REQ-035: the Redact helper strips
|
||||
// PEM private key blocks from arbitrary input.
|
||||
func TestRedactStripsPrivateKey(t *testing.T) {
|
||||
in := []byte(`hello
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEowIBAAKCAQEAxxxx
|
||||
-----END RSA PRIVATE KEY-----
|
||||
world
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDazCCAlOgAwIBAgI...
|
||||
-----END CERTIFICATE-----
|
||||
trailing
|
||||
`)
|
||||
out := string(Redact(in))
|
||||
if contains(out, "PRIVATE KEY-----") {
|
||||
t.Errorf("Redact output still contains PRIVATE KEY header: %q", out)
|
||||
}
|
||||
if contains(out, "BEGIN RSA PRIVATE KEY") {
|
||||
t.Errorf("Redact output still contains BEGIN RSA PRIVATE KEY: %q", out)
|
||||
}
|
||||
if !contains(out, "[REDACTED PRIVATE KEY]") {
|
||||
t.Errorf("expected redaction marker in output: %q", out)
|
||||
}
|
||||
if !contains(out, "BEGIN CERTIFICATE") {
|
||||
t.Errorf("expected CERTIFICATE block to survive redaction: %q", out)
|
||||
}
|
||||
if !contains(out, "hello") || !contains(out, "world") || !contains(out, "trailing") {
|
||||
t.Errorf("expected non-key content preserved: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactNoKey verifies Redact is a no-op (other than a copy) when no
|
||||
// private key blocks are present.
|
||||
func TestRedactNoKey(t *testing.T) {
|
||||
in := []byte("just a cert\n-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n")
|
||||
out := string(Redact(in))
|
||||
if out != string(in) {
|
||||
t.Errorf("Redact changed input without any keys present:\n got=%q\nwant=%q", out, in)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateCSRRequiresSANs verifies REQ-036: a CSR without any SANs is
|
||||
// rejected at generation time.
|
||||
func TestGenerateCSRRequiresSANs(t *testing.T) {
|
||||
if _, _, err := GenerateCSR("foo", nil); err == nil {
|
||||
t.Error("expected GenerateCSR to fail with empty sans")
|
||||
}
|
||||
if _, _, err := GenerateCSR("", []string{"foo"}); err == nil {
|
||||
t.Error("expected GenerateCSR to fail with empty commonName")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignCSRRejectsSANless verifies REQ-036: even a syntactically valid CSR
|
||||
// with no SANs is rejected at sign-time.
|
||||
func TestSignCSRRejectsSANless(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ca, err := CAInit(dir, "orca-sign-reject-test")
|
||||
if err != nil {
|
||||
t.Fatalf("CAInit: %v", err)
|
||||
}
|
||||
// Build a CSR directly with no SANs to bypass the GenerateCSR guard.
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
csr := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: "nosan.example"},
|
||||
DNSNames: nil,
|
||||
}
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, csr, key)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCertificateRequest: %v", err)
|
||||
}
|
||||
csrPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER})
|
||||
if _, err := ca.SignCSR(csrPEM); err == nil {
|
||||
t.Error("expected SignCSR to fail with SAN-less CSR (REQ-036)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFingerprintStable verifies the SHA-256 hex is identical across two
|
||||
// computations of the same DER.
|
||||
func TestFingerprintStable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := CAInit(dir, "orca-fp-test"); err != nil {
|
||||
t.Fatalf("CAInit: %v", err)
|
||||
}
|
||||
caPEM, err := os.ReadFile(filepath.Join(dir, CACertFile))
|
||||
if err != nil {
|
||||
t.Fatalf("read ca.crt: %v", err)
|
||||
}
|
||||
der, err := firstCertDER(caPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("firstCertDER: %v", err)
|
||||
}
|
||||
fp1 := FingerprintOf(der)
|
||||
fp2 := FingerprintOf(der)
|
||||
if fp1 != fp2 {
|
||||
t.Errorf("FingerprintOf not stable: %q vs %q", fp1, fp2)
|
||||
}
|
||||
if len(fp1) != 64 {
|
||||
t.Errorf("expected 64 hex chars, got %d", len(fp1))
|
||||
}
|
||||
}
|
||||
|
||||
func contains(haystack, needle string) bool {
|
||||
return indexOf(haystack, needle) >= 0
|
||||
}
|
||||
|
||||
func indexOf(s, sub string) int {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
)
|
||||
|
||||
// GenerateCSR mints a new RSA private key, builds a CSR with the given
|
||||
// commonName and SANs (DNS or IP entries), and returns the key + CSR in
|
||||
// PEM form. The private key is RSA 3072 (matches CAKeySize).
|
||||
//
|
||||
// REQ-036: server certs MUST have at least one DNS or IP SAN. This function
|
||||
// enforces that constraint — calling with empty sans returns an error.
|
||||
//
|
||||
// Validation: dns entries must be syntactically valid hostnames; ip entries
|
||||
// must be parseable by net.ParseIP. Bad inputs are rejected up-front so
|
||||
// the operator gets a clear error before signing.
|
||||
func GenerateCSR(commonName string, sans []string) (keyPEM, csrPEM []byte, err error) {
|
||||
if commonName == "" {
|
||||
return nil, nil, errors.New("GenerateCSR: commonName is required")
|
||||
}
|
||||
if len(sans) == 0 {
|
||||
return nil, nil, errors.New("GenerateCSR: at least one DNS or IP SAN is required (REQ-036)")
|
||||
}
|
||||
|
||||
dnsNames := make([]string, 0, len(sans))
|
||||
ipAddrs := make([]net.IP, 0, len(sans))
|
||||
for _, s := range sans {
|
||||
if s == "" {
|
||||
return nil, nil, errors.New("GenerateCSR: empty SAN entry")
|
||||
}
|
||||
if ip := net.ParseIP(s); ip != nil {
|
||||
ipAddrs = append(ipAddrs, ip)
|
||||
continue
|
||||
}
|
||||
// Treat as a DNS name. Validate it parses and is not a host:port form.
|
||||
if _, _, err := net.SplitHostPort(s); err == nil {
|
||||
return nil, nil, fmt.Errorf("GenerateCSR: SAN %q looks like host:port; use a bare hostname or IP", s)
|
||||
}
|
||||
dnsNames = append(dnsNames, s)
|
||||
}
|
||||
if len(dnsNames) == 0 && len(ipAddrs) == 0 {
|
||||
return nil, nil, errors.New("GenerateCSR: at least one valid DNS or IP SAN is required (REQ-036)")
|
||||
}
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, CAKeySize)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("GenerateCSR: generate key: %w", err)
|
||||
}
|
||||
csr := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{
|
||||
CommonName: commonName,
|
||||
Organization: []string{"orca"},
|
||||
},
|
||||
DNSNames: dnsNames,
|
||||
IPAddresses: ipAddrs,
|
||||
}
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, csr, key)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("GenerateCSR: create CSR: %w", err)
|
||||
}
|
||||
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("GenerateCSR: marshal key: %w", err)
|
||||
}
|
||||
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||
csrPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER})
|
||||
return keyPEM, csrPEM, nil
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
// Package security provides certificate authority, CSR signing, TLS
|
||||
// configuration, and rotation helpers for orca's mTLS transport.
|
||||
//
|
||||
// The CA model is internal + operator-mediated (per PROJECT.md D-011, D-012):
|
||||
//
|
||||
// - The bootstrap node runs CAInit(dir) to mint a self-signed CA and persist
|
||||
// ca.crt (0644) + ca.key (0600). Mode enforcement is intentional — REQ-033
|
||||
// requires the daemon to refuse to start if the file modes are wrong.
|
||||
// - Operators copy ca.crt to peers out-of-band.
|
||||
// - Peers run GenerateCSR to produce a CSR + key, ship the CSR to the CA
|
||||
// node, which calls SignCSR to produce a server cert. The peer verifies
|
||||
// the on-disk CA cert's SHA-256 fingerprint at `node join` time against
|
||||
// a pinned value (REQ-026) — fail fast on CA mismatch (D-014).
|
||||
//
|
||||
// All certificate operations use the Go standard library (no external
|
||||
// crypto deps) per the v0.2 plan's "no new direct deps for P01" rule.
|
||||
package security
|
||||
@@ -1,58 +0,0 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Fingerprint returns the SHA-256 hex digest of the certificate's DER bytes,
|
||||
// computed from the on-disk PEM at certPath. The output is lowercase hex
|
||||
// (64 chars) and matches the value operators see with `openssl x509 -fingerprint
|
||||
// -sha256 -noout`. Used for the `orca node join --ca-fingerprint <sha>` pin.
|
||||
func Fingerprint(certPath string) (string, error) {
|
||||
if certPath == "" {
|
||||
return "", errors.New("Fingerprint: certPath is required")
|
||||
}
|
||||
pemBytes, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Fingerprint: read cert: %w", err)
|
||||
}
|
||||
der, err := firstCertDER(pemBytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Fingerprint: %w", err)
|
||||
}
|
||||
return FingerprintOf(der), nil
|
||||
}
|
||||
|
||||
// FingerprintOf returns the SHA-256 hex digest of a DER-encoded certificate.
|
||||
// Lowercase hex; matches `openssl ... -fingerprint -sha256` output.
|
||||
func FingerprintOf(der []byte) string {
|
||||
sum := sha256.Sum256(der)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// firstCertDER decodes PEM bytes and returns the DER of the first
|
||||
// CERTIFICATE block. Errors if the input is empty or no CERTIFICATE block
|
||||
// is present.
|
||||
func firstCertDER(pemBytes []byte) ([]byte, error) {
|
||||
if len(pemBytes) == 0 {
|
||||
return nil, errors.New("empty input")
|
||||
}
|
||||
block, _ := pem.Decode(pemBytes)
|
||||
if block == nil {
|
||||
return nil, errors.New("no PEM data found")
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
return nil, fmt.Errorf("unexpected PEM type %q, want CERTIFICATE", block.Type)
|
||||
}
|
||||
// Re-parse through x509 to validate the cert is well-formed.
|
||||
if _, err := x509.ParseCertificate(block.Bytes); err != nil {
|
||||
return nil, fmt.Errorf("parse certificate: %w", err)
|
||||
}
|
||||
return block.Bytes, nil
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
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
|
||||
@@ -1,103 +0,0 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// privateKeyBlockRe matches the PEM header for any private key variant.
|
||||
// Catches: RSA, EC, DSA, OPENSSH, ENCRYPTED, and the legacy PKCS#1 forms.
|
||||
var privateKeyBlockRe = regexp.MustCompile(
|
||||
`-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP |)PRIVATE KEY-----`,
|
||||
)
|
||||
|
||||
// Redact removes all PEM private-key blocks from the input. It strips the
|
||||
// header, base64 body, and footer of each private key block, replacing the
|
||||
// block with a single line: `[REDACTED PRIVATE KEY]`.
|
||||
//
|
||||
// REQ-035: `orca cert show` MUST NOT print private key material, in either
|
||||
// the default text or --json output. This helper is the single source of
|
||||
// truth for that guarantee — call it on any PEM blob before display.
|
||||
//
|
||||
// The function is conservative: if the input contains no private key
|
||||
// blocks, the input is returned unchanged (other than a copy). Errors are
|
||||
// only returned for impossible states (e.g., a nil pattern hit, which
|
||||
// can't happen in practice).
|
||||
func Redact(pem []byte) []byte {
|
||||
if len(pem) == 0 {
|
||||
return pem
|
||||
}
|
||||
// Find all header positions.
|
||||
matches := privateKeyBlockRe.FindAllIndex(pem, -1)
|
||||
if len(matches) == 0 {
|
||||
// No private key blocks — return a defensive copy.
|
||||
out := make([]byte, len(pem))
|
||||
copy(out, pem)
|
||||
return out
|
||||
}
|
||||
|
||||
// Process each block: locate the matching footer "-----END ... PRIVATE KEY-----"
|
||||
// and replace the entire block. Multiple matches possible.
|
||||
type span struct{ start, end int }
|
||||
spans := make([]span, 0, len(matches))
|
||||
for _, m := range matches {
|
||||
headerStart := m[0]
|
||||
// Find footer starting after the header.
|
||||
footerStart := findPrivateKeyFooter(pem[headerStart:])
|
||||
if footerStart < 0 {
|
||||
// Malformed PEM — leave the input alone for safety. The caller
|
||||
// will likely surface the parse error elsewhere.
|
||||
continue
|
||||
}
|
||||
end := headerStart + footerStart + len("-----END (any) PRIVATE KEY-----")
|
||||
// We don't know the exact footer length; use bytes.Index for it.
|
||||
if exactEnd := exactFooterEnd(pem[headerStart:]); exactEnd > 0 {
|
||||
end = headerStart + exactEnd
|
||||
}
|
||||
spans = append(spans, span{headerStart, end})
|
||||
}
|
||||
if len(spans) == 0 {
|
||||
out := make([]byte, len(pem))
|
||||
copy(out, pem)
|
||||
return out
|
||||
}
|
||||
|
||||
// Build output: segments between spans + redaction marker.
|
||||
var out bytes.Buffer
|
||||
prev := 0
|
||||
for _, s := range spans {
|
||||
out.Write(pem[prev:s.start])
|
||||
out.WriteString("[REDACTED PRIVATE KEY]\n")
|
||||
prev = s.end
|
||||
}
|
||||
out.Write(pem[prev:])
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// findPrivateKeyFooter returns the offset of the footer for a private key
|
||||
// block whose header starts at pem[0]. Returns -1 if not found.
|
||||
func findPrivateKeyFooter(pem []byte) int {
|
||||
re := regexp.MustCompile(`-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP |)PRIVATE KEY-----`)
|
||||
loc := re.FindIndex(pem)
|
||||
if loc == nil {
|
||||
return -1
|
||||
}
|
||||
return loc[0]
|
||||
}
|
||||
|
||||
// exactFooterEnd returns the offset just past the footer line's newline (or
|
||||
// end-of-input if no trailing newline). Returns -1 if no footer is found.
|
||||
func exactFooterEnd(pem []byte) int {
|
||||
re := regexp.MustCompile(`-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP |)PRIVATE KEY-----\r?\n?`)
|
||||
loc := re.FindIndex(pem)
|
||||
if loc == nil {
|
||||
return -1
|
||||
}
|
||||
return loc[1]
|
||||
}
|
||||
|
||||
// Sentinel to silence the "imported and not used" check if a future
|
||||
// refactor removes all consumers of errors. Currently errors is imported
|
||||
// only transitively, so keep this var to anchor the package.
|
||||
var _ = errors.New
|
||||
@@ -1,73 +0,0 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
// RotationWindow is the lead-time before expiry at which we start warning
|
||||
// the operator. REQ-034 says 30 days.
|
||||
const RotationWindow = 30 * 24 * time.Hour
|
||||
|
||||
// RotationAlarm checks cert's remaining validity. Returns nil if the cert
|
||||
// has more than RotationWindow of life left. If remaining <= RotationWindow,
|
||||
// returns a non-nil error wrapping the days-remaining message so callers
|
||||
// can log it. Callers MUST treat a non-nil result as a warning, not a fatal
|
||||
// error — the cert is still usable; we want to alert the operator ahead
|
||||
// of time.
|
||||
func RotationAlarm(cert *x509.Certificate) error {
|
||||
if cert == nil {
|
||||
return errors.New("RotationAlarm: nil cert")
|
||||
}
|
||||
now := time.Now()
|
||||
remaining := cert.NotAfter.Sub(now)
|
||||
if remaining > RotationWindow {
|
||||
return nil
|
||||
}
|
||||
days := int(remaining.Hours() / 24)
|
||||
if days < 0 {
|
||||
days = 0
|
||||
}
|
||||
return fmt.Errorf("cert rotates in %d days (NotAfter=%s) — renew soon (REQ-034)",
|
||||
days, cert.NotAfter.UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// RotationAlarmAt is identical to RotationAlarm but takes an explicit "now"
|
||||
// for deterministic testing.
|
||||
func RotationAlarmAt(cert *x509.Certificate, now time.Time) error {
|
||||
if cert == nil {
|
||||
return errors.New("RotationAlarmAt: nil cert")
|
||||
}
|
||||
remaining := cert.NotAfter.Sub(now)
|
||||
if remaining > RotationWindow {
|
||||
return nil
|
||||
}
|
||||
days := int(remaining.Hours() / 24)
|
||||
if days < 0 {
|
||||
days = 0
|
||||
}
|
||||
return fmt.Errorf("cert rotates in %d days (NotAfter=%s) — renew soon (REQ-034)",
|
||||
days, cert.NotAfter.UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// PruneOldCerts deletes all certs for (nodeID, kind) beyond the most recent
|
||||
// `keep` rows, ordered by created_at DESC. Per REQ-025, the rotation
|
||||
// history is bounded at 10 generations per cert kind. Returns the number
|
||||
// of rows deleted.
|
||||
//
|
||||
// `keep` is a positive integer; values <= 0 are treated as 10 (the
|
||||
// documented max).
|
||||
func PruneOldCerts(ctx context.Context, repo *store.CertRepo, nodeID, kind string, keep int) (int64, error) {
|
||||
if repo == nil {
|
||||
return 0, errors.New("PruneOldCerts: nil repo")
|
||||
}
|
||||
if keep <= 0 {
|
||||
keep = 10
|
||||
}
|
||||
return repo.PruneOlderThan(ctx, nodeID, kind, keep)
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// allowedSuites is the AEAD cipher allowlist required by D-015. We only
|
||||
// support TLS 1.3, so the Go cipher suite names below are TLS 1.3 cipher
|
||||
// suites. In Go 1.22+, the CipherSuites field still works for TLS 1.2
|
||||
// negotiation, but with MinVersion=tls.VersionTLS13 only the TLS 1.3
|
||||
// suites apply.
|
||||
//
|
||||
// We pin the three NIST/CHACHA AEAD suites:
|
||||
// - TLS_AES_256_GCM_SHA384
|
||||
// - TLS_CHACHA20_POLY1305_SHA256
|
||||
// - TLS_AES_128_GCM_SHA256
|
||||
//
|
||||
// No TLS 1.2 fallback. No CBC modes. No NULL/integrity-only modes.
|
||||
var allowedSuites = []uint16{
|
||||
tls.TLS_AES_256_GCM_SHA384,
|
||||
tls.TLS_CHACHA20_POLY1305_SHA256,
|
||||
tls.TLS_AES_128_GCM_SHA256,
|
||||
}
|
||||
|
||||
// AllowedCipherSuites returns a copy of the cipher allowlist. Exposed for
|
||||
// tests and for callers that want to construct their own tls.Config with
|
||||
// the same policy.
|
||||
func AllowedCipherSuites() []uint16 {
|
||||
out := make([]uint16, len(allowedSuites))
|
||||
copy(out, allowedSuites)
|
||||
return out
|
||||
}
|
||||
|
||||
// loadKeyPair is a small helper: load cert + key from disk, return
|
||||
// tls.Certificate. Errors are wrapped with the path that failed.
|
||||
func loadKeyPair(certPath, keyPath string) (tls.Certificate, error) {
|
||||
if certPath == "" || keyPath == "" {
|
||||
return tls.Certificate{}, errors.New("loadKeyPair: certPath and keyPath are required")
|
||||
}
|
||||
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, fmt.Errorf("load cert/key pair (%s, %s): %w", certPath, keyPath, err)
|
||||
}
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
// loadCAPool reads a PEM CA cert file and returns a CertPool containing
|
||||
// that cert. We use the subject as the trust anchor — clients verify
|
||||
// server certs against this single CA.
|
||||
func loadCAPool(caPath string) (*x509.CertPool, error) {
|
||||
if caPath == "" {
|
||||
return nil, errors.New("loadCAPool: caPath is required")
|
||||
}
|
||||
caPEM, err := os.ReadFile(caPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read CA cert: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caPEM) {
|
||||
return nil, fmt.Errorf("parse CA cert PEM from %s", caPath)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
// ServerTLSConfig returns a *tls.Config suitable for an mTLS server. The
|
||||
// server presents certPath/keyPath and requires client certs signed by
|
||||
// the CA at caPath. The cipher allowlist + MinVersion=1.3 are enforced.
|
||||
//
|
||||
// ClientCAs is the same pool as the trust store — peers present certs
|
||||
// signed by the same CA, and we verify them. GetCertificate is left nil;
|
||||
// callers (the daemon) populate it to enable hot-swap on cert renewal.
|
||||
//
|
||||
// Returns an error if any path is missing or any file cannot be read.
|
||||
func ServerTLSConfig(certPath, keyPath, caPath string) (*tls.Config, error) {
|
||||
if _, err := loadKeyPair(certPath, keyPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pool, err := loadCAPool(caPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
MaxVersion: tls.VersionTLS13,
|
||||
CipherSuites: AllowedCipherSuites(),
|
||||
Certificates: []tls.Certificate{{Certificate: nil}}, // placeholder; daemon fills via GetCertificate
|
||||
ClientCAs: pool,
|
||||
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ClientTLSConfig returns a *tls.Config suitable for an mTLS client. The
|
||||
// client verifies the server cert against the CA at caPath. If certPath
|
||||
// and keyPath are both non-empty, the client also presents a cert (for
|
||||
// mutual auth). If only one is set, the call fails — both-or-neither.
|
||||
//
|
||||
// serverName is the expected server identity (SNI / cert SAN match). It
|
||||
// MUST match a SAN on the server cert; the standard tls.Config will then
|
||||
// validate it during the handshake. For extra safety, callers should also
|
||||
// use VerifyPeerCertificate to enforce a pinned peer identity.
|
||||
func ClientTLSConfig(caPath, serverName string, certPath, keyPath string) (*tls.Config, error) {
|
||||
pool, err := loadCAPool(caPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg := &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
MaxVersion: tls.VersionTLS13,
|
||||
CipherSuites: AllowedCipherSuites(),
|
||||
RootCAs: pool,
|
||||
ServerName: serverName,
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
}
|
||||
hasCert, hasKey := certPath != "", keyPath != ""
|
||||
if hasCert != hasKey {
|
||||
return nil, errors.New("ClientTLSConfig: certPath and keyPath must be both set or both empty")
|
||||
}
|
||||
if hasCert && hasKey {
|
||||
cert, err := loadKeyPair(certPath, keyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Certificates = []tls.Certificate{cert}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CertKind enumerates the kinds of certs orca tracks. 'ca' is the
|
||||
// cluster's internal CA; 'server' is a per-node server cert.
|
||||
type CertKind string
|
||||
|
||||
const (
|
||||
CertKindCA CertKind = "ca"
|
||||
CertKindServer CertKind = "server"
|
||||
)
|
||||
|
||||
// Cert is the in-memory representation of a row in the `certs` table.
|
||||
type Cert struct {
|
||||
ID string `json:"id"`
|
||||
Kind CertKind `json:"kind"`
|
||||
NodeID string `json:"node_id"`
|
||||
SerialHex string `json:"serial_hex"`
|
||||
SubjectCN string `json:"subject_cn"`
|
||||
IssuerCN string `json:"issuer_cn"`
|
||||
NotBefore time.Time `json:"not_before"`
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
SourcePath string `json:"source_path,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CertRepo is a CRUD wrapper around the `certs` table.
|
||||
type CertRepo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewCertRepo(db *sql.DB) *CertRepo {
|
||||
return &CertRepo{db: db}
|
||||
}
|
||||
|
||||
// Insert persists a new cert. Fills CreatedAt to now() if zero. The caller
|
||||
// is responsible for setting ID, SerialHex, Fingerprint, etc.
|
||||
func (r *CertRepo) Insert(ctx context.Context, c *Cert) error {
|
||||
if c == nil {
|
||||
return errors.New("CertRepo.Insert: nil cert")
|
||||
}
|
||||
if c.ID == "" {
|
||||
return errors.New("CertRepo.Insert: ID is required")
|
||||
}
|
||||
if c.Kind == "" {
|
||||
return errors.New("CertRepo.Insert: Kind is required")
|
||||
}
|
||||
if c.CreatedAt.IsZero() {
|
||||
c.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`INSERT INTO certs (id, kind, node_id, serial_hex, subject_cn, issuer_cn, not_before, not_after, fingerprint, source_path, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
c.ID, string(c.Kind), c.NodeID, c.SerialHex, c.SubjectCN, c.IssuerCN,
|
||||
c.NotBefore, c.NotAfter, c.Fingerprint, c.SourcePath, c.CreatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CertRepo.Insert: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns a single cert by ID. Returns ErrNotFound if absent.
|
||||
func (r *CertRepo) Get(ctx context.Context, id string) (*Cert, error) {
|
||||
row := r.db.QueryRowContext(ctx,
|
||||
`SELECT id, kind, node_id, serial_hex, subject_cn, issuer_cn, not_before, not_after, fingerprint, source_path, created_at FROM certs WHERE id = ?`, id)
|
||||
return scanCert(row)
|
||||
}
|
||||
|
||||
// List returns all certs ordered by created_at DESC. Use ListByNode /
|
||||
// LatestForKind for filtered queries.
|
||||
func (r *CertRepo) List(ctx context.Context) ([]*Cert, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, kind, node_id, serial_hex, subject_cn, issuer_cn, not_before, not_after, fingerprint, source_path, created_at FROM certs ORDER BY created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CertRepo.List: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var certs []*Cert
|
||||
for rows.Next() {
|
||||
c, err := scanCert(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
certs = append(certs, c)
|
||||
}
|
||||
return certs, rows.Err()
|
||||
}
|
||||
|
||||
// ListByNode returns certs belonging to a node (or matching node_id for the
|
||||
// CA — CA rows use node_id = ”).
|
||||
func (r *CertRepo) ListByNode(ctx context.Context, nodeID string) ([]*Cert, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, kind, node_id, serial_hex, subject_cn, issuer_cn, not_before, not_after, fingerprint, source_path, created_at FROM certs WHERE node_id = ? ORDER BY created_at DESC`, nodeID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CertRepo.ListByNode: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var certs []*Cert
|
||||
for rows.Next() {
|
||||
c, err := scanCert(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
certs = append(certs, c)
|
||||
}
|
||||
return certs, rows.Err()
|
||||
}
|
||||
|
||||
// LatestForKind returns the most recent cert of the given kind for the given
|
||||
// node. Returns ErrNotFound if none exists. nodeID may be empty to query
|
||||
// the cluster-wide CA.
|
||||
func (r *CertRepo) LatestForKind(ctx context.Context, nodeID string, kind CertKind) (*Cert, error) {
|
||||
row := r.db.QueryRowContext(ctx,
|
||||
`SELECT id, kind, node_id, serial_hex, subject_cn, issuer_cn, not_before, not_after, fingerprint, source_path, created_at FROM certs WHERE node_id = ? AND kind = ? ORDER BY created_at DESC LIMIT 1`,
|
||||
nodeID, string(kind))
|
||||
return scanCert(row)
|
||||
}
|
||||
|
||||
// PruneOlderThan deletes certs beyond the most recent `keep` rows for
|
||||
// (nodeID, kind), ordered by created_at DESC. Returns the number of
|
||||
// rows deleted. `keep` must be > 0; values <= 0 are treated as 1.
|
||||
func (r *CertRepo) PruneOlderThan(ctx context.Context, nodeID, kind string, keep int) (int64, error) {
|
||||
if keep <= 0 {
|
||||
keep = 1
|
||||
}
|
||||
// Two-step delete: first find the cutoff created_at, then delete
|
||||
// everything older. Done in a single transaction via ExecContext.
|
||||
// modernc/sqlite supports multiple statements in a single Exec only
|
||||
// via the "multi-statement" pragma; we use a subquery instead.
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
`DELETE FROM certs WHERE node_id = ? AND kind = ? AND id NOT IN (
|
||||
SELECT id FROM certs WHERE node_id = ? AND kind = ?
|
||||
ORDER BY created_at DESC LIMIT ?
|
||||
)`,
|
||||
nodeID, kind, nodeID, kind, keep)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("CertRepo.PruneOlderThan: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Delete removes a cert by ID. Returns ErrNotFound if no rows affected.
|
||||
func (r *CertRepo) Delete(ctx context.Context, id string) error {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM certs WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CertRepo.Delete: %w", err)
|
||||
}
|
||||
rows, _ := res.RowsAffected()
|
||||
if rows == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanCert(s scanner) (*Cert, error) {
|
||||
var (
|
||||
c Cert
|
||||
kindStr string
|
||||
)
|
||||
err := s.Scan(&c.ID, &kindStr, &c.NodeID, &c.SerialHex, &c.SubjectCN, &c.IssuerCN,
|
||||
&c.NotBefore, &c.NotAfter, &c.Fingerprint, &c.SourcePath, &c.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan cert: %w", err)
|
||||
}
|
||||
c.Kind = CertKind(kindStr)
|
||||
return &c, nil
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
-- Cert inventory: every CA + server cert issued by orca, with metadata
|
||||
-- sufficient to drive rotation history, fingerprint pinning, and
|
||||
-- `orca doctor cert` health reports. This is migration 0004; v0.2 P01.
|
||||
--
|
||||
-- `kind` is one of: 'ca', 'server'. CA rows have node_id = '' (the
|
||||
-- CA is per-cluster, not per-node). Server rows have node_id set.
|
||||
-- `serial_hex` is the cert serial as a hex string; used to detect
|
||||
-- duplicate issuances.
|
||||
-- `fingerprint` is SHA-256 hex (lowercase) of the cert's DER bytes;
|
||||
-- matches the value returned by `Fingerprint(certPath)` in
|
||||
-- internal/security.
|
||||
CREATE TABLE IF NOT EXISTS certs (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
node_id TEXT NOT NULL DEFAULT '',
|
||||
serial_hex TEXT NOT NULL,
|
||||
subject_cn TEXT NOT NULL,
|
||||
issuer_cn TEXT NOT NULL,
|
||||
not_before DATETIME NOT NULL,
|
||||
not_after DATETIME NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
source_path TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_certs_kind ON certs(kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_certs_node ON certs(node_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_certs_node_kind ON certs(node_id, kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_certs_created ON certs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_certs_fp ON certs(fingerprint);
|
||||
@@ -1,66 +0,0 @@
|
||||
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[:])
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
#!/bin/bash
|
||||
# backfill_releases.sh - Backfill Gitea releases for existing v0.1 tags
|
||||
#
|
||||
# For each tag passed (or all v0.1.1..v0.1.6 and v0.2.0), this script:
|
||||
# 1. Builds the orca binary from the current milestone branch head
|
||||
# (v0.1 retrospective: phase tags marked ship points but the entry
|
||||
# point fix is consolidated into a single post-fix build; see
|
||||
# .ciagent/RELEASE_POLICY.md for the standing rule)
|
||||
# 2. Injects the historical version via -ldflags
|
||||
# 3. Packages a tarball
|
||||
# 4. Creates a Gitea release with the tarball as an asset
|
||||
#
|
||||
# Idempotent: skips tags that already have a release.
|
||||
#
|
||||
# Usage: scripts/backfill_releases.sh [tag1 tag2 ...]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Source .env for GITEA_TOKEN
|
||||
for env_file in "$REPO_ROOT/.env" "$PWD/.env" "./.env"; do
|
||||
if [ -f "$env_file" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "$env_file"
|
||||
set +a
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
err() { echo "backfill: error: $*" >&2; exit 1; }
|
||||
info() { echo "backfill: $*"; }
|
||||
|
||||
: "${GITEA_TOKEN:?GITEA_TOKEN is required}"
|
||||
command -v tea >/dev/null 2>&1 || err "tea CLI not on PATH"
|
||||
command -v go >/dev/null 2>&1 || err "go not on PATH"
|
||||
command -v tar >/dev/null 2>&1 || err "tar not on PATH"
|
||||
|
||||
REPO="coreci/orca"
|
||||
|
||||
# Default: backfill v0.1.1..v0.1.6 and v0.2.0
|
||||
if [ $# -eq 0 ]; then
|
||||
TAGS=(v0.1.1 v0.1.2 v0.1.3 v0.1.4 v0.1.5 v0.1.6 v0.2.0)
|
||||
else
|
||||
TAGS=("$@")
|
||||
fi
|
||||
|
||||
# Existing releases to skip
|
||||
EXISTING="$(tea releases list --repo "$REPO" --output simple 2>/dev/null | awk '{print $1}' || true)"
|
||||
|
||||
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
x86_64) ARCH=amd64 ;;
|
||||
aarch64) ARCH=arm64 ;;
|
||||
armv7l) ARCH=armv7 ;;
|
||||
esac
|
||||
|
||||
# Use the cached Go 1.25.0 toolchain explicitly
|
||||
TOOLGO="/root/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.25.0.linux-amd64/bin/go"
|
||||
if [ ! -x "$TOOLGO" ]; then
|
||||
TOOLGO="$(command -v go)"
|
||||
fi
|
||||
|
||||
phase_name() {
|
||||
case "$1" in
|
||||
v0.1.1) echo "Phase 1: CLI skeleton" ;;
|
||||
v0.1.2) echo "Phase 2: Node management" ;;
|
||||
v0.1.3) echo "Phase 3: Task execution" ;;
|
||||
v0.1.4) echo "Phase 4: State persistence" ;;
|
||||
v0.1.5) echo "Phase 5: Health checks" ;;
|
||||
v0.1.6) echo "Phase 6: CoreCI release flow" ;;
|
||||
v0.2.0) echo "Milestone v0.1: Foundation complete" ;;
|
||||
*) echo "Orca $1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
for TAG in "${TAGS[@]}"; do
|
||||
if echo "$EXISTING" | grep -qx "$TAG"; then
|
||||
info "skip $TAG (release exists)"
|
||||
continue
|
||||
fi
|
||||
|
||||
info "=== $TAG ==="
|
||||
# The v0.1.1..v0.1.6 phase tags point to merge commits; the canonical
|
||||
# source of truth for v0.1 code is the current milestone branch HEAD
|
||||
# (which includes the main.go entry-point fix).
|
||||
BUILD_COMMIT="$(git rev-parse --short HEAD)"
|
||||
FULL_COMMIT="$(git rev-parse HEAD)"
|
||||
info "build from HEAD: $BUILD_COMMIT (per RELEASE_POLICY.md v0.1 retrospective)"
|
||||
|
||||
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
LDFLAGS="-s -w -X git.cloudinit.dev/coreci/orca/internal/cli.version=$TAG -X git.cloudinit.dev/coreci/orca/internal/cli.gitCommit=$BUILD_COMMIT -X git.cloudinit.dev/coreci/orca/internal/cli.buildTime=$BUILD_TIME"
|
||||
|
||||
mkdir -p bin
|
||||
GOTOOLCHAIN=local "$TOOLGO" build -trimpath -ldflags="$LDFLAGS" -o bin/orca ./cmd/orca
|
||||
|
||||
TARBALL="orca-${TAG}-${OS}-${ARCH}.tar.gz"
|
||||
tar -czf "$TARBALL" -C bin orca
|
||||
info "tarball: $TARBALL ($(du -h "$TARBALL" | cut -f1))"
|
||||
|
||||
# Generate release notes
|
||||
PREV_TAG="$(git describe --tags --abbrev=0 "$TAG^" 2>/dev/null || true)"
|
||||
NOTES_FILE="$(mktemp)"
|
||||
PHASE_NAME="$(phase_name "$TAG")"
|
||||
{
|
||||
echo "# Release $TAG — ${PHASE_NAME}"
|
||||
echo ""
|
||||
echo "_Built: $BUILD_TIME from $BUILD_COMMIT (${FULL_COMMIT:0:12})_"
|
||||
echo ""
|
||||
echo "## Notes"
|
||||
echo ""
|
||||
echo "This release artifact is built from the v0.1 milestone branch HEAD"
|
||||
echo "(post entry-point fix). For v0.2+ and future milestones, every phase"
|
||||
echo "tag will be released with the binary as-of that exact commit; see"
|
||||
echo "\`.ciagent/RELEASE_POLICY.md\` for the standing rule."
|
||||
echo ""
|
||||
if [ -n "$PREV_TAG" ] && [ "$TAG" != "v0.2.0" ]; then
|
||||
echo "## Changes since $PREV_TAG"
|
||||
echo ""
|
||||
git log --pretty=format:'- %s' "${PREV_TAG}..${TAG}" 2>/dev/null | head -50
|
||||
echo ""
|
||||
fi
|
||||
if [ "$TAG" = "v0.2.0" ]; then
|
||||
echo "## Milestone v0.1: Foundation — All Phases"
|
||||
echo ""
|
||||
echo "All 6 phases of the v0.1 Foundation milestone are complete:"
|
||||
echo ""
|
||||
echo "- **Phase 1** (v0.1.1): CLI skeleton with Cobra, subcommand stubs, pre-push hook"
|
||||
echo "- **Phase 2** (v0.1.2): Node management with SQLite-backed registry"
|
||||
echo "- **Phase 3** (v0.1.3): Task execution engine with HCL specs, jobs, tasks, WaitDelay"
|
||||
echo "- **Phase 4** (v0.1.4): Local state persistence — audit log + migration runner"
|
||||
echo "- **Phase 5** (v0.1.5): Health-check daemon with /healthz, /readyz, /v1/* handlers"
|
||||
echo "- **Phase 6** (v0.1.6): CoreCI release flow with .coreci.yml and tea integration"
|
||||
echo ""
|
||||
echo "## Requirements Covered (21/24)"
|
||||
echo ""
|
||||
echo "REQ-001 Go 1.25+ toolchain, REQ-002 CLI-first single binary, REQ-003 Offline-first,"
|
||||
echo "REQ-004 Single-node task execution, REQ-005 modernc/sqlite CGO-free, REQ-006 slog"
|
||||
echo "audit logging, REQ-007 CoreCI release flow, REQ-008 Structured JSON logging,"
|
||||
echo "REQ-009 HCL/YAML job spec parsing, REQ-010 --json output flag, REQ-012 Config"
|
||||
echo "locations (~/.orca/, ORCA_DB), REQ-013 Pre-push hook, REQ-015 MIT LICENSE,"
|
||||
echo "REQ-016 README quickstart, REQ-017 context.Context propagation, REQ-018 %w error"
|
||||
echo "wrapping, REQ-019 Cobra CLI, REQ-020 hashicorp/hcl parser, REQ-021 os/exec"
|
||||
echo "WaitDelay, REQ-024 Makefile standard targets."
|
||||
echo ""
|
||||
echo "Deferred to v0.2: REQ-011/023 (mTLS), REQ-014 (gosec+govulncheck), REQ-022"
|
||||
echo "(iter.Seq streaming)."
|
||||
echo ""
|
||||
echo "## Changes since v0.1.6"
|
||||
echo ""
|
||||
git log --pretty=format:'- %s' "v0.1.6..${TAG}" 2>/dev/null | head -50
|
||||
echo ""
|
||||
fi
|
||||
} > "$NOTES_FILE"
|
||||
|
||||
info "creating gitea release..."
|
||||
tea releases create "$TAG" \
|
||||
--repo "$REPO" \
|
||||
--title "Orca $TAG — ${PHASE_NAME}" \
|
||||
--note-file "$NOTES_FILE" \
|
||||
--asset "$TARBALL"
|
||||
|
||||
info "✓ $TAG published"
|
||||
rm -f "$TARBALL"
|
||||
done
|
||||
|
||||
info "all done"
|
||||
+1
-1
@@ -106,7 +106,7 @@ trap 'rm -f "$NOTES_FILE"' EXIT
|
||||
{
|
||||
echo "# Release $VERSION"
|
||||
echo ""
|
||||
echo "_Built: $BUILD_TIME from $GIT_COMMIT"
|
||||
echo "_Built: $BUILD_TIME from $GIT_COMMIT_"
|
||||
echo ""
|
||||
|
||||
PREV_TAG="$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")"
|
||||
|
||||
Reference in New Issue
Block a user