// 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[:]) }