8d1cdceb5c
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---
124 lines
4.7 KiB
Go
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()
|
|
}
|