// 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() }