Files
orca/internal/emitter/nft_test.go
T
Jon Chery 5013209e31 feat(P3): nft SNAT+DNAT + orca init ingress bootstrap (REQ-173)
nft emitter (internal/emitter/nft.go):
- Add DNATTarget field (C-51: validated via net.ParseIP; injection
  guard). Default 127.0.0.1; proxmox native uses LXC bridge IP.
- Add EnableSNAT field (default true for zero-value config).
- Add postrouting masquerade chain (research Topic 1):
  ip saddr 127.0.0.0/8 oifname != lo masquerade
- Shift input/forward priority from filter (=0) to -10 (research
  Topic 2: pve-firewall coexistence — avoids same-priority undefined
  evaluation order).

internal/ingress/bootstrap.go (new):
- BootstrapLocalIngress: mkdir dirs, push step-ca root CA (C-60:
  certpaths.CACertPath not CAPath), render+write traefik static
  config (C-58: preserves traefik-on-public-ip opt-out), render+
  write+apply nft ruleset, pre-create table (C-55: avoids first-
  apply flush-table error), ensure podman container. All non-fatal.

init.go: Step 4d now calls ingress.BootstrapLocalIngress (R-024).
doctor_nft.go: assert postrouting masquerade + priority -10.

Tests: nft_test.go — DNATTarget substitution, invalid DNATTarget
rejection (C-51), EnableSNAT=false omits postrouting, priority -10.

---ci---
project: orca
phase: 3
milestone: v0.14
status: execute
---/ci---
2026-08-10 20:09:26 +00:00

228 lines
7.4 KiB
Go

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",
"priority -10; policy accept;", // research Topic 2: pve-firewall coexistence
"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 postrouting", // research Topic 1: SNAT masquerade
"ip saddr 127.0.0.0/8 oifname != \"lo\" masquerade", // scoped to loopback DNAT return
"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])
}
}
// TestNftEmitter_RejectsInvalidTrustedProbe verifies that a TrustedProbes
// entry that is not a valid IP or CIDR is rejected (F9: ruleset injection
// guard). An unvalidated entry written raw into the ruleset could inject
// arbitrary nft syntax.
func TestNftEmitter_RejectsInvalidTrustedProbe(t *testing.T) {
bad := []string{
"not-an-ip",
"127.0.0.1; flush ruleset",
"$(whoami)",
"10.0.0.0/33", // invalid CIDR prefix
}
for _, b := range bad {
_, err := (NftEmitter{}).RenderNftConfig(NftClusterConfig{TrustedProbes: []string{"127.0.0.1", b}})
if err == nil {
t.Errorf("expected error for invalid trusted probe %q, got nil", b)
}
}
}
// TestNftEmitter_TrustedProbesSplitV4V6 verifies that IPv4 and IPv6
// probes are rendered into separate typed sets (F9: the prior single
// ipv4_addr set included ::1, an IPv6 address — a type mismatch).
func TestNftEmitter_TrustedProbesSplitV4V6(t *testing.T) {
files, err := (NftEmitter{}).RenderNftConfig(NftClusterConfig{TrustedProbes: []string{"10.0.0.5", "::1"}})
if err != nil {
t.Fatalf("Render: %v", err)
}
c := files[0].Content
if !strings.Contains(c, "set orca_trusted_probes_v4") {
t.Errorf("missing v4 set:\n%s", c)
}
if !strings.Contains(c, "set orca_trusted_probes_v6") {
t.Errorf("missing v6 set:\n%s", c)
}
if !strings.Contains(c, "type ipv6_addr") {
t.Errorf("missing ipv6_addr type:\n%s", c)
}
if !strings.Contains(c, "10.0.0.5") {
t.Errorf("missing 10.0.0.5 in v4 set:\n%s", c)
}
if !strings.Contains(c, "::1") {
t.Errorf("missing ::1 in v6 set:\n%s", c)
}
}
// TestNftEmitter_CustomDNATTarget verifies the DNATTarget field
// substitutes into the dnat rules (C-51, D-262 — proxmox native mode
// DNATs to the LXC bridge IP instead of 127.0.0.1).
func TestNftEmitter_CustomDNATTarget(t *testing.T) {
files, err := (NftEmitter{}).RenderNftConfig(NftClusterConfig{DNATTarget: "10.99.0.10"})
if err != nil {
t.Fatalf("Render: %v", err)
}
c := files[0].Content
if !strings.Contains(c, "dnat to 10.99.0.10:8443") {
t.Errorf("missing custom DNAT target :8443:\n%s", c)
}
if !strings.Contains(c, "dnat to 10.99.0.10:8080") {
t.Errorf("missing custom DNAT target :8080:\n%s", c)
}
if strings.Contains(c, "127.0.0.1:8443") {
t.Errorf("default 127.0.0.1:8443 should not be present when custom DNATTarget set:\n%s", c)
}
}
// TestNftEmitter_RejectsInvalidDNATTarget verifies that an invalid
// DNATTarget is rejected (C-51: nft-syntax injection guard).
func TestNftEmitter_RejectsInvalidDNATTarget(t *testing.T) {
bad := []string{
"not-an-ip",
"127.0.0.1; flush ruleset",
"$(whoami)",
"10.0.0.0/33",
"",
}
for _, b := range bad {
// Empty string gets defaulted to 127.0.0.1, so it won't error.
// Test only non-empty invalid values.
if b == "" {
continue
}
_, err := (NftEmitter{}).RenderNftConfig(NftClusterConfig{DNATTarget: b})
if err == nil {
t.Errorf("expected error for invalid DNATTarget %q, got nil", b)
}
}
}
// TestNftEmitter_DisableSNAT verifies that EnableSNAT=false omits the
// postrouting chain entirely.
func TestNftEmitter_DisableSNAT(t *testing.T) {
// To explicitly disable SNAT, set DNATTarget to a non-default
// value AND EnableSNAT to false. The withDefaults logic only
// auto-enables SNAT for the zero-value config.
files, err := (NftEmitter{}).RenderNftConfig(NftClusterConfig{
DNATTarget: "10.99.0.10",
EnableSNAT: false,
})
if err != nil {
t.Fatalf("Render: %v", err)
}
c := files[0].Content
if strings.Contains(c, "chain postrouting") {
t.Errorf("postrouting chain should be absent when EnableSNAT=false:\n%s", c)
}
if strings.Contains(c, "masquerade") {
t.Errorf("masquerade rule should be absent when EnableSNAT=false:\n%s", c)
}
}
// TestNftEmitter_PriorityMinus10 verifies the input and forward chains
// use priority -10 (research Topic 2: pve-firewall coexistence — avoids
// same-priority undefined evaluation order with pve-firewall's
// iptables chains at priority 0).
func TestNftEmitter_PriorityMinus10(t *testing.T) {
files, _ := (NftEmitter{}).RenderNftConfig(NftClusterConfig{})
c := files[0].Content
if !strings.Contains(c, "hook input priority -10;") {
t.Errorf("input chain should use priority -10:\n%s", c)
}
if !strings.Contains(c, "hook forward priority -10;") {
t.Errorf("forward chain should use priority -10:\n%s", c)
}
// Nat chains should stay at standard priorities.
if !strings.Contains(c, "hook prerouting priority -100;") {
t.Errorf("prerouting chain should use priority -100:\n%s", c)
}
if !strings.Contains(c, "hook postrouting priority 100;") {
t.Errorf("postrouting chain should use priority 100:\n%s", c)
}
}