-
Notifications
You must be signed in to change notification settings - Fork 40
feat: Transparent inbound interception for proxy-sidecar (SO_ORIGINAL_DST) #776
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
69fe285
b3a9f81
552ef02
5c9a2bb
aee1a05
2003008
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| package config | ||
|
|
||
| import "testing" | ||
|
|
||
| // TestApplyPreset_TransparentInbound covers the mutual exclusion between the | ||
| // two inbound mechanisms. Filling reverse_proxy_addr alongside transparent | ||
| // interception would bind a port nothing routes to — and if it collided with | ||
| // the agent's own port (which transparent mode deliberately leaves in place), | ||
| // the pod would fail to start. | ||
| func TestApplyPreset_TransparentInbound(t *testing.T) { | ||
| cfg := &Config{Mode: ModeProxySidecar, Listener: ListenerConfig{ | ||
| InboundInterception: InboundInterceptionTransparent, | ||
| }} | ||
| ApplyPreset(cfg) | ||
|
|
||
| if cfg.Listener.TransparentInboundAddr != ":8083" { | ||
| t.Errorf("transparent_inbound_addr = %q, want :8083", cfg.Listener.TransparentInboundAddr) | ||
| } | ||
| if cfg.Listener.ReverseProxyAddr != "" { | ||
| t.Errorf("transparent inbound must not fill reverse_proxy_addr, got %q", cfg.Listener.ReverseProxyAddr) | ||
| } | ||
| // The forward role is still active by default, so egress is untouched. | ||
| if cfg.Listener.ForwardProxyAddr != ":8081" || cfg.Listener.TransparentProxyAddr != ":8082" { | ||
| t.Errorf("egress defaults changed: forward=%q transparent-out=%q", | ||
| cfg.Listener.ForwardProxyAddr, cfg.Listener.TransparentProxyAddr) | ||
| } | ||
| } | ||
|
|
||
| // TestApplyPreset_DefaultIsReverseProxy locks the opt-in contract: an unset | ||
| // inbound_interception must leave the preset byte-identical to today. | ||
| func TestApplyPreset_DefaultIsReverseProxy(t *testing.T) { | ||
| cfg := &Config{Mode: ModeProxySidecar} | ||
| ApplyPreset(cfg) | ||
|
|
||
| if cfg.Listener.ReverseProxyAddr != ":8080" { | ||
| t.Errorf("reverse_proxy_addr = %q, want :8080 (default mechanism unchanged)", cfg.Listener.ReverseProxyAddr) | ||
| } | ||
| if cfg.Listener.TransparentInboundAddr != "" { | ||
| t.Errorf("default must not fill transparent_inbound_addr, got %q", cfg.Listener.TransparentInboundAddr) | ||
| } | ||
| } | ||
|
|
||
| // TestApplyPreset_TransparentInboundUserOverride ensures an operator-chosen port | ||
| // survives the preset — it has to match proxy-init's INBOUND_TRANSPARENT_PORT, | ||
| // so silently overwriting it would break ingress. | ||
| func TestApplyPreset_TransparentInboundUserOverride(t *testing.T) { | ||
| cfg := &Config{Mode: ModeProxySidecar, Listener: ListenerConfig{ | ||
| InboundInterception: InboundInterceptionTransparent, | ||
| TransparentInboundAddr: ":19083", | ||
| }} | ||
| ApplyPreset(cfg) | ||
| if cfg.Listener.TransparentInboundAddr != ":19083" { | ||
| t.Errorf("transparent_inbound_addr = %q, want the operator's :19083", cfg.Listener.TransparentInboundAddr) | ||
| } | ||
| } | ||
|
|
||
| func TestInboundTransparent(t *testing.T) { | ||
| for _, tc := range []struct { | ||
| value string | ||
| want bool | ||
| }{ | ||
| {"", false}, | ||
| {InboundInterceptionReverseProxy, false}, | ||
| {InboundInterceptionTransparent, true}, | ||
| } { | ||
| if got := (ListenerConfig{InboundInterception: tc.value}).InboundTransparent(); got != tc.want { | ||
| t.Errorf("InboundTransparent(%q) = %v, want %v", tc.value, got, tc.want) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func TestValidate_InboundInterception(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| cfg *Config | ||
| wantErr bool | ||
| }{ | ||
| { | ||
| name: "transparent needs no reverse_proxy_backend", | ||
| cfg: &Config{Mode: ModeProxySidecar, Listener: ListenerConfig{ | ||
| InboundInterception: InboundInterceptionTransparent, | ||
| }}, | ||
| }, | ||
| { | ||
| name: "reverse-proxy still requires a backend", | ||
| cfg: &Config{Mode: ModeProxySidecar, Listener: ListenerConfig{ | ||
| InboundInterception: InboundInterceptionReverseProxy, | ||
| }}, | ||
| wantErr: true, | ||
| }, | ||
| { | ||
| name: "default still requires a backend", | ||
| cfg: &Config{Mode: ModeProxySidecar}, | ||
| // unchanged behavior for existing configs | ||
| wantErr: true, | ||
| }, | ||
| { | ||
| name: "unknown value is rejected at startup", | ||
| cfg: &Config{Mode: ModeProxySidecar, Listener: ListenerConfig{ | ||
| InboundInterception: "transparant", // typo an operator would make | ||
| ReverseProxyBackend: "http://127.0.0.1:8000", | ||
| }}, | ||
| wantErr: true, | ||
| }, | ||
| { | ||
| name: "transparent without the reverse role is a no-op, so rejected", | ||
| cfg: &Config{Mode: ModeProxySidecar, Listener: ListenerConfig{ | ||
| Roles: []string{RoleForward}, | ||
| InboundInterception: InboundInterceptionTransparent, | ||
| }}, | ||
| wantErr: true, | ||
| }, | ||
| { | ||
| name: "envoy-sidecar rejects the field (Envoy already intercepts inbound)", | ||
| cfg: &Config{Mode: ModeEnvoySidecar, Listener: ListenerConfig{ | ||
| InboundInterception: InboundInterceptionTransparent, | ||
| }}, | ||
| wantErr: true, | ||
| }, | ||
| { | ||
| name: "waypoint rejects the field", | ||
| cfg: &Config{Mode: ModeWaypoint, Listener: ListenerConfig{ | ||
| InboundInterception: InboundInterceptionTransparent, | ||
| }}, | ||
| wantErr: true, | ||
| }, | ||
| } | ||
|
|
||
| for _, tc := range tests { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| err := Validate(tc.cfg) | ||
| 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) | ||
| } | ||
| }) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,13 +32,19 @@ func validateListeners(cfg *Config) error { | |
| if cfg.Listener.ReverseProxyAddr != "" { | ||
| return fmt.Errorf("envoy-sidecar mode does not support reverse_proxy_addr (use proxy-sidecar mode)") | ||
| } | ||
| if cfg.Listener.InboundInterception != "" { | ||
| return fmt.Errorf("envoy-sidecar mode does not support inbound_interception (Envoy already intercepts inbound transparently)") | ||
| } | ||
| if cfg.Listener.ExtAuthzAddr != "" { | ||
| return fmt.Errorf("envoy-sidecar mode does not support ext_authz_addr (use waypoint mode)") | ||
| } | ||
| case ModeWaypoint: | ||
| if cfg.Listener.ExtProcAddr != "" { | ||
| return fmt.Errorf("waypoint mode does not support ext_proc_addr (use envoy-sidecar mode)") | ||
| } | ||
| if cfg.Listener.InboundInterception != "" { | ||
| return fmt.Errorf("waypoint mode does not support inbound_interception (the waypoint owns inbound)") | ||
| } | ||
| if cfg.Listener.ReverseProxyAddr != "" { | ||
| return fmt.Errorf("waypoint mode does not support reverse_proxy_addr") | ||
| } | ||
|
|
@@ -54,11 +60,22 @@ func validateListeners(cfg *Config) error { | |
| return fmt.Errorf("listener.roles: %q is not a valid role (use %q and/or %q)", r, RoleReverse, RoleForward) | ||
| } | ||
| } | ||
| switch cfg.Listener.InboundInterception { | ||
| case "", InboundInterceptionReverseProxy, InboundInterceptionTransparent: | ||
| // valid | ||
| default: | ||
| return fmt.Errorf("listener.inbound_interception: %q is not valid (use %q or %q)", | ||
| cfg.Listener.InboundInterception, InboundInterceptionReverseProxy, InboundInterceptionTransparent) | ||
| } | ||
| roles := cfg.Listener.ActiveRoles() | ||
| if cfg.Listener.InboundTransparent() && !roles[RoleReverse] { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: On mismatch, PREROUTING REDIRECTs to a port nothing is bound to, so every inbound connection is refused. The failure is at the iptables/process boundary, so neither side can see the other — and both defaulting to 8083 means it only bites an operator who overrides one of them. You have already done the reasonable mitigations: the requirement is called out in three code comments and in the proxy-init README ("A mismatch redirects traffic to a dead port"), and the If it is worth closing further, the cheapest option is a startup log line recording the bound transparent-inbound address, so the value is greppable in pod logs when someone debugs connection-refused. A stronger option, if the operator sets both, is having it write the port to one place both consume so they cannot drift. |
||
| return fmt.Errorf("listener.inbound_interception: transparent requires the %q role (it selects how inbound reaches the inbound pipeline)", RoleReverse) | ||
| } | ||
| // The reverse proxy forwards inbound traffic to reverse_proxy_backend, | ||
| // so it's required only when the reverse role is active. A forward-only | ||
| // deployment needs no backend. | ||
| if roles[RoleReverse] && cfg.Listener.ReverseProxyBackend == "" { | ||
| // deployment needs no backend, and transparent interception derives the | ||
| // backend per connection from SO_ORIGINAL_DST rather than from config. | ||
| if roles[RoleReverse] && !cfg.Listener.InboundTransparent() && cfg.Listener.ReverseProxyBackend == "" { | ||
| return fmt.Errorf("proxy-sidecar mode with the reverse role requires listener.reverse_proxy_backend") | ||
| } | ||
| // The TLS bridge only rewrites outbound (forward-proxy) traffic; enabling | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.