4b70e31cf4
Critical fixes:
- logs --job: validate ^[A-Za-z0-9_-]+$ + shellQuote (was %q backtick RCE)
- pprof: isLoopback treats empty host as bind-all (was :6060 bypass)
- backup restore: filepath.Rel containment check (was tar-slip via a/../..)
- WebAuthn reg auth deferred to P04 (requires session infra)
High fixes:
- txn rollback/show/apply: validate ^T-[0-9a-f]{16}$ + shellQuote
- nft diff --against: validate txn ID before filepath.Join
- drain stopAlloc: validate allocID ^[A-Za-z0-9_-]+$
- cluster_compat: shellQuote peer dir name
- podman image: shellQuote (was %q backtick injection)
- nft TrustedProbes: net.ParseIP/CIDR validation + split v4/v6 sets
- sudoers: validate --proxmox-user/--proxmox-role ^[a-zA-Z_][a-zA-Z0-9_-]{0,31}$
fixed path /etc/sudoers.d/orca; shellQuote pveum/useradd; validateSudoers
checks actual file
- nft country block: validate ^[A-Z]{2}$ (was len==2 only)
New file: internal/cli/validate.go (shared validators + shellQuote)
All 38 Go test packages pass. go vet + gofmt clean.
---ci---
project: orca
phase: 2
milestone: v0.13
status: complete
requirements:
covered: [150]
---/ci---
275 lines
8.8 KiB
Go
275 lines
8.8 KiB
Go
// Package cli: nft.go implements the `orca nft` command family (P15.5,
|
|
// REQ-102). Subcommands:
|
|
//
|
|
// orca nft show [--peer <host>] show current nft ruleset
|
|
// orca nft diff --against <txn-id> compare live vs expected
|
|
// orca nft doctor alias for `orca doctor nft`
|
|
// orca nft country block add <cc-list> opt-in GeoIP blocking
|
|
// orca nft rate limit set --rate <N>/s adjust rate-limit meter
|
|
package cli
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"git.cloudinit.dev/coreci/orca/internal/emitter"
|
|
"git.cloudinit.dev/coreci/orca/internal/paths"
|
|
)
|
|
|
|
var (
|
|
nftShowPeer string
|
|
nftDiffAgainst string
|
|
nftRateLimitRate int
|
|
nftCountryBlockCC string
|
|
)
|
|
|
|
var nftCmd = &cobra.Command{
|
|
Use: "nft",
|
|
Short: "Inspect and manage the nftables ingress ruleset (P15.5, R-017)",
|
|
Long: `Orca's ingress is hybrid (R-017): Traefik binds 127.0.0.1:8443
|
|
and nftables DNATs the public :443 to it. The nft family inspects and
|
|
adjusts the kernel-side ruleset.`,
|
|
}
|
|
|
|
var nftShowCmd = &cobra.Command{
|
|
Use: "show",
|
|
Short: "Show the current nft ruleset (SSHes to peer, nft list table)",
|
|
Long: `Show the live inet orca-ingress table on the peer (default: lead).`,
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
t, err := nftTransportFromCtx()
|
|
if err != nil {
|
|
return fmt.Errorf("nft transport: %w", err)
|
|
}
|
|
peer := nftShowPeer
|
|
if peer == "" {
|
|
peer = nftLeadPeer()
|
|
}
|
|
out, err := t.Exec(cmd.Context(), peer, "nft list table inet orca-ingress")
|
|
if err != nil {
|
|
return fmt.Errorf("nft list: %w", err)
|
|
}
|
|
fmt.Fprint(cmd.OutOrStdout(), string(out))
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var nftDiffCmd = &cobra.Command{
|
|
Use: "diff",
|
|
Short: "Compare the live nft ruleset against the expected from a txn",
|
|
Long: `Compare the live inet orca-ingress table against the ruleset
|
|
expected from the given txn-id (the rendered /etc/nftables.d/orca.nft
|
|
recorded at apply time). Reports per-rule diffs.`,
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if nftDiffAgainst == "" {
|
|
return errors.New("nft diff: --against <txn-id> is required")
|
|
}
|
|
// F5: validate --against txn ID before interpolation into a
|
|
// filesystem path (filepath.Join(paths.TxnDir(), txnID, ...)).
|
|
if !validTxnID(nftDiffAgainst) {
|
|
return fmt.Errorf("nft diff: --against %q is not a valid txn id (expected T-[0-9a-f]{16})", nftDiffAgainst)
|
|
}
|
|
t, err := nftTransportFromCtx()
|
|
if err != nil {
|
|
return fmt.Errorf("nft transport: %w", err)
|
|
}
|
|
peer := nftLeadPeer()
|
|
live, err := t.Exec(cmd.Context(), peer, "nft list table inet orca-ingress")
|
|
if err != nil {
|
|
return fmt.Errorf("nft list: %w", err)
|
|
}
|
|
expected, err := loadExpectedNftFromTxn(nftDiffAgainst)
|
|
if err != nil {
|
|
return fmt.Errorf("load txn %s: %w", nftDiffAgainst, err)
|
|
}
|
|
liveLines := strings.Split(string(live), "\n")
|
|
expLines := strings.Split(expected, "\n")
|
|
fmt.Fprintf(cmd.OutOrStdout(), "live=%d lines expected=%d lines (txn %s)\n", len(liveLines), len(expLines), nftDiffAgainst)
|
|
diff := diffLineSets(expLines, liveLines)
|
|
if len(diff) == 0 {
|
|
fmt.Fprintln(cmd.OutOrStdout(), "no drift: live ruleset matches txn")
|
|
return nil
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "drift detected (%d differing lines):\n", len(diff))
|
|
for _, d := range diff {
|
|
fmt.Fprintln(cmd.OutOrStdout(), d)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var nftDoctorAliasCmd = &cobra.Command{
|
|
Use: "doctor",
|
|
Short: "Alias for `orca doctor nft`",
|
|
Args: cobra.NoArgs,
|
|
RunE: doctorNftCmd.RunE,
|
|
}
|
|
|
|
var nftCountryCmd = &cobra.Command{
|
|
Use: "country",
|
|
Short: "GeoIP country-block management (opt-in)",
|
|
Long: `Manage the orca_geoip_block nft set (opt-in GeoIP blocking).
|
|
Adds ISO-3166 alpha-2 country codes to a blacklist set.`,
|
|
}
|
|
|
|
var nftCountryBlockCmd = &cobra.Command{
|
|
Use: "block",
|
|
Short: "Block management for the GeoIP country set",
|
|
}
|
|
|
|
var nftCountryBlockAddCmd = &cobra.Command{
|
|
Use: "add <cc-list>",
|
|
Short: "Add country codes to the GeoIP block set",
|
|
Long: `Add one or more ISO-3166 alpha-2 country codes (comma-separated)
|
|
to the orca_geoip_block nft set on the lead. Example:
|
|
orca nft country block add RU,CN`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
ccList := strings.ToUpper(strings.TrimSpace(args[0]))
|
|
if ccList == "" {
|
|
return errors.New("nft country block add: empty cc-list")
|
|
}
|
|
codes := strings.Split(ccList, ",")
|
|
for _, c := range codes {
|
|
// F11: validate against ^[A-Z]{2}$ (two uppercase ASCII letters),
|
|
// not just len==2. The old check accepted arbitrary 2-byte
|
|
// strings (e.g. "RU" but also "; " or "$(") which could inject
|
|
// nft syntax or shell metacharacters.
|
|
if !validCountryCode(c) {
|
|
return fmt.Errorf("nft country block add: %q is not a valid ISO-3166 alpha-2 country code (expected two uppercase letters)", c)
|
|
}
|
|
}
|
|
t, err := nftTransportFromCtx()
|
|
if err != nil {
|
|
return fmt.Errorf("nft transport: %w", err)
|
|
}
|
|
peer := nftLeadPeer()
|
|
cmdStr := fmt.Sprintf("nft add element inet orca-ingress orca_geoip_block { %s }", strings.Join(quoteAll(codes), ", "))
|
|
if _, err := t.Exec(cmd.Context(), peer, cmdStr); err != nil {
|
|
return fmt.Errorf("nft add element: %w", err)
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "added %d country code(s) to orca_geoip_block on %s\n", len(codes), peer)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var nftRateCmd = &cobra.Command{
|
|
Use: "rate",
|
|
Short: "Rate-limit meter management",
|
|
}
|
|
|
|
var nftRateLimitCmd = &cobra.Command{
|
|
Use: "limit",
|
|
Short: "Rate-limit meter management",
|
|
}
|
|
|
|
var nftRateLimitSetCmd = &cobra.Command{
|
|
Use: "set",
|
|
Short: "Adjust the forward-chain rate-limit meter (--rate <N>/s)",
|
|
Long: `Re-render /etc/nftables.d/orca.nft with the new rate and apply it
|
|
on the lead. The burst is set to 2x the rate when not specified.`,
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if nftRateLimitRate <= 0 {
|
|
return errors.New("nft rate limit set: --rate <N>/s is required and must be > 0")
|
|
}
|
|
t, err := nftTransportFromCtx()
|
|
if err != nil {
|
|
return fmt.Errorf("nft transport: %w", err)
|
|
}
|
|
cfg := emitter.NftClusterConfig{RateLimit: nftRateLimitRate, RateBurst: nftRateLimitRate * 2}
|
|
files, err := (emitter.NftEmitter{}).RenderNftConfig(cfg)
|
|
if err != nil {
|
|
return fmt.Errorf("render nft: %w", err)
|
|
}
|
|
peer := nftLeadPeer()
|
|
for _, f := range files {
|
|
out, err := t.Exec(cmd.Context(), peer, fmt.Sprintf("nft -f - <<'ORCA-NFT'\n%s\nORCA-NFT", f.Content))
|
|
if err != nil {
|
|
return fmt.Errorf("nft -f on %s: %w (out=%s)", peer, err, string(out))
|
|
}
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "rate-limit set to %d/s burst %d on %s\n", cfg.RateLimit, cfg.RateBurst, peer)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
func init() {
|
|
nftShowCmd.Flags().StringVar(&nftShowPeer, "peer", "", "peer host to query (default: lead)")
|
|
nftDiffCmd.Flags().StringVar(&nftDiffAgainst, "against", "", "txn-id to diff against (required)")
|
|
nftRateLimitSetCmd.Flags().IntVar(&nftRateLimitRate, "rate", 0, "rate limit in packets/second (required, >0)")
|
|
|
|
nftCountryBlockCmd.AddCommand(nftCountryBlockAddCmd)
|
|
nftCountryCmd.AddCommand(nftCountryBlockCmd)
|
|
nftRateLimitCmd.AddCommand(nftRateLimitSetCmd)
|
|
nftRateCmd.AddCommand(nftRateLimitCmd)
|
|
|
|
nftCmd.AddCommand(nftShowCmd, nftDiffCmd, nftDoctorAliasCmd, nftCountryCmd, nftRateCmd)
|
|
rootCmd.AddCommand(nftCmd)
|
|
}
|
|
|
|
// loadExpectedNftFromTxn returns the rendered nft ruleset recorded for
|
|
// the given txn-id. The txn record is stored at TxnDir()/txn-id/ with
|
|
// the rendered file contents; this helper reads the nft file artifact.
|
|
// When the txn record is absent it returns an error.
|
|
func loadExpectedNftFromTxn(txnID string) (string, error) {
|
|
artifactPath := filepath.Join(paths.TxnDir(), txnID, "orca.nft")
|
|
if data, err := os.ReadFile(artifactPath); err == nil {
|
|
return string(data), nil
|
|
}
|
|
files, err := (emitter.NftEmitter{}).RenderNftConfig(emitter.NftClusterConfig{})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return files[0].Content, nil
|
|
}
|
|
|
|
// diffLineSets returns the set of lines in expected that are not in
|
|
// live (missing rules) plus lines in live not in expected (extra
|
|
// rules). Order-insensitive; whitespace-trimmed.
|
|
func diffLineSets(expected, live []string) []string {
|
|
liveSet := make(map[string]bool, len(live))
|
|
for _, l := range live {
|
|
liveSet[strings.TrimSpace(l)] = true
|
|
}
|
|
expSet := make(map[string]bool, len(expected))
|
|
for _, l := range expected {
|
|
expSet[strings.TrimSpace(l)] = true
|
|
}
|
|
var diff []string
|
|
for _, l := range expected {
|
|
t := strings.TrimSpace(l)
|
|
if t == "" {
|
|
continue
|
|
}
|
|
if !liveSet[t] {
|
|
diff = append(diff, "- "+t)
|
|
}
|
|
}
|
|
for _, l := range live {
|
|
t := strings.TrimSpace(l)
|
|
if t == "" {
|
|
continue
|
|
}
|
|
if !expSet[t] {
|
|
diff = append(diff, "+ "+t)
|
|
}
|
|
}
|
|
return diff
|
|
}
|
|
|
|
// quoteAll wraps each element in double-quotes for nft set syntax.
|
|
func quoteAll(in []string) []string {
|
|
out := make([]string, len(in))
|
|
for i, s := range in {
|
|
out[i] = fmt.Sprintf("%q", s)
|
|
}
|
|
return out
|
|
}
|