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