Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions charts/operator/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,11 @@ defaults:
# always-on enforce-redirect egress guard (proxy-sidecar / lite). MUST match
# the authbridge listener.transparent_proxy_addr (default :8082).
transparentPort: 8082
# INBOUND transparent listener port — the PREROUTING REDIRECT target when a
# workload selects inboundInterception: transparent. MUST match the authbridge
# listener.transparent_inbound_addr (preset default :8083), and must differ
# from transparentPort (they are separate listeners in the same container).
transparentInboundPort: 8083
# 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.
Expand All @@ -280,6 +285,16 @@ defaults:
allowedEgressEnforcement:
- enforce-redirect
- none
# Which inbound interception mechanisms workloads in this cluster may select.
# A resolved value outside this list falls back to the FIRST entry, so order
# matters. Transparent inbound needs a privileged proxy-init container
# (NET_ADMIN), so an admin may want to forbid or mandate it:
# ["reverse-proxy"] — port stealing only, no NET_ADMIN
# ["transparent"] — hard inbound boundary required
# ["reverse-proxy", "transparent"] — workloads choose (default)
allowedInboundInterception:
- reverse-proxy
- transparent

# Resource defaults (conservative for dev)
# Note: requests must be <= limits
Expand Down
65 changes: 64 additions & 1 deletion operator/api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion operator/internal/webhook/config/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,20 @@ func CompiledDefaults() *PlatformConfig {
// Transparent listener port — must match the authbridge proxy-sidecar
// preset (listener.transparent_proxy_addr default :8082).
TransparentPort: 8082,
// Inbound transparent listener. Matches the authbridge proxy-sidecar
// preset (listener.transparent_inbound_addr default :8083); 8080/8081/8082
// are the reverse, forward and transparent-egress listeners.
TransparentInboundPort: 8083,
// Empty by default: proxy-init auto-detects the iptables backend from
// /proc/modules. Set (e.g. "iptables") to force a backend per-platform.
IptablesCmd: "",
// Both modes allowed by default. Set to ["none"] on platforms
// where iptables is unavailable (ROSA HCP, managed OpenShift),
// or ["enforce-redirect"] to prevent opt-out.
AllowedEgressEnforcement: []string{"enforce-redirect", "none"},
// Both allowed by default: transparent inbound is opt-in per workload,
// and a platform admin can narrow this to forbid or mandate it.
AllowedInboundInterception: []string{"reverse-proxy", "transparent"},
AllowedEgressEnforcement: []string{"enforce-redirect", "none"},
},
Resources: ResourcesConfig{
EnvoyProxy: corev1.ResourceRequirements{
Expand Down
107 changes: 107 additions & 0 deletions operator/internal/webhook/config/transparent_inbound_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package config

import "testing"

// TestValidate_TransparentInboundPort covers the misconfigurations that would
// otherwise surface as a pod that passes admission and then fails to start.
func TestValidate_TransparentInboundPort(t *testing.T) {
tests := []struct {
name string
mutate func(*PlatformConfig)
wantErr bool
}{
{
name: "defaults are valid",
mutate: func(*PlatformConfig) {},
},
{
name: "below the privileged range",
mutate: func(c *PlatformConfig) { c.Proxy.TransparentInboundPort = 80 },
wantErr: true,
},
{
name: "above the port range",
mutate: func(c *PlatformConfig) { c.Proxy.TransparentInboundPort = 70000 },
wantErr: true,
},
{
name: "unset (zero) is rejected rather than silently defaulted",
mutate: func(c *PlatformConfig) { c.Proxy.TransparentInboundPort = 0 },
wantErr: true,
},
{
// Both listeners live in one container, so a shared value makes the
// second bind fail at pod start — long after admission succeeded.
name: "colliding with the egress transparent port",
mutate: func(c *PlatformConfig) {
c.Proxy.TransparentInboundPort = c.Proxy.TransparentPort
},
wantErr: true,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cfg := CompiledDefaults()
tc.mutate(cfg)
err := cfg.Validate()
if tc.wantErr && err == nil {
t.Fatal("expected a validation error, got nil")
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected validation error: %v", err)
}
})
}
}

func TestValidate_AllowedInboundInterception(t *testing.T) {
tests := []struct {
name string
allowed []string
wantErr bool
}{
{name: "both", allowed: []string{"reverse-proxy", "transparent"}},
{name: "forbid transparent", allowed: []string{"reverse-proxy"}},
{name: "mandate transparent", allowed: []string{"transparent"}},
{
// An empty list would make the fallback index panic, and "allow
// nothing" has no sensible meaning.
name: "empty is rejected", allowed: []string{}, wantErr: true,
},
{name: "unknown value is rejected", allowed: []string{"reverse-proxy", "tranparent"}, wantErr: true},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cfg := CompiledDefaults()
cfg.Proxy.AllowedInboundInterception = tc.allowed
err := cfg.Validate()
if tc.wantErr && err == nil {
t.Fatal("expected a validation error, got nil")
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected validation error: %v", err)
}
})
}
}

// TestCompiledDefaults_TransparentInboundDefaults pins the values that must stay
// in lockstep with authbridge's proxy-sidecar preset; a drift here silently
// redirects inbound traffic to a dead port.
func TestCompiledDefaults_TransparentInboundDefaults(t *testing.T) {
cfg := CompiledDefaults()
if cfg.Proxy.TransparentInboundPort != 8083 {
t.Errorf("TransparentInboundPort = %d, want 8083 (authbridge preset transparent_inbound_addr)", cfg.Proxy.TransparentInboundPort)
}
if len(cfg.Proxy.AllowedInboundInterception) != 2 {
t.Errorf("AllowedInboundInterception = %v, want both mechanisms allowed by default", cfg.Proxy.AllowedInboundInterception)
}
// Order matters: the first entry is the fallback when a workload requests a
// value outside the list, and the no-privilege shape must win.
if cfg.Proxy.AllowedInboundInterception[0] != "reverse-proxy" {
t.Errorf("AllowedInboundInterception[0] = %q, want reverse-proxy (the fallback must not grant NET_ADMIN)",
cfg.Proxy.AllowedInboundInterception[0])
}
}
35 changes: 35 additions & 0 deletions operator/internal/webhook/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,20 @@ type ProxyConfig struct {
// where auto-detection is wrong or undesired.
IptablesCmd string `json:"iptablesCmd" yaml:"iptablesCmd"`

// TransparentInboundPort is the INBOUND transparent listener port — the
// PREROUTING REDIRECT target when inboundInterception is "transparent".
// MUST match the authbridge listener.transparent_inbound_addr (preset
// default :8083); a mismatch redirects inbound traffic to a dead port.
TransparentInboundPort int32 `json:"transparentInboundPort" yaml:"transparentInboundPort"`

// AllowedInboundInterception restricts which inboundInterception values
// workloads in this cluster may select, mirroring
// AllowedEgressEnforcement. Transparent inbound requires a privileged
// proxy-init container, so a platform admin may want to forbid it
// (["reverse-proxy"]) or mandate it (["transparent"]). A resolved value
// outside the list falls back to the list's first entry.
AllowedInboundInterception []string `json:"allowedInboundInterception,omitempty" yaml:"allowedInboundInterception,omitempty"`

// AllowedEgressEnforcement restricts which egressEnforcement values
// workloads (AgentRuntime CR / namespace ConfigMap) may select.
// The webhook rejects resolved values not in this list, falling back
Expand Down Expand Up @@ -108,6 +122,10 @@ func (c *PlatformConfig) DeepCopy() *PlatformConfig {
copy(result.TokenExchange.DefaultScopes, c.TokenExchange.DefaultScopes)
}

if c.Proxy.AllowedInboundInterception != nil {
result.Proxy.AllowedInboundInterception = make([]string, len(c.Proxy.AllowedInboundInterception))
copy(result.Proxy.AllowedInboundInterception, c.Proxy.AllowedInboundInterception)
}
if c.Proxy.AllowedEgressEnforcement != nil {
result.Proxy.AllowedEgressEnforcement = make([]string, len(c.Proxy.AllowedEgressEnforcement))
copy(result.Proxy.AllowedEgressEnforcement, c.Proxy.AllowedEgressEnforcement)
Expand Down Expand Up @@ -152,6 +170,15 @@ func (c *PlatformConfig) Validate() error {
if c.Proxy.TransparentPort < 1024 || c.Proxy.TransparentPort > 65535 {
return fmt.Errorf("proxy.transparentPort must be between 1024 and 65535")
}
if c.Proxy.TransparentInboundPort < 1024 || c.Proxy.TransparentInboundPort > 65535 {
return fmt.Errorf("proxy.transparentInboundPort must be between 1024 and 65535")
}
// The two transparent listeners are separate sockets in one container; a
// shared value would make the second bind fail at pod start, after admission
// has already succeeded. Catch it at operator startup instead.
if c.Proxy.TransparentInboundPort == c.Proxy.TransparentPort {
return fmt.Errorf("proxy.transparentInboundPort (%d) must differ from proxy.transparentPort — they are distinct listeners in the same container", c.Proxy.TransparentInboundPort)
}
// The enforce-redirect guard exempts this UID (--uid-owner) and the proxy
// container runs as it; it must be a real non-root user.
if c.Proxy.UID < 1 {
Expand All @@ -166,6 +193,14 @@ func (c *PlatformConfig) Validate() error {
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 len(c.Proxy.AllowedInboundInterception) == 0 {
return fmt.Errorf("proxy.allowedInboundInterception must not be empty (set [\"reverse-proxy\"] to forbid transparent inbound, [\"transparent\"] to require it, or both to allow workload choice)")
}
for _, mode := range c.Proxy.AllowedInboundInterception {
if mode != "reverse-proxy" && mode != "transparent" {
return fmt.Errorf("proxy.allowedInboundInterception contains invalid value %q (allowed: reverse-proxy, transparent)", mode)
}
}
if len(c.Proxy.AllowedEgressEnforcement) == 0 {
return fmt.Errorf("proxy.allowedEgressEnforcement must not be empty (set [\"enforce-redirect\"] to require enforcement, [\"none\"] to disable it, or both to allow workload choice)")
}
Expand Down
32 changes: 32 additions & 0 deletions operator/internal/webhook/injector/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,38 @@ const (
EgressEnforcementNone = "none"
)

// AuthBridge's fixed (non-negotiable) listener ports in proxy-sidecar / lite.
// Unlike the reverse/forward/transparent ports these are not configurable, so
// the operator must exempt them from the transparent inbound REDIRECT by number.
// Gating AuthBridgeHealthPort in particular would put kubelet probes behind JWT
// validation and crash-loop the pod.
const (
authBridgeHealthPort = 9091 // /healthz
authBridgeStatsPort = 9093 // stats + config inspection + /reload/status
authBridgeSessionAPIPort = 9094 // session events API (consumed by abctl)
)

// Inbound interception mechanisms for the proxy-sidecar / lite paths. These
// select HOW inbound traffic reaches AuthBridge's inbound pipeline — they are
// not two levels of the same knob, but two different deployment shapes.
const (
// InboundInterceptionReverseProxy is the default: port stealing. AuthBridge
// binds the agent's original port and the agent is relocated to a free one
// via the PORT env var, so the Service needs no patching. Requires no
// privileges, but leaves the relocated port reachable directly (a pod-to-pod
// bypass of JWT validation), only covers the first declared container port,
// and silently fails for agents that hardcode their listen port.
InboundInterceptionReverseProxy = "reverse-proxy"

// InboundInterceptionTransparent installs a PREROUTING REDIRECT via
// proxy-init and lets AuthBridge recover each connection's real destination
// via SO_ORIGINAL_DST. The agent keeps its own port — no relocation, no PORT
// env var, no second port to discover — and every port it listens on is
// covered. Costs a privileged proxy-init container (NET_ADMIN) and is
// Linux-only, so it is opt-in.
InboundInterceptionTransparent = "transparent"
)

// mTLS modes for the proxy-sidecar / lite paths. Selected via the
// namespace `authbridge-runtime-config` ConfigMap's `mtls.mode` field,
// then MTLSModeDisabled. envoy-sidecar mode is incompatible with mTLS
Expand Down
Loading
Loading