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---
This commit is contained in:
Jon Chery
2026-08-07 08:24:34 +00:00
parent cc57ae4c23
commit 8d1cdceb5c
9 changed files with 1170 additions and 1 deletions
+123
View File
@@ -0,0 +1,123 @@
// 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()
}
+92
View File
@@ -0,0 +1,92 @@
package emitter
import (
"strings"
"testing"
)
func TestNftEmitter_RenderBasic(t *testing.T) {
files, err := (NftEmitter{}).RenderNftConfig(NftClusterConfig{})
if err != nil {
t.Fatalf("RenderNftConfig: %v", err)
}
if len(files) != 1 {
t.Fatalf("got %d files, want 1", len(files))
}
f := files[0]
if f.Path != "/etc/nftables.d/orca.nft" {
t.Errorf("Path = %q, want /etc/nftables.d/orca.nft", f.Path)
}
if f.Mode != "0644" {
t.Errorf("Mode = %q, want 0644", f.Mode)
}
c := f.Content
for _, want := range []string{
"#!/usr/sbin/nft -f",
"table inet orca-ingress",
"set orca_trusted_probes",
"type ipv4_addr",
"flags interval",
"127.0.0.1",
"::1",
"chain input",
"tcp dport 443 tcp-flags != syn,rst,ack,fin notrack drop",
"chain prerouting",
"tcp dport 443 dnat to 127.0.0.1:8443",
"tcp dport 80 dnat to 127.0.0.1:8080",
"chain forward",
"rate 100/second burst 200 packets",
"ora_rl",
} {
if !strings.Contains(c, want) {
t.Errorf("content missing %q:\n%s", want, c)
}
}
}
func TestNftEmitter_DefaultsApplied(t *testing.T) {
files, err := (NftEmitter{}).RenderNftConfig(NftClusterConfig{RateLimit: 0, RateBurst: 0})
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(files[0].Content, "rate 100/second burst 200 packets") {
t.Errorf("defaults not applied:\n%s", files[0].Content)
}
}
func TestNftEmitter_CustomRate(t *testing.T) {
files, err := (NftEmitter{}).RenderNftConfig(NftClusterConfig{RateLimit: 50, RateBurst: 150})
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(files[0].Content, "rate 50/second burst 150 packets") {
t.Errorf("custom rate not rendered:\n%s", files[0].Content)
}
}
func TestNftEmitter_CustomTrustedProbes(t *testing.T) {
files, err := (NftEmitter{}).RenderNftConfig(NftClusterConfig{TrustedProbes: []string{"10.0.0.5", "192.168.1.1"}})
if err != nil {
t.Fatalf("Render: %v", err)
}
c := files[0].Content
if !strings.Contains(c, "10.0.0.5, 192.168.1.1") {
t.Errorf("custom probes not rendered:\n%s", c)
}
if strings.Contains(c, "::1") {
t.Errorf("default ::1 should not be present when custom probes set:\n%s", c)
}
}
func TestNftEmitter_NegativeRateRejected(t *testing.T) {
if _, err := (NftEmitter{}).RenderNftConfig(NftClusterConfig{RateLimit: -1}); err == nil {
t.Fatal("expected error for negative rate, got nil")
}
}
func TestNftEmitter_ShebangFirst(t *testing.T) {
files, _ := (NftEmitter{}).RenderNftConfig(NftClusterConfig{})
if !strings.HasPrefix(files[0].Content, "#!/usr/sbin/nft -f\n") {
t.Errorf("shebang not first:\n%s", files[0].Content[:40])
}
}
+74
View File
@@ -223,3 +223,77 @@ func allocIDFor(node *Node) string {
}
return node.Hostname
}
// traefikStaticConfigPath is the canonical on-peer path for the
// Traefik static config (R-017, D-220).
const traefikStaticConfigPath = "/etc/traefik/traefik.yml"
// TraefikStaticOpts controls the Traefik static-config binding. The
// default (PublicBinding="hybrid", R-017) binds the entrypoints to the
// loopback so nftables owns the public surface; the opt-out
// ("traefik-on-public-ip") binds them to the wildcard so Traefik owns
// the public surface directly (legacy / single-host deployments).
type TraefikStaticOpts struct {
// PublicBinding selects the binding model:
// "hybrid" (default, R-017): loopback bind + nft DNAT.
// "traefik-on-public-ip": Traefik binds :443/:80 directly.
PublicBinding string
}
// withDefaults returns a copy of o with the default PublicBinding
// applied when empty.
func (o TraefikStaticOpts) withDefaults() TraefikStaticOpts {
out := o
if strings.TrimSpace(out.PublicBinding) == "" {
out.PublicBinding = "hybrid"
}
return out
}
func (o TraefikStaticOpts) publicWebSecure() string {
if o.PublicBinding == "traefik-on-public-ip" {
return ":443"
}
return "127.0.0.1:8443"
}
func (o TraefikStaticOpts) publicWeb() string {
if o.PublicBinding == "traefik-on-public-ip" {
return ":80"
}
return "127.0.0.1:8080"
}
// RenderTraefikStaticConfig renders /etc/traefik/traefik.yml (REQ-100,
// D-220). The static config pins the entrypoint bind addresses (R-017
// hybrid default: loopback + nft DNAT; opt-out: Traefik on the public
// IP), the file provider (watch=/etc/traefik/dynamic/orca.yml), and
// json log + access log.
func (TraefikEmitter) RenderTraefikStaticConfig(opts TraefikStaticOpts) ([]File, error) {
o := opts.withDefaults()
content := renderTraefikStaticYAML(o)
return []File{{Path: traefikStaticConfigPath, Content: content, Mode: "0644"}}, nil
}
// renderTraefikStaticYAML builds the static-config YAML. The shape is
// load-bearing for `orca doctor ingress` (which greps the live file
// for the bind addresses).
func renderTraefikStaticYAML(o TraefikStaticOpts) string {
var b strings.Builder
b.WriteString("entryPoints:\n")
b.WriteString(" websecure:\n")
b.WriteString(fmt.Sprintf(" address: %q\n", o.publicWebSecure()))
b.WriteString(" web:\n")
b.WriteString(fmt.Sprintf(" address: %q\n", o.publicWeb()))
b.WriteString(" traefik:\n")
b.WriteString(fmt.Sprintf(" address: %q\n", "127.0.0.1:8081"))
b.WriteString("\nproviders:\n")
b.WriteString(" file:\n")
b.WriteString(fmt.Sprintf(" filename: %q\n", "/etc/traefik/dynamic/orca.yml"))
b.WriteString(" watch: true\n")
b.WriteString("\nlog:\n")
b.WriteString(" level: INFO\n")
b.WriteString(" format: json\n")
b.WriteString("\naccessLog:\n")
b.WriteString(" format: json\n")
return b.String()
}
+53
View File
@@ -351,3 +351,56 @@ func TestRegisterTraefik_OverwritesExisting(t *testing.T) {
// Compile-time assertion that TraefikEmitter implements Emitter.
var _ Emitter = TraefikEmitter{}
func TestTraefikEmitter_RenderStaticConfigHybrid(t *testing.T) {
files, err := TraefikEmitter{}.RenderTraefikStaticConfig(TraefikStaticOpts{})
if err != nil {
t.Fatalf("RenderTraefikStaticConfig: %v", err)
}
if len(files) != 1 {
t.Fatalf("got %d files, want 1", len(files))
}
f := files[0]
if f.Path != "/etc/traefik/traefik.yml" {
t.Errorf("Path = %q, want /etc/traefik/traefik.yml", f.Path)
}
if f.Mode != "0644" {
t.Errorf("Mode = %q, want 0644", f.Mode)
}
c := f.Content
for _, want := range []string{
"entryPoints:",
`address: "127.0.0.1:8443"`,
`address: "127.0.0.1:8080"`,
`address: "127.0.0.1:8081"`,
"providers:",
"file:",
`filename: "/etc/traefik/dynamic/orca.yml"`,
"watch: true",
"log:",
"level: INFO",
"format: json",
"accessLog:",
} {
if !strings.Contains(c, want) {
t.Errorf("static config missing %q:\n%s", want, c)
}
}
}
func TestTraefikEmitter_RenderStaticConfigPublicBinding(t *testing.T) {
files, err := TraefikEmitter{}.RenderTraefikStaticConfig(TraefikStaticOpts{PublicBinding: "traefik-on-public-ip"})
if err != nil {
t.Fatalf("Render: %v", err)
}
c := files[0].Content
if !strings.Contains(c, `address: ":443"`) {
t.Errorf("public binding :443 missing:\n%s", c)
}
if !strings.Contains(c, `address: ":80"`) {
t.Errorf("public binding :80 missing:\n%s", c)
}
if strings.Contains(c, `address: "127.0.0.1:8443"`) {
t.Errorf("hybrid bind should not be present under public binding:\n%s", c)
}
}