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---
192 lines
7.1 KiB
Go
192 lines
7.1 KiB
Go
// Package emitter: nft.go implements the nftables emitter for the
|
|
// R-017 ingress hybrid model (P15.5, REQ-099). Orca's ingress is
|
|
// "hybrid": Traefik binds the loopback 127.0.0.1:8443 (mTLS, app L7)
|
|
// and nftables DNATs the public :443 to 127.0.0.1:8443 so the kernel
|
|
// owns the public-facing surface (D-218: modern nft, not legacy
|
|
// iptables; D-217: idempotent `nft -f` apply).
|
|
//
|
|
// The rendered file /etc/nftables.d/orca.nft is a self-contained nft
|
|
// ruleset fragment. It starts with `#!/usr/sbin/nft -f` so it can be
|
|
// applied directly (`nft -f /etc/nftables.d/orca.nft`) and is
|
|
// idempotent: the table is flushed+recreated on each apply (D-217).
|
|
//
|
|
// The ruleset defines:
|
|
//
|
|
// - table inet orca-ingress
|
|
// - set orca_trusted_probes_v4 (ipv4_addr interval, default 127.0.0.1)
|
|
// - set orca_trusted_probes_v6 (ipv6_addr interval, default ::1)
|
|
// - input chain (SYN-flood filter on :443)
|
|
// - prerouting chain (DNAT :443->127.0.0.1:8443, :80->127.0.0.1:8080)
|
|
// - forward chain (rate-limit meter on :443)
|
|
//
|
|
// The emitter is pure (no I/O): RenderNftConfig returns a []File; the
|
|
// SSH-push transport writes the file to each peer.
|
|
package emitter
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
)
|
|
|
|
// NftEmitter renders the R-017 nftables ingress ruleset (REQ-099).
|
|
type NftEmitter struct{}
|
|
|
|
// nftConfigPath is the canonical on-peer path for the rendered
|
|
// ruleset. systemd-locale-independent; nft -f loads it on boot.
|
|
const nftConfigPath = "/etc/nftables.d/orca.nft"
|
|
|
|
// NftClusterConfig carries the cluster-wide knobs the emitter needs.
|
|
// All fields have safe defaults so a zero-value config renders a
|
|
// working ruleset.
|
|
type NftClusterConfig struct {
|
|
// TrustedProbes is the list of source IPs/CIDRs exempt from the
|
|
// SYN-flood filter and rate-limit (monitoring probes, the orca
|
|
// lead itself). Defaults to [127.0.0.1, ::1]. Each entry must
|
|
// parse as a valid IP or CIDR via net.ParseIP / net.ParseCIDR or
|
|
// RenderNftConfig returns an error (F9: ruleset injection guard).
|
|
TrustedProbes []string
|
|
// RateLimit is the per-source rate limit (packets/second) for the
|
|
// forward-chain meter on :443. Defaults to 100.
|
|
RateLimit int
|
|
// RateBurst is the per-source burst (packets) for the meter.
|
|
// Defaults to 200.
|
|
RateBurst int
|
|
}
|
|
|
|
// withDefaults returns a copy of c with zero values replaced by the
|
|
// canonical defaults.
|
|
func (c NftClusterConfig) withDefaults() NftClusterConfig {
|
|
out := c
|
|
if len(out.TrustedProbes) == 0 {
|
|
out.TrustedProbes = []string{"127.0.0.1", "::1"}
|
|
}
|
|
if out.RateLimit <= 0 {
|
|
out.RateLimit = 100
|
|
}
|
|
if out.RateBurst <= 0 {
|
|
out.RateBurst = 200
|
|
}
|
|
return out
|
|
}
|
|
|
|
// RenderNftConfig renders the /etc/nftables.d/orca.nft file for the
|
|
// given cluster config. The output is a single File whose Path is
|
|
// nftConfigPath, Mode is 0644, and Content starts with the
|
|
// `#!/usr/sbin/nft -f` shebang (so `nft -f` applies it and so a
|
|
// drift-check `nft -c -f` validates the syntax).
|
|
//
|
|
// Returns an error when the config is internally inconsistent (e.g. a
|
|
// negative rate, which the defaults already prevent) or when a
|
|
// TrustedProbes entry fails to parse as an IP or CIDR (F9: ruleset
|
|
// injection hardening — unvalidated entries are written directly into
|
|
// the nft ruleset and could inject arbitrary nft syntax).
|
|
func (NftEmitter) RenderNftConfig(clusterConfig NftClusterConfig) ([]File, error) {
|
|
if clusterConfig.RateLimit < 0 || clusterConfig.RateBurst < 0 {
|
|
return nil, errors.New("emitter/nft: rate/burst must be non-negative")
|
|
}
|
|
cfg := clusterConfig.withDefaults()
|
|
// F9: validate every TrustedProbes entry before rendering. An
|
|
// invalid entry is rejected with an error rather than written raw
|
|
// into the ruleset (which would allow nft-syntax injection).
|
|
v4, v6, err := partitionTrustedProbes(cfg.TrustedProbes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
content := renderNftRuleset(cfg, v4, v6)
|
|
return []File{{Path: nftConfigPath, Content: content, Mode: "0644"}}, nil
|
|
}
|
|
|
|
// partitionTrustedProbes validates each entry as an IP or CIDR and
|
|
// partitions the list into IPv4 and IPv6 slices. Returns an error if
|
|
// any entry is neither a valid IP nor a valid CIDR (F9).
|
|
func partitionTrustedProbes(probes []string) (v4, v6 []string, err error) {
|
|
for _, p := range probes {
|
|
if p == "" {
|
|
return nil, nil, fmt.Errorf("emitter/nft: empty trusted probe entry (F9: ruleset injection guard)")
|
|
}
|
|
if ip := net.ParseIP(p); ip != nil {
|
|
if ip.To4() != nil {
|
|
v4 = append(v4, p)
|
|
} else {
|
|
v6 = append(v6, p)
|
|
}
|
|
continue
|
|
}
|
|
if _, _, cidrErr := net.ParseCIDR(p); cidrErr == nil {
|
|
// Determine address family from the CIDR prefix.
|
|
ip := net.ParseIP(strings.Split(p, "/")[0])
|
|
if ip != nil && ip.To4() != nil {
|
|
v4 = append(v4, p)
|
|
} else {
|
|
v6 = append(v6, p)
|
|
}
|
|
continue
|
|
}
|
|
return nil, nil, fmt.Errorf("emitter/nft: trusted probe %q is not a valid IP or CIDR (F9: ruleset injection guard)", p)
|
|
}
|
|
return v4, v6, nil
|
|
}
|
|
|
|
// renderNftRuleset builds the nft ruleset string. The shape is
|
|
// documented in the package comment; the exact lines are load-bearing
|
|
// for `orca doctor nft` (which greps the live table for them) and for
|
|
// `nft -c -f` (which parses the syntax).
|
|
//
|
|
// F9: TrustedProbes are split into separate ipv4_addr and ipv6_addr
|
|
// sets (orca_trusted_probes_v4 / orca_trusted_probes_v6) because the
|
|
// prior single ipv4_addr set included ::1 (an IPv6 address), which is
|
|
// a type mismatch nft rejects.
|
|
func renderNftRuleset(cfg NftClusterConfig, v4, v6 []string) string {
|
|
var b strings.Builder
|
|
b.WriteString("#!/usr/sbin/nft -f\n\n")
|
|
b.WriteString("flush table inet orca-ingress\n\n")
|
|
b.WriteString("table inet orca-ingress {\n")
|
|
|
|
// F9: split IPv4 and IPv6 trusted probes into separate typed sets.
|
|
b.WriteString("\tset orca_trusted_probes_v4 {\n")
|
|
b.WriteString("\t\ttype ipv4_addr\n")
|
|
b.WriteString("\t\tflags interval\n")
|
|
b.WriteString("\t\telements = { ")
|
|
for i, p := range v4 {
|
|
if i > 0 {
|
|
b.WriteString(", ")
|
|
}
|
|
b.WriteString(p)
|
|
}
|
|
b.WriteString(" }\n")
|
|
b.WriteString("\t}\n\n")
|
|
|
|
b.WriteString("\tset orca_trusted_probes_v6 {\n")
|
|
b.WriteString("\t\ttype ipv6_addr\n")
|
|
b.WriteString("\t\tflags interval\n")
|
|
b.WriteString("\t\telements = { ")
|
|
for i, p := range v6 {
|
|
if i > 0 {
|
|
b.WriteString(", ")
|
|
}
|
|
b.WriteString(p)
|
|
}
|
|
b.WriteString(" }\n")
|
|
b.WriteString("\t}\n\n")
|
|
|
|
b.WriteString("\tchain input {\n")
|
|
b.WriteString("\t\ttype filter hook input priority filter; policy accept;\n")
|
|
b.WriteString("\t\tct state invalid drop\n")
|
|
b.WriteString("\t\tct state established,related accept\n")
|
|
b.WriteString("\t\ttcp dport 443 tcp-flags != syn,rst,ack,fin notrack drop\n")
|
|
b.WriteString("\t}\n\n")
|
|
b.WriteString("\tchain prerouting {\n")
|
|
b.WriteString("\t\ttype nat hook prerouting priority -100; policy accept;\n")
|
|
b.WriteString("\t\ttcp dport 443 dnat to 127.0.0.1:8443\n")
|
|
b.WriteString("\t\ttcp dport 80 dnat to 127.0.0.1:8080\n")
|
|
b.WriteString("\t}\n\n")
|
|
b.WriteString("\tchain forward {\n")
|
|
b.WriteString("\t\ttype filter hook forward priority filter; policy accept;\n")
|
|
b.WriteString(fmt.Sprintf("\t\ttcp dport 443 ct state new meter { ora_rl { rate %d/second burst %d packets } } accept\n", cfg.RateLimit, cfg.RateBurst))
|
|
b.WriteString("\t}\n")
|
|
b.WriteString("}\n")
|
|
return b.String()
|
|
}
|