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:
@@ -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()
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user