Files
orca/internal/cli/doctor_nft.go
T
Jon Chery 8d1cdceb5c feat(P15.5): threat model (C-19) + ingress hybrid (R-017, REQ-099..102) + doctor mTLS (REQ-118)
Sub-wave 1: internal/emitter/nft.go (nftables emitter, DNAT :443→127.0.0.1:8443, rate-limit, SYN-flood filter); Traefik static config 127.0.0.1:8443 binding (D-220); orca doctor nft; orca nft CLI (show/diff/doctor/country-block/rate-limit).
Sub-wave 2: docs/threat-model.md (R-017 trust boundary, R-020 deadlock, D-234 secret exclusion, orca user blast radius, step-ca SPOF); orca doctor mTLS (chain verification + live handshake probe, C5).

---ci---
project: orca
phase: 15.5
milestone: v0.11
status: execute
---/ci---
2026-08-07 08:35:06 +00:00

166 lines
6.2 KiB
Go

// Package cli: doctor_nft.go implements `orca doctor nft` (P15.5,
// REQ-101). The check verifies the R-017 nftables ingress ruleset is
// present, parses, and matches the latest applied txn's hash.
//
// Checks (each emits a PASS/WARN/FAIL line):
//
// 1. table inet orca-ingress exists (nft list table)
// 2. DNAT :443 -> 127.0.0.1:8443 present
// 3. DNAT :80 -> 127.0.0.1:8080 present
// 4. rate-limit meter ora_rl present
// 5. /etc/nftables.d/orca.nft parses (nft -c -f)
// 6. file hash matches the latest applied txn (drift, P10b)
package cli
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"git.cloudinit.dev/coreci/orca/internal/certpaths"
"git.cloudinit.dev/coreci/orca/internal/paths"
"git.cloudinit.dev/coreci/orca/internal/sshpush"
)
// nftTransport is the SSH-push surface doctor nft needs. Mirrors the
// drift CLI seam; tests substitute a mock.
type nftTransport interface {
Exec(ctx context.Context, peer string, cmd string) ([]byte, error)
ReadFile(ctx context.Context, peer string, path string) ([]byte, error)
}
// nftTransportOverride is the package-level test seam.
var nftTransportOverride nftTransport
// nftCheckResult is one line of `orca doctor nft` output.
type nftCheckResult struct {
Name string `json:"name"`
Result string `json:"result"`
Message string `json:"message"`
}
func nftTransportFromCtx() (nftTransport, error) {
if nftTransportOverride != nil {
return nftTransportOverride, nil
}
keyPath := certpaths.SSHKeyPath()
khPath := certpaths.KnownHostsPath()
return sshpush.NewTransport(keyPath, khPath), nil
}
// nftLeadPeerOverride is the test seam for the peer to probe.
var nftLeadPeerOverride string
func nftLeadPeer() string {
if nftLeadPeerOverride != "" {
return nftLeadPeerOverride
}
return "lead"
}
var doctorNftCmd = &cobra.Command{
Use: "nft",
Short: "Run the nftables ingress self-check (P15.5, REQ-101)",
Long: `Verify the R-017 nftables ingress ruleset: table exists, DNAT
:443->127.0.0.1:8443 and :80->127.0.0.1:8080 present, rate-limit meter
present, /etc/nftables.d/orca.nft parses, and the on-disk file hash
matches the latest applied txn (drift, P10b).`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
results := runNftChecks(cmd.Context())
if jsonOutput {
return printJSON(results)
}
for _, r := range results {
fmt.Fprintf(cmd.OutOrStdout(), "%-28s %-5s %s\n", r.Name, r.Result, r.Message)
}
return nil
},
}
// runNftChecks executes the nft doctor checks against nftLeadPeer().
func runNftChecks(ctx context.Context) []nftCheckResult {
t, err := nftTransportFromCtx()
if err != nil {
return []nftCheckResult{{Name: "nft:transport", Result: "FAIL", Message: err.Error()}}
}
peer := nftLeadPeer()
var results []nftCheckResult
tableOut, tableErr := t.Exec(ctx, peer, "nft list table inet orca-ingress")
if tableErr != nil {
results = append(results, nftCheckResult{Name: "nft:table", Result: "FAIL", Message: tableErr.Error()})
} else {
results = append(results, nftCheckResult{Name: "nft:table", Result: "PASS", Message: "table inet orca-ingress present"})
}
tableStr := string(tableOut)
if strings.Contains(tableStr, "dnat to 127.0.0.1:8443") {
results = append(results, nftCheckResult{Name: "nft:dnat-443", Result: "PASS", Message: "DNAT :443->127.0.0.1:8443 present"})
} else {
results = append(results, nftCheckResult{Name: "nft:dnat-443", Result: "FAIL", Message: "DNAT :443->127.0.0.1:8443 missing"})
}
if strings.Contains(tableStr, "dnat to 127.0.0.1:8080") {
results = append(results, nftCheckResult{Name: "nft:dnat-80", Result: "PASS", Message: "DNAT :80->127.0.0.1:8080 present"})
} else {
results = append(results, nftCheckResult{Name: "nft:dnat-80", Result: "FAIL", Message: "DNAT :80->127.0.0.1:8080 missing"})
}
if strings.Contains(tableStr, "ora_rl") {
results = append(results, nftCheckResult{Name: "nft:rate-limit", Result: "PASS", Message: "rate-limit meter ora_rl present"})
} else {
results = append(results, nftCheckResult{Name: "nft:rate-limit", Result: "FAIL", Message: "rate-limit meter ora_rl missing"})
}
if _, err := t.Exec(ctx, peer, "nft -c -f /etc/nftables.d/orca.nft"); err != nil {
results = append(results, nftCheckResult{Name: "nft:parse", Result: "FAIL", Message: fmt.Sprintf("nft -c -f failed: %v", err)})
} else {
results = append(results, nftCheckResult{Name: "nft:parse", Result: "PASS", Message: "/etc/nftables.d/orca.nft parses cleanly"})
}
results = append(results, checkNftHashDrift(ctx, t, peer))
return results
}
// checkNftHashDrift compares the on-peer file hash against the
// locally-recorded hash from the latest applied txn. The locally
// recorded hash is stored at ClusterDir()/nft.applied.sha256 (written by
// the apply path; the drift check reads it). When the local record is
// absent the check WARNs (no baseline to compare against).
func checkNftHashDrift(ctx context.Context, t nftTransport, peer string) nftCheckResult {
liveHashOut, err := t.Exec(ctx, peer, "sha256sum /etc/nftables.d/orca.nft 2>/dev/null")
if err != nil {
return nftCheckResult{Name: "nft:hash-drift", Result: "FAIL", Message: fmt.Sprintf("remote sha256sum: %v", err)}
}
fields := strings.Fields(strings.TrimSpace(string(liveHashOut)))
if len(fields) == 0 {
return nftCheckResult{Name: "nft:hash-drift", Result: "FAIL", Message: "remote sha256sum returned no output"}
}
liveHash := fields[0]
recordPath := filepath.Join(paths.ClusterDir(), "nft.applied.sha256")
recorded, rerr := os.ReadFile(recordPath)
if rerr != nil {
return nftCheckResult{Name: "nft:hash-drift", Result: "WARN", Message: "no applied-txn hash baseline (first apply or record missing)"}
}
want := strings.TrimSpace(string(recorded))
if liveHash == want {
return nftCheckResult{Name: "nft:hash-drift", Result: "PASS", Message: "on-disk hash matches latest applied txn"}
}
return nftCheckResult{Name: "nft:hash-drift", Result: "FAIL", Message: fmt.Sprintf("DRIFT: live=%s recorded=%s", liveHash, want)}
}
// localSha256OfFile is a small helper for tests that compute the
// expected recorded hash from rendered content.
func localSha256OfFile(content []byte) string {
sum := sha256.Sum256(content)
return hex.EncodeToString(sum[:])
}