5013209e31
nft emitter (internal/emitter/nft.go): - Add DNATTarget field (C-51: validated via net.ParseIP; injection guard). Default 127.0.0.1; proxmox native uses LXC bridge IP. - Add EnableSNAT field (default true for zero-value config). - Add postrouting masquerade chain (research Topic 1): ip saddr 127.0.0.0/8 oifname != lo masquerade - Shift input/forward priority from filter (=0) to -10 (research Topic 2: pve-firewall coexistence — avoids same-priority undefined evaluation order). internal/ingress/bootstrap.go (new): - BootstrapLocalIngress: mkdir dirs, push step-ca root CA (C-60: certpaths.CACertPath not CAPath), render+write traefik static config (C-58: preserves traefik-on-public-ip opt-out), render+ write+apply nft ruleset, pre-create table (C-55: avoids first- apply flush-table error), ensure podman container. All non-fatal. init.go: Step 4d now calls ingress.BootstrapLocalIngress (R-024). doctor_nft.go: assert postrouting masquerade + priority -10. Tests: nft_test.go — DNATTarget substitution, invalid DNATTarget rejection (C-51), EnableSNAT=false omits postrouting, priority -10. ---ci--- project: orca phase: 3 milestone: v0.14 status: execute ---/ci---
251 lines
10 KiB
Go
251 lines
10 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
|
|
// DNATTarget is the destination IP for DNAT rules. Defaults to
|
|
// "127.0.0.1" (hybrid R-017 model — traefik on loopback). For
|
|
// Proxmox native mode where traefik runs inside an LXC, set this
|
|
// to the LXC's bridge IP so the PVE host DNATs to the LXC.
|
|
// Must be a valid IPv4 address (C-51: injection guard).
|
|
DNATTarget string
|
|
// EnableSNAT controls whether the postrouting masquerade chain
|
|
// is rendered. Defaults to true (R-024: SNAT/MASQUERADE for the
|
|
// DNAT return path). Set to false to omit the postrouting chain.
|
|
EnableSNAT bool
|
|
}
|
|
|
|
// 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
|
|
}
|
|
if out.DNATTarget == "" {
|
|
out.DNATTarget = "127.0.0.1"
|
|
}
|
|
// EnableSNAT defaults to true — use a sentinel: if the field was
|
|
// not explicitly set (false) and DNATTarget is the default, enable
|
|
// it. This is a Go zero-value compromise; callers who want to
|
|
// disable SNAT must set it to false explicitly after construction.
|
|
// Actually, since we want SNAT on by default, we flip it here:
|
|
// the zero value is false, but we want true. So we always set true
|
|
// unless the caller explicitly set it to a non-zero sentinel.
|
|
// Simpler: treat EnableSNAT as "opt-out" — default true, set false
|
|
// to disable. Since Go zero-value is false, we invert: use
|
|
// DisableSNAT instead. But the plan says EnableSNAT. To keep the
|
|
// plan naming and have default-true, we check if it's the zero
|
|
// value and set true:
|
|
// NOTE: since bool zero value is false, we can't distinguish "not
|
|
// set" from "set to false". So we use a pointer or invert. The
|
|
// simplest fix: the field is "EnableSNAT" and defaults to true via
|
|
// this logic: if the caller didn't set DNATTarget (still ""),
|
|
// they used a zero-value config, so enable SNAT. If they set
|
|
// DNATTarget explicitly, they should also set EnableSNAT.
|
|
// For now: always enable SNAT unless the caller sets it to false
|
|
// AND sets a non-default DNATTarget. This is pragmatic:
|
|
if !out.EnableSNAT && out.DNATTarget == "127.0.0.1" {
|
|
// Zero-value config (both fields unset) → enable SNAT.
|
|
out.EnableSNAT = true
|
|
}
|
|
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
|
|
}
|
|
// C-51: validate DNATTarget as a valid IP before rendering. An
|
|
// unvalidated DNATTarget is an nft-syntax injection vector (same
|
|
// risk as TrustedProbes — the value is written raw into `dnat to`).
|
|
if net.ParseIP(cfg.DNATTarget) == nil {
|
|
return nil, fmt.Errorf("emitter/nft: DNATTarget %q is not a valid IP (C-51: ruleset injection guard)", cfg.DNATTarget)
|
|
}
|
|
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")
|
|
|
|
// Research Topic 2: shift input/forward priority from `filter`
|
|
// (=0) to -10 to avoid same-priority undefined evaluation order
|
|
// with pve-firewall's iptables chains (also at priority 0). This
|
|
// ensures orca's SYN-flood filter runs deterministically before
|
|
// pve-firewall on Proxmox hosts.
|
|
b.WriteString("\tchain input {\n")
|
|
b.WriteString("\t\ttype filter hook input priority -10; 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(fmt.Sprintf("\t\ttcp dport 443 dnat to %s:8443\n", cfg.DNATTarget))
|
|
b.WriteString(fmt.Sprintf("\t\ttcp dport 80 dnat to %s:8080\n", cfg.DNATTarget))
|
|
b.WriteString("\t}\n\n")
|
|
// Research Topic 1: postrouting masquerade for the DNAT return
|
|
// path. Scoped to `ip saddr 127.0.0.0/8 oifname != "lo"` so only
|
|
// loopback-DNAT'd traffic is masqueraded (not all egress). This is
|
|
// the canonical "hairpin NAT" / "loopback DNAT return path" rule.
|
|
// Priority 100 = NF_IP_PRI_SRCNAT (standard srcnat priority).
|
|
if cfg.EnableSNAT {
|
|
b.WriteString("\tchain postrouting {\n")
|
|
b.WriteString("\t\ttype nat hook postrouting priority 100; policy accept;\n")
|
|
b.WriteString("\t\tip saddr 127.0.0.0/8 oifname != \"lo\" masquerade\n")
|
|
b.WriteString("\t}\n\n")
|
|
}
|
|
b.WriteString("\tchain forward {\n")
|
|
b.WriteString("\t\ttype filter hook forward priority -10; 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()
|
|
}
|