Files
orca/internal/emitter/nft.go
T
Jon Chery 8d1cdceb5c feat(P15.5): threat model (C-19) + ingress hybrid (R-017, REQ-099..102) + doctor mTLS (REQ-118)
Sub-wave 1: internal/emitter/nft.go (nftables emitter, DNAT :443→127.0.0.1:8443, rate-limit, SYN-flood filter); Traefik static config 127.0.0.1:8443 binding (D-220); orca doctor nft; orca nft CLI (show/diff/doctor/country-block/rate-limit).
Sub-wave 2: docs/threat-model.md (R-017 trust boundary, R-020 deadlock, D-234 secret exclusion, orca user blast radius, step-ca SPOF); orca doctor mTLS (chain verification + live handshake probe, C5).

---ci---
project: orca
phase: 15.5
milestone: v0.11
status: execute
---/ci---
2026-08-07 08:35:06 +00:00

124 lines
4.7 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 (ipv4_addr interval, default 127.0.0.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"
"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 exempt from the
// SYN-flood filter and rate-limit (monitoring probes, the orca
// lead itself). Defaults to [127.0.0.1, ::1].
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 only when the config is internally inconsistent
// (e.g. a negative rate, which the defaults already prevent).
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()
content := renderNftRuleset(cfg)
return []File{{Path: nftConfigPath, Content: content, Mode: "0644"}}, 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).
func renderNftRuleset(cfg NftClusterConfig) 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")
b.WriteString("\tset orca_trusted_probes {\n")
b.WriteString("\t\ttype ipv4_addr\n")
b.WriteString("\t\tflags interval\n")
b.WriteString("\t\telements = { ")
for i, p := range cfg.TrustedProbes {
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\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()
}