8d1cdceb5c
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---
166 lines
6.2 KiB
Go
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[:])
|
|
}
|