Skip to content

feat: Add inboundInterception=transparent (drops port stealing) - #511

Draft
huang195 wants to merge 2 commits into
rossoctl:mainfrom
huang195:feat/transparent-inbound
Draft

feat: Add inboundInterception=transparent (drops port stealing)#511
huang195 wants to merge 2 commits into
rossoctl:mainfrom
huang195:feat/transparent-inbound

Conversation

@huang195

Copy link
Copy Markdown
Member

Summary

Adds an opt-in inbound shape for proxy-sidecar / lite that stops stealing the agent's port. Companion to rossoctl/cortex#776, which implements the listener and iptables rules; this is the switch that turns them on.

With inboundInterception: transparent, proxy-init installs a PREROUTING REDIRECT and AuthBridge recovers each connection's real destination via SO_ORIGINAL_DST, so the agent keeps the port it already binds.

Why

Port stealing has three problems, all of which the existing code acknowledges:

  1. The relocated port is an unvalidated entrance. originalPort+1 is declared in the pod spec and directly reachable, so any pod can reach the agent without JWT validation. Pods are the granularity NetworkPolicy and ztunnel enforce at, so this was the boundary that mattered.
  2. It depends on the agent honoring PORT. pod_mutator.go concedes that agents which hardcode their listen port "won't be affected" — in practice they collide with AuthBridge on the stolen port and the pod never starts. No config can fix that.
  3. Only Ports[0] of the first container with ports is relocated, so a second declared port was never proxied. Undeclared ports aren't even in usedPorts, so findFreePort can hand the agent a port it already uses.

Transparent interception removes all three: no relocation, no PORT env var, no second port to discover, and every port the agent listens on is covered.

Default is unchanged

Default stays reverse-proxy. Transparent costs a privileged proxy-init container, so it is chosen deliberately; with the field unset every existing path is byte-identical. Two fallbacks are deliberately biased toward the unprivileged shape:

  • An unrecognized value falls back to reverse-proxy, so a typo cannot silently grant a NET_ADMIN init container.
  • transparent + egressEnforcement: none falls back to reverse-proxy. The two features share one proxy-init container; without it nothing would REDIRECT to the inbound listener and inbound would be silently unenforced — worse than port stealing, which at least validates Service-routed traffic.

proxy.allowedInboundInterception lets a platform admin forbid it (["reverse-proxy"]) or mandate it (["transparent"]).

Deliberately NOT adding an AgentRuntime spec field

The obvious move was to mirror spec.egressEnforcement — but that field is dead surface. The pod mutator has no access to the CR and resolves everything from the namespace ConfigMap, so nothing reads spec.egressEnforcement (spec.mtlsMode reaches the pod only as a rollout-triggering annotation, not as mutator input). An enum-validated spec field that silently does nothing is worse than no field, and plumbing CR → mutator properly is a separate change that would have to fix mtlsMode and egressEnforcement too.

Worth deciding separately: spec.egressEnforcement should probably be either wired up or removed.

Details

  • The per-agent ConfigMap gets inbound_interception + transparent_inbound_addr and must not get reverse_proxy_addr/reverse_proxy_backend — authbridge's config validation rejects that combination, so emitting both would crash-loop the pod.
  • SIDECAR_PORTS_EXCLUDE carries the resolved forward-proxy port, not the script's 8081 default: findFreePort may have moved it, and an unexempted forward-proxy port would be swallowed by the inbound REDIRECT. Gating 9091 would put kubelet probes behind JWT validation.
  • The sidecar declares its inbound port as transparent-in, so which shape is running is visible in the pod spec.
  • Config validation rejects transparentInboundPort == transparentPort: both are listeners in one container, so a shared value fails the second bind at pod start, long after admission succeeded.

Upgrade-safe: the config loader overlays YAML onto compiled defaults, so an existing ConfigMap without the new keys keeps 8083 / both-allowed. Verified by running this operator against an unmodified 22-day-old cluster ConfigMap.

Verification

  • make test — 21 packages green, including 10 new injector tests and 8 new config-validation cases.
  • No CRD changes (none needed); make manifests generate produces no drift.
  • Verified live on Kind: agent keeps :8000 with no PORT override, sidecar declares transparent-in=8083 + forward-proxy=8081, proxy-init receives INBOUND_TRANSPARENT_PORT=8083 / POD_IP (downward API) / SIDECAR_PORTS_EXCLUDE=8081,9091,9093,9094, and the per-agent ConfigMap contains inbound_interception: transparent + transparent_inbound_addr: :8083 with no reverse_proxy_* keys.

Draft: outstanding gate

Live traffic assertions (pod-to-pod → 401) are not yet run — the cluster used lacks the Keycloak CRD, so per-agent credential Secrets are never created and any new agent stays Pending on FailedMount independent of this change. Gated by the e2e suite in the rossoctl companion PR.

Refs rossoctl/cortex#330

Assisted-By: Claude Code

…ing)

Adds an opt-in inbound shape for proxy-sidecar / lite that stops stealing the
agent's port. When inboundInterception is "transparent", proxy-init installs a
PREROUTING REDIRECT and AuthBridge recovers each connection's real destination
via SO_ORIGINAL_DST, so the agent keeps the port it already binds.

That removes the three problems with port stealing, all of which the existing
code acknowledges:

  - The relocated port (originalPort+1) is declared in the pod spec and directly
    reachable, so any pod could reach the agent without JWT validation. The pod
    is the granularity Kubernetes NetworkPolicy and ztunnel both enforce at, so
    this was the boundary that mattered and it was open.
  - Relocation depends on the agent honoring PORT. pod_mutator.go concedes that
    agents which hardcode their listen port "won't be affected" — in practice
    they collide with AuthBridge on the stolen port and the pod never starts.
  - Only Ports[0] of the first container with ports was relocated, so a
    second declared port was never proxied.

Default is "reverse-proxy" (port stealing). Transparent costs a privileged
proxy-init container, so it must be chosen deliberately; with the field unset
every existing path is byte-identical.

The switch is the namespace authbridge-runtime-config ConfigMap
(inboundInterception), resolved namespace > cluster default, with a
proxy.allowedInboundInterception allowlist so a platform admin can forbid it (no
NET_ADMIN) or mandate it. Two fallbacks are deliberately biased toward the
unprivileged shape:

  - An unrecognized value falls back to reverse-proxy, so a typo cannot silently
    grant a NET_ADMIN init container.
  - transparent + egressEnforcement=none falls back to reverse-proxy. The two
    features share one proxy-init container; without it nothing would REDIRECT
    to the inbound listener and inbound would be silently unenforced — worse
    than port stealing, which at least validates Service-routed traffic.

Deliberately NOT adding an AgentRuntime spec field. The obvious move was to
mirror spec.egressEnforcement, but that field is dead surface: the pod mutator
has no access to the CR and resolves everything from the namespace ConfigMap, so
nothing reads spec.egressEnforcement (spec.mtlsMode reaches the pod only as a
rollout-triggering annotation, not as mutator input). An enum-validated spec
field that silently does nothing is worse than no field, and plumbing CR ->
mutator properly is a separate change that would have to fix mtlsMode and
egressEnforcement too.

Wiring details:

  - The per-agent ConfigMap gets inbound_interception + transparent_inbound_addr
    and must NOT get reverse_proxy_addr / reverse_proxy_backend: authbridge's
    config validation rejects that combination, so emitting both would
    crash-loop the pod.
  - proxy-init gets INBOUND_TRANSPARENT_PORT and POD_IP (downward API). POD_IP is
    not optional — the init script uses it as the DNAT target for the Istio
    ambient inbound path, which arrives through OUTPUT rather than PREROUTING,
    and refuses to start without it rather than install PREROUTING-only rules
    that wave all mesh traffic through.
  - SIDECAR_PORTS_EXCLUDE carries the RESOLVED forward-proxy port, not the
    script's 8081 default: findFreePort may have moved it, and an unexempted
    forward-proxy port would be swallowed by the inbound REDIRECT.
  - The sidecar declares its inbound port as "transparent-in" rather than
    "reverse-proxy", so which shape is running is visible in the pod spec.
  - Config validation rejects transparentInboundPort == transparentPort. Both are
    listeners in one container, so a shared value fails the second bind at pod
    start, long after admission succeeded.

Drive-by lint cleanups in touched code: extracted the duplicated
resolution-source strings ("cluster-default", "default-invalid-fallback") into
constants alongside the existing sourceNamespaceConfigMap, and dropped an unused
parameter from the new private sidecar builder.

make test: 21 packages green. No CRD changes (none needed).

Refs: rossoctl/cortex#330

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
proxy-init's ambient DNAT target must match the address family of the traffic.
POD_IP is the pod's PRIMARY address (usually v4), so on a dual-stack pod the
other family's HBONE delivery passed unvalidated while that family's PREROUTING
rules were installed — half-enforcement, which the POD_IP guard refuses to ship
in the equivalent case.

Adds POD_IPS from the Downward API (status.podIPs) alongside POD_IP. proxy-init
falls back to POD_IP when absent, so an older init image still works for its own
family.

Refs: rossoctl/cortex#330

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
@huang195

Copy link
Copy Markdown
Member Author

Adds POD_IPS (status.podIPs) alongside POD_IP.

Review found that proxy-init's ambient DNAT target was keyed off POD_IP — the pod's primary address — so on a dual-stack pod the other family's HBONE delivery passed unvalidated while that family's PREROUTING rules were installed. proxy-init now selects a target per family; details and the companion fix in rossoctl/cortex#776.

make test still 21 packages green, with a new assertion that POD_IPS comes from the Downward API.

Assisted-By: Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant