From 79994133f2f2c485c4dd1036cc4e5b5f77b4b4a3 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 15 Jun 2026 22:27:18 -0400 Subject: [PATCH 1/2] Feat: Expose configurable iptables backend (IPTABLES_CMD) for proxy-init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional ProxyConfig.IptablesCmd that the webhook injects as the IPTABLES_CMD env var on the proxy-init init container (both redirect and enforce-redirect modes). Empty by default — proxy-init then auto-detects the backend from /proc/modules (kagenti-extensions#518). The override is the deterministic, per-platform escape hatch: set it to "iptables" (nft) on nft-only platforms (OpenShift/ROSA) or "iptables-legacy" to force a backend where auto-detection is wrong or undesired. - config: ProxyConfig.IptablesCmd (json/yaml tags), empty compiled default, logged in logConfig. - injector: append IPTABLES_CMD env only when non-empty, after the mode switch so both modes are covered; default injection stays unchanged. - chart: defaults.proxy.iptablesCmd ("") in values.yaml, documented. - tests: assert IPTABLES_CMD is absent by default and present with the configured value in both modes. Companion to kagenti-extensions#518 (the script-side /proc/modules detection + loud-fail). The script is self-sufficient without this; this gives operators a deterministic per-platform override. Pre-existing golangci-lint findings in untouched files (pod_mutator.go, volume_builder_test.go) are out of scope. Refs kagenti-extensions#518, kagenti-extensions#502 Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- charts/kagenti-operator/values.yaml | 5 +++ .../internal/webhook/config/defaults.go | 3 ++ .../internal/webhook/config/loader.go | 2 + .../internal/webhook/config/types.go | 8 ++++ .../webhook/injector/container_builder.go | 8 ++++ .../injector/container_builder_test.go | 39 +++++++++++++++++++ 6 files changed, 65 insertions(+) diff --git a/charts/kagenti-operator/values.yaml b/charts/kagenti-operator/values.yaml index 411e5c5e..466148bc 100644 --- a/charts/kagenti-operator/values.yaml +++ b/charts/kagenti-operator/values.yaml @@ -228,6 +228,11 @@ defaults: # Cluster DNS is kept direct by proxy-init itself (it reads the pod's # /etc/resolv.conf nameservers), so there is no in-cluster CIDR knob to set — # works on Kind / OpenShift / EKS / NodeLocal-DNSCache with no per-cluster config. + # iptables backend override, injected as IPTABLES_CMD. Empty (default) lets + # proxy-init auto-detect from /proc/modules (iptable_nat loaded => legacy, as + # on Kind/kubeadm; absent => nft, as on OpenShift/ROSA). Set to "iptables" + # (nft) or "iptables-legacy" to force a backend where detection is undesired. + iptablesCmd: "" # Resource defaults (conservative for dev) # Note: requests must be <= limits diff --git a/kagenti-operator/internal/webhook/config/defaults.go b/kagenti-operator/internal/webhook/config/defaults.go index 50aa7892..86f6c1e7 100644 --- a/kagenti-operator/internal/webhook/config/defaults.go +++ b/kagenti-operator/internal/webhook/config/defaults.go @@ -35,6 +35,9 @@ func CompiledDefaults() *PlatformConfig { // Transparent listener port — must match the authbridge proxy-sidecar // preset (listener.transparent_proxy_addr default :8082). TransparentPort: 8082, + // Empty by default: proxy-init auto-detects the iptables backend from + // /proc/modules. Set (e.g. "iptables") to force a backend per-platform. + IptablesCmd: "", }, Resources: ResourcesConfig{ EnvoyProxy: corev1.ResourceRequirements{ diff --git a/kagenti-operator/internal/webhook/config/loader.go b/kagenti-operator/internal/webhook/config/loader.go index 3a2d8167..3d61682e 100644 --- a/kagenti-operator/internal/webhook/config/loader.go +++ b/kagenti-operator/internal/webhook/config/loader.go @@ -199,6 +199,8 @@ func logConfig(cfg *PlatformConfig, source string) { "uid", cfg.Proxy.UID, "inboundProxyPort", cfg.Proxy.InboundProxyPort, "adminPort", cfg.Proxy.AdminPort, + "transparentPort", cfg.Proxy.TransparentPort, + "iptablesCmd", cfg.Proxy.IptablesCmd, ) log.Info("[config] resources.envoyProxy", "requests", cfg.Resources.EnvoyProxy.Requests, diff --git a/kagenti-operator/internal/webhook/config/types.go b/kagenti-operator/internal/webhook/config/types.go index 56a50432..25f987ad 100644 --- a/kagenti-operator/internal/webhook/config/types.go +++ b/kagenti-operator/internal/webhook/config/types.go @@ -51,6 +51,14 @@ type ProxyConfig struct { // external TCP egress to. It MUST match the authbridge proxy-sidecar // listener.transparent_proxy_addr (default :8082). TransparentPort int32 `json:"transparentPort" yaml:"transparentPort"` + + // IptablesCmd optionally pins the iptables backend the proxy-init script + // uses, injected as the IPTABLES_CMD env var (omitted when empty). Empty + // (default) lets the script auto-detect from /proc/modules (iptable_nat + // loaded => legacy, as on Kind/kubeadm; absent => nft, as on OpenShift/ + // ROSA). Set to "iptables" (nft) or "iptables-legacy" to force a backend + // where auto-detection is wrong or undesired. + IptablesCmd string `json:"iptablesCmd" yaml:"iptablesCmd"` } type ResourcesConfig struct { diff --git a/kagenti-operator/internal/webhook/injector/container_builder.go b/kagenti-operator/internal/webhook/injector/container_builder.go index ba8966a4..c42bfb01 100644 --- a/kagenti-operator/internal/webhook/injector/container_builder.go +++ b/kagenti-operator/internal/webhook/injector/container_builder.go @@ -534,6 +534,14 @@ func (b *ContainerBuilder) BuildProxyInitContainer(mode ProxyInitMode, outboundP return corev1.Container{} } + // Optional explicit iptables backend override (applies to both modes). Empty + // by default: the init script auto-detects from /proc/modules (iptable_nat + // loaded => legacy, else nft). Set b.cfg.Proxy.IptablesCmd (e.g. "iptables" + // on nft-only platforms) to force a backend. + if b.cfg.Proxy.IptablesCmd != "" { + env = append(env, corev1.EnvVar{Name: "IPTABLES_CMD", Value: b.cfg.Proxy.IptablesCmd}) + } + return corev1.Container{ Name: ProxyInitContainerName, Image: b.cfg.Images.ProxyInit, diff --git a/kagenti-operator/internal/webhook/injector/container_builder_test.go b/kagenti-operator/internal/webhook/injector/container_builder_test.go index cc4d937a..84a0eec1 100644 --- a/kagenti-operator/internal/webhook/injector/container_builder_test.go +++ b/kagenti-operator/internal/webhook/injector/container_builder_test.go @@ -336,6 +336,45 @@ func TestBuildProxyInitContainer_EnforceRedirect(t *testing.T) { if _, ok := got["OUTBOUND_PORTS_EXCLUDE"]; ok { t.Error("enforce-redirect must not set OUTBOUND_PORTS_EXCLUDE") } + if _, ok := got["IPTABLES_CMD"]; ok { + t.Error("enforce-redirect must not set IPTABLES_CMD by default (proxy-init auto-detects from /proc/modules)") + } +} + +// IPTABLES_CMD is injected only when Proxy.IptablesCmd is configured, in both +// modes, so the init script's /proc/modules auto-detection stays the default +// and the backend override is strictly opt-in. +func TestBuildProxyInitContainer_IptablesCmd(t *testing.T) { + modes := []ProxyInitMode{ProxyInitModeRedirect, ProxyInitModeEnforceRedirect} + + // Default (empty): env var absent in both modes. + def := NewContainerBuilder(config.CompiledDefaults()) + for _, mode := range modes { + for _, e := range def.BuildProxyInitContainer(mode, "", "").Env { + if e.Name == "IPTABLES_CMD" { + t.Errorf("mode %q: IPTABLES_CMD must be absent when unset, got %q", mode, e.Value) + } + } + } + + // Configured: env var present with the configured value in both modes. + cfg := config.CompiledDefaults() + cfg.Proxy.IptablesCmd = "iptables" + b := NewContainerBuilder(cfg) + for _, mode := range modes { + found := false + for _, e := range b.BuildProxyInitContainer(mode, "", "").Env { + if e.Name == "IPTABLES_CMD" { + found = true + if e.Value != "iptables" { + t.Errorf("mode %q: IPTABLES_CMD = %q, want %q", mode, e.Value, "iptables") + } + } + } + if !found { + t.Errorf("mode %q: IPTABLES_CMD env not set when configured", mode) + } + } } // An unknown mode must fail closed: BuildProxyInitContainer returns a From 8196708d8c07f57bc14b4fd7b62fd0240c381cbf Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 16 Jun 2026 08:19:03 -0400 Subject: [PATCH 2/2] Fix: Validate proxy.iptablesCmd at config load (review #432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reject an unrecognized proxy.iptablesCmd in PlatformConfig.Validate() so a chart typo fails fast at operator startup (and is retained-safe on hot reload) rather than surfacing as a per-injected-pod proxy-init crash. Allowed set is the binaries shipped in the proxy-init image plus "" (auto-detect): iptables, iptables-nft, iptables-legacy. Note iptables-nft is included — it is a documented IPTABLES_CMD override value in kagenti-extensions, so the stricter {iptables, iptables-legacy} set suggested in review would reject a valid value. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .../internal/webhook/config/types.go | 9 ++++++ .../internal/webhook/config/types_test.go | 31 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/kagenti-operator/internal/webhook/config/types.go b/kagenti-operator/internal/webhook/config/types.go index 25f987ad..7f2df7fe 100644 --- a/kagenti-operator/internal/webhook/config/types.go +++ b/kagenti-operator/internal/webhook/config/types.go @@ -139,6 +139,15 @@ func (c *PlatformConfig) Validate() error { if c.Proxy.UID < 1 { return fmt.Errorf("proxy.uid must be >= 1 (got %d): the proxy must not run as root and the egress-enforcement exemption keys on this UID", c.Proxy.UID) } + // IptablesCmd, when set, pins the proxy-init iptables backend (IPTABLES_CMD). + // Restrict overrides to the binaries shipped in the proxy-init image so a + // chart typo fails fast at operator startup rather than as a per-injected-pod + // init crash. Empty is the default — proxy-init auto-detects from /proc/modules. + switch c.Proxy.IptablesCmd { + case "", "iptables", "iptables-nft", "iptables-legacy": + default: + return fmt.Errorf("proxy.iptablesCmd %q is not a recognized backend (want one of: \"\" (auto-detect), iptables, iptables-nft, iptables-legacy)", c.Proxy.IptablesCmd) + } if c.Images.EnvoyProxy == "" { return fmt.Errorf("images.envoyProxy is required") } diff --git a/kagenti-operator/internal/webhook/config/types_test.go b/kagenti-operator/internal/webhook/config/types_test.go index 542b64d8..2bb86431 100644 --- a/kagenti-operator/internal/webhook/config/types_test.go +++ b/kagenti-operator/internal/webhook/config/types_test.go @@ -29,3 +29,34 @@ func TestValidate_TransparentPort(t *testing.T) { }) } } + +// IptablesCmd pins the proxy-init backend; only the binaries shipped in the +// image (plus "" = auto-detect) are accepted, so a chart typo fails at operator +// startup rather than as a per-injected-pod init crash. +func TestValidate_IptablesCmd(t *testing.T) { + tests := []struct { + name string + cmd string + wantErr bool + }{ + {"empty (auto-detect) ok", "", false}, + {"iptables (nft) ok", "iptables", false}, + {"iptables-nft ok", "iptables-nft", false}, + {"iptables-legacy ok", "iptables-legacy", false}, + {"typo rejected", "iptable", true}, + {"arbitrary rejected", "nft", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := CompiledDefaults() + c.Proxy.IptablesCmd = tt.cmd + err := c.Validate() + if tt.wantErr && err == nil { + t.Errorf("expected validation error, got nil") + } + if !tt.wantErr && err != nil { + t.Errorf("unexpected validation error: %v", err) + } + }) + } +}