Skip to content

Commit ad0787e

Browse files
authored
Merge pull request #498 from huang195/fix/in-cluster-egress-capture
Fix: Capture in-cluster TCP egress in enforce-redirect (keep DNS direct)
2 parents f1c5e76 + 238f48e commit ad0787e

5 files changed

Lines changed: 83 additions & 45 deletions

File tree

CLAUDE.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ kagenti-extensions/
4444
│ │ ├── main.go
4545
│ │ ├── Dockerfile # proxy-sidecar lite combined image
4646
│ │ └── entrypoint.sh
47-
│ ├── proxy-init/ # iptables init container (envoy-sidecar mode only)
47+
│ ├── proxy-init/ # iptables init container (envoy-sidecar + proxy-sidecar enforce-redirect modes)
4848
│ │ ├── init-iptables.sh
4949
│ │ ├── Dockerfile.init
5050
│ │ ├── Makefile
@@ -76,7 +76,7 @@ kagenti-extensions/
7676

7777
**Common:**
7878
- `authlib/` — shared auth library (JWT validation, token exchange, caching, routing, all listener implementations, all plugins).
79-
- `proxy-init/init-iptables.sh` — traffic interception setup (Istio ambient mesh compatible). Used by envoy-sidecar mode only.
79+
- `proxy-init/init-iptables.sh` — traffic interception setup (Istio ambient mesh compatible). Used by envoy-sidecar mode (`redirect`) and by proxy-sidecar mode's `enforce-redirect` egress guard.
8080
- `proxy-init/Dockerfile.init` — proxy-init container image.
8181

8282
**Ports (envoy-sidecar):** 15123 (outbound), 15124 (inbound), 9090 (ext-proc), 9901 (admin)
@@ -149,13 +149,20 @@ Three mode-specific binaries, one Dockerfile per binary:
149149

150150
### PR Title Convention
151151

152-
PRs must follow **conventional commits** format:
152+
PR titles must follow the format:
153153

154154
```
155-
<type>: <Subject starting with uppercase>
155+
<Prefix>: <Subject starting with uppercase>
156156
```
157157

158-
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`
158+
The CI title check (`deepakputhraya/action-pr-title`) is **case-sensitive** and
159+
requires a **capitalized** prefix from this set: `Build`, `Chore`, `CI`, `Docs`,
160+
`Feat`, `Fix`, `Perf`, `Refactor`, `Revert`, `Style`, `Test`, `Feature`,
161+
`Bug fix`, `Proposal`, `Breaking change`, `Other`, `Other/Misc`.
162+
163+
Note: commit *message* examples elsewhere in this doc use lowercase
164+
(`feat:` / `fix:`) — that is fine for commits, but the PR *title* check rejects
165+
lowercase prefixes. Use `Fix:` / `Feat:` / `Docs:` in PR titles.
159166

160167
## Container Images
161168

@@ -167,7 +174,7 @@ All images are pushed to `ghcr.io/kagenti/kagenti-extensions/` from
167174
| **`authbridge`** | **`authbridge/cmd/authbridge-proxy/Dockerfile`** | **proxy-sidecar combined image (default mode): authbridge-proxy (full plugin set incl. parsers) + spiffe-helper. No Envoy.** |
168175
| `authbridge-envoy` | `authbridge/cmd/authbridge-envoy/Dockerfile` | envoy-sidecar combined image: Envoy + authbridge-envoy (ext_proc, full plugin set) + spiffe-helper |
169176
| `authbridge-lite` | `authbridge/cmd/authbridge-lite/Dockerfile` | proxy-sidecar lite combined image: authbridge-lite (auth gates only, parsers dropped) + spiffe-helper. Same listener layout as `authbridge`; not yet referenced by the operator's default config |
170-
| `proxy-init` | `authbridge/proxy-init/Dockerfile.init` | Alpine + iptables init container (envoy-sidecar mode only) |
177+
| `proxy-init` | `authbridge/proxy-init/Dockerfile.init` | Alpine + iptables init container (envoy-sidecar + proxy-sidecar enforce-redirect modes) |
171178

172179
In all three combined images, `spiffe-helper` is started conditionally
173180
based on the `SPIRE_ENABLED` env var (set by the operator when SPIRE

authbridge/CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ authbridge/
6262
│ ├── Dockerfile # proxy-sidecar lite combined image
6363
│ └── entrypoint.sh
6464
65-
├── proxy-init/ # iptables init container (envoy-sidecar mode only)
65+
├── proxy-init/ # iptables init container (envoy-sidecar + proxy-sidecar enforce-redirect modes)
6666
│ ├── init-iptables.sh # iptables setup script
6767
│ ├── Dockerfile.init # proxy-init container image
6868
│ ├── Makefile # docker-build-init + load-image targets
@@ -350,7 +350,7 @@ without needing a CR.
350350
### Build images locally
351351

352352
```bash
353-
# Build the proxy-init iptables init container (envoy-sidecar mode only)
353+
# Build the proxy-init iptables init container (envoy-sidecar + proxy-sidecar enforce-redirect modes)
354354
cd authbridge/proxy-init
355355
make docker-build-init
356356
make load-image # Uses KIND_CLUSTER_NAME env var (default: kagenti)

authbridge/proxy-init/README.md

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ env var:
88
| `MODE` | Used by | What it does |
99
|---|---|---|
1010
| `redirect` (default) | `envoy-sidecar` | Transparently **REDIRECT**s pod traffic to the Envoy listeners. |
11-
| `enforce-redirect` | `proxy-sidecar`, `lite` | Fail-closed egress guard that **captures**: REDIRECTs external TCP that bypasses the forward proxy to AuthBridge's transparent listener; DROPs non-TCP external egress. |
11+
| `enforce-redirect` | `proxy-sidecar`, `lite` | Fail-closed egress guard that **captures**: REDIRECTs bypassing TCP — external **and** in-cluster (except in-cluster DNS) — to AuthBridge's transparent listener; DROPs non-TCP external egress; leaves in-cluster non-TCP (DNS/UDP) direct. |
1212

1313
## `redirect` mode (envoy-sidecar)
1414

@@ -48,39 +48,50 @@ nat-table target but the nat table forbids `DROP` (`iptables` errors with
4848

4949
- **`nat` OUTPUT / `AB_REDIRECT`** (position 1): `RETURN` ztunnel mark
5050
`0x539`, the proxy UID (`--uid-owner $PROXY_UID`, avoids the loop),
51-
loopback, and `CLUSTER_CIDRS`; then `REDIRECT` external **TCP** to
52-
`TRANSPARENT_PORT`.
51+
loopback, and in-cluster **DNS-over-TCP** (`-p tcp --dport 53` to
52+
`CLUSTER_CIDRS`, so cluster name resolution stays direct); then
53+
`REDIRECT` all remaining **TCP** — external **and** in-cluster — to
54+
`TRANSPARENT_PORT`, so agent→in-cluster calls (e.g. agent→tool) are
55+
captured by the egress pipeline too.
5356
- **`mangle` OUTPUT / `AB_NOTCP`** (position 1): the same exemptions
5457
(plus `ESTABLISHED,RELATED` first, so UDP conntrack replies like DNS
55-
pass), then `-p tcp -j RETURN` (TCP is handled by the nat REDIRECT) and
56-
a terminal `DROP` for external **non-TCP** (UDP/QUIC), so HTTP/3 cannot
57-
bypass — well-behaved clients fall back to TCP and get captured.
58+
pass), **including the full `CLUSTER_CIDRS` `RETURN`** so in-cluster
59+
non-TCP (DNS-over-UDP and any other in-cluster UDP) stays direct; then
60+
`-p tcp -j RETURN` (TCP is handled by the nat REDIRECT) and a terminal
61+
`DROP` for external **non-TCP** (UDP/QUIC), so HTTP/3 cannot bypass —
62+
well-behaved clients fall back to TCP and get captured.
5863

5964
Because the OUTPUT hook order is `raw → mangle → nat → filter`, the
6065
mangle chain drops non-TCP on its original destination while TCP falls
6166
through to the nat REDIRECT. Both chains are inserted at position 1,
62-
ahead of Istio's appended (`-A`) chains, so they preempt ambient's nat
63-
redirect for external destinations — exactly as `redirect` mode does for
64-
the Envoy path. IPv6 mirrors apply the same rules. See
67+
ahead of Istio's appended (`-A`) chains. The proxy's own re-originated
68+
egress (`--uid-owner $PROXY_UID`, `RETURN`ed) falls through to
69+
`ISTIO_OUTPUT` → ztunnel for transport mTLS under Istio ambient, and goes
70+
out plain without a mesh — so capturing in-cluster TCP **composes with**
71+
the mesh (AuthBridge does L7, ztunnel does transport) rather than
72+
bypassing it. IPv6 mirrors apply the same rules. See
6573
[`test-enforce-redirect.sh`](./test-enforce-redirect.sh), which proves
6674
the capture, the preemption, and the non-TCP drop via packet counters.
6775

68-
> **`CLUSTER_CIDRS` is Kind-shaped by default.** The `10.0.0.0/8` default
69-
> covers Kind (pods `10.244.0.0/16` + services `10.96.0.0/16`). Other
70-
> distros differ — **OpenShift** uses services `172.30.0.0/16` and pods
71-
> `10.128.0.0/14`, and `172.30.0.0/16` is **outside** `10/8`, so the
72-
> default would drop in-cluster service traffic. On OCP/EKS/etc. you
73-
> **must** override `CLUSTER_CIDRS` with the cluster's real pod+service
74-
> ranges. The script logs the resolved value at startup, and the
75-
> operator sets it from the cluster's CIDRs.
76+
> **`CLUSTER_CIDRS` is Kind-shaped by default.** It now governs only what
77+
> stays **direct**: in-cluster DNS-over-TCP (`tcp/53`) and in-cluster
78+
> non-TCP (so cluster DNS keeps working). The `10.0.0.0/8` default covers
79+
> Kind (pods `10.244.0.0/16` + services `10.96.0.0/16`). Other distros
80+
> differ — **OpenShift** uses services `172.30.0.0/16` and pods
81+
> `10.128.0.0/14`, and `172.30.0.0/16` is **outside** `10/8`, so with the
82+
> default, cluster DNS (CoreDNS on `172.30.x`) is no longer left direct and
83+
> **resolution breaks**. On OCP/EKS/etc. you **must** override
84+
> `CLUSTER_CIDRS` with the cluster's real pod+service ranges. The script
85+
> logs the resolved value at startup, and the operator sets it from the
86+
> cluster's CIDRs.
7687
7788
> **`enforce-redirect` intentionally ignores `OUTBOUND_PORTS_EXCLUDE`** (a
7889
> `redirect`-mode knob). Any destination previously bypassed that way —
7990
> e.g. a direct LLM endpoint at `host.docker.internal:11434` — is now
80-
> captured (external TCP) or dropped (external non-TCP) unless it falls
81-
> within `CLUSTER_CIDRS`. That is the point: `enforce-redirect` closes
82-
> direct-egress holes. Operators relying on a bypass for an in-cluster
83-
> target must include it in `CLUSTER_CIDRS`.
91+
> captured (external TCP) or dropped (external non-TCP). In-cluster TCP is
92+
> captured as well (only in-cluster DNS stays direct), so `CLUSTER_CIDRS`
93+
> is **not** a TCP bypass — it only keeps cluster DNS and in-cluster UDP
94+
> direct. That is the point: `enforce-redirect` closes direct-egress holes.
8495
8596
## iptables backend
8697

@@ -100,7 +111,7 @@ whichever the host kernel exposes. Override with `IPTABLES_CMD` (and
100111
| `OUTBOUND_PORTS_EXCLUDE` | (empty) | redirect | Comma-separated outbound port list to skip (e.g. `8080`) |
101112
| `INBOUND_PORTS_EXCLUDE` | (empty) | redirect | Comma-separated inbound port list to skip |
102113
| `POD_IP` | (required in `redirect`) | redirect | Set via Downward API; DNAT target for ambient-mesh inbound. Not used by `enforce-redirect`. |
103-
| `CLUSTER_CIDRS` | `10.0.0.0/8` | enforce-redirect | Comma-separated in-cluster CIDRs allowed direct (pods/services/DNS) |
114+
| `CLUSTER_CIDRS` | `10.0.0.0/8` | enforce-redirect | Comma-separated in-cluster CIDRs kept direct **for DNS only**: DNS-over-TCP (`tcp/53`) + all in-cluster non-TCP (UDP). Other in-cluster TCP is captured. |
104115
| `CLUSTER_CIDRS6` | (empty) | enforce-redirect | IPv6 in-cluster CIDRs (dual-stack); empty drops all external v6 egress |
105116
| `IPTABLES_CMD` | auto-detected | all | Override iptables binary (`iptables-legacy` / `iptables-nft`) |
106117
| `IP6TABLES_CMD` | derived from `IPTABLES_CMD` | enforce-redirect | Override ip6tables binary |

authbridge/proxy-init/init-iptables.sh

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -178,11 +178,13 @@ SSH_PORT="${SSH_PORT:-22}"
178178
OUTBOUND_PORTS_EXCLUDE="${OUTBOUND_PORTS_EXCLUDE:-}"
179179
INBOUND_PORTS_EXCLUDE="${INBOUND_PORTS_EXCLUDE:-}"
180180

181-
# enforce-redirect mode: in-cluster destinations the agent may reach directly
182-
# (pods / services / DNS) — external TCP is REDIRECTed to the transparent
183-
# listener and external non-TCP is dropped. Defaults to the RFC1918 10/8 block
184-
# which covers typical Kind pod (10.244/16) and service (10.96/16) CIDRs;
185-
# override with the cluster's actual ranges.
181+
# enforce-redirect mode: in-cluster CIDRs used to keep cluster DNS resolution
182+
# working — in-cluster DNS-over-TCP (TCP/53) and in-cluster non-TCP (UDP, incl.
183+
# DNS) are left direct, while all OTHER in-cluster TCP is now captured by the
184+
# egress pipeline so agent->in-cluster calls (e.g. agent->tool) are seen.
185+
# Defaults to the RFC1918 10/8 block (typical Kind pod 10.244/16 + service
186+
# 10.96/16); override with the cluster's actual ranges (e.g. OpenShift
187+
# 172.30.0.0/16) so cluster DNS is not captured.
186188
CLUSTER_CIDRS="${CLUSTER_CIDRS:-10.0.0.0/8}"
187189
CLUSTER_CIDRS6="${CLUSTER_CIDRS6:-}" # IPv6 in-cluster CIDRs (dual-stack); empty = none
188190

@@ -226,9 +228,11 @@ fi
226228
# Rule order: RETURN ztunnel's own sockets (fwmark 0x539, no-op without ambient)
227229
# -> RETURN the proxy's own re-originated egress (PROXY_UID, avoids the loop) ->
228230
# RETURN loopback (app -> forward proxy via HTTP_PROXY, and any loopback) ->
229-
# RETURN in-cluster CIDRs (mesh/DNS, left direct) -> REDIRECT external TCP to
230-
# TRANSPARENT_PORT -> DROP all other external egress (UDP/QUIC, so HTTP/3 can't
231-
# bypass; well-behaved clients fall back to TCP and get captured).
231+
# RETURN in-cluster DNS-over-TCP (TCP/53 to CLUSTER_CIDRS, left direct) ->
232+
# REDIRECT all remaining TCP -- external AND in-cluster -- to TRANSPARENT_PORT
233+
# (so agent->in-cluster calls are captured too) -> DROP all other external
234+
# egress (UDP/QUIC, so HTTP/3 can't bypass; clients fall back to TCP). In-cluster
235+
# non-TCP (UDP, incl. DNS) is left direct by the mangle chain below.
232236
#
233237
# The nat REDIRECT chain has no conntrack ESTABLISHED rule: nat only evaluates
234238
# the first packet of a flow, so replies and established connections are not
@@ -248,7 +252,7 @@ setup_enforce_redirect() {
248252

249253
echo "enforce-redirect: installing fail-closed egress capture"
250254
echo "enforce-redirect: external TCP -> 127.0.0.1:${TRANSPARENT_PORT} (nat REDIRECT); external non-TCP -> DROP (mangle)"
251-
echo "enforce-redirect: exempt proxy UID=${PROXY_UID}; direct in-cluster CIDRs=${CLUSTER_CIDRS}"
255+
echo "enforce-redirect: exempt proxy UID=${PROXY_UID}; in-cluster TCP captured (DNS/53 + non-TCP left direct; CIDRs=${CLUSTER_CIDRS})"
252256

253257
# --- IPv4: nat REDIRECT for TCP ---
254258
${IPT} -t nat -N "${REDIR_CHAIN}" 2>/dev/null || true
@@ -261,11 +265,15 @@ setup_enforce_redirect() {
261265
# app -> forward proxy over loopback (HTTP_PROXY target), and any loopback.
262266
${IPT} -t nat -A "${REDIR_CHAIN}" -o lo -j RETURN
263267
${IPT} -t nat -A "${REDIR_CHAIN}" -d 127.0.0.0/8 -j RETURN
264-
# in-cluster traffic (pods / services / DNS) — left direct, carried by the mesh.
268+
# in-cluster DNS-over-TCP (TCP/53) — left direct so cluster name resolution is
269+
# not captured. All OTHER in-cluster TCP falls through to the REDIRECT below,
270+
# so the egress pipeline sees agent->in-cluster calls (e.g. agent->tool). The
271+
# proxy's re-originated egress (PROXY_UID, RETURNed above) is exempt, and in an
272+
# Istio ambient mesh it falls through to ISTIO_OUTPUT -> ztunnel for mTLS.
265273
for cidr in $(echo "${CLUSTER_CIDRS}" | tr ',' ' '); do
266-
[ -n "${cidr}" ] && ${IPT} -t nat -A "${REDIR_CHAIN}" -d "${cidr}" -j RETURN
274+
[ -n "${cidr}" ] && ${IPT} -t nat -A "${REDIR_CHAIN}" -p tcp --dport 53 -d "${cidr}" -j RETURN
267275
done
268-
# external TCP that bypassed the forward proxy — capture it transparently.
276+
# all remaining TCP (external + in-cluster, minus in-cluster DNS) — capture it transparently.
269277
${IPT} -t nat -A "${REDIR_CHAIN}" -p tcp -j REDIRECT --to-port "${TRANSPARENT_PORT}"
270278
if ! ${IPT} -t nat -C OUTPUT -j "${REDIR_CHAIN}" 2>/dev/null; then
271279
${IPT} -t nat -I OUTPUT 1 -j "${REDIR_CHAIN}"
@@ -280,6 +288,9 @@ setup_enforce_redirect() {
280288
${IPT} -t mangle -A "${NOTCP_CHAIN}" -m owner --uid-owner "${PROXY_UID}" -j RETURN
281289
${IPT} -t mangle -A "${NOTCP_CHAIN}" -o lo -j RETURN
282290
${IPT} -t mangle -A "${NOTCP_CHAIN}" -d 127.0.0.0/8 -j RETURN
291+
# in-cluster non-TCP (UDP, incl. DNS) — left direct. In-cluster TCP is now
292+
# captured by the nat chain above; only in-cluster UDP relies on this RETURN
293+
# (notably DNS-over-UDP, which the terminal DROP below would otherwise kill).
283294
for cidr in $(echo "${CLUSTER_CIDRS}" | tr ',' ' '); do
284295
[ -n "${cidr}" ] && ${IPT} -t mangle -A "${NOTCP_CHAIN}" -d "${cidr}" -j RETURN
285296
done
@@ -305,8 +316,10 @@ setup_enforce_redirect() {
305316
${IP6T} -t nat -A "${REDIR_CHAIN}" -d ::1/128 -j RETURN
306317
${IP6T} -t nat -A "${REDIR_CHAIN}" -d fe80::/10 -j RETURN
307318
${IP6T} -t nat -A "${REDIR_CHAIN}" -d ff02::/16 -j RETURN
319+
# in-cluster DNS-over-TCP (TCP/53) only — mirror of IPv4; all other in-cluster
320+
# v6 TCP is captured below.
308321
for cidr in $(echo "${CLUSTER_CIDRS6}" | tr ',' ' '); do
309-
[ -n "${cidr}" ] && ${IP6T} -t nat -A "${REDIR_CHAIN}" -d "${cidr}" -j RETURN
322+
[ -n "${cidr}" ] && ${IP6T} -t nat -A "${REDIR_CHAIN}" -p tcp --dport 53 -d "${cidr}" -j RETURN
310323
done
311324
${IP6T} -t nat -A "${REDIR_CHAIN}" -p tcp -j REDIRECT --to-port "${TRANSPARENT_PORT}"
312325
if ! ${IP6T} -t nat -C OUTPUT -j "${REDIR_CHAIN}" 2>/dev/null; then

authbridge/proxy-init/test-enforce-redirect.sh

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
# position 1 with the expected RETURN exemptions and a `-p tcp` REDIRECT to
99
# TRANSPARENT_PORT (no DROP — the nat table forbids it); and the AB_NOTCP
1010
# chain is hooked from mangle OUTPUT with `-p tcp RETURN` then a terminal
11-
# DROP for external non-TCP egress.
11+
# DROP for external non-TCP egress. In-cluster DNS-over-TCP (TCP/53) is left
12+
# direct; all OTHER in-cluster TCP is captured (no blanket in-cluster RETURN).
1213
# 2. CAPTURE (not drop) + AMBIENT ROBUSTNESS — external TCP egress is
1314
# REDIRECTed to TRANSPARENT_PORT, preempting a simulated Istio ambient
1415
# "nat OUTPUT REDIRECT" appended after our chain. Proven via packet
@@ -65,7 +66,13 @@ assert "nat ztunnel mark RETURN" 'AB_REDIRECT .*mark.*0x539.*-j RETUR
6566
assert "nat proxy UID RETURN" 'AB_REDIRECT .*--uid-owner 1337 -j RETURN' "${natdump}"
6667
assert "nat loopback iface RETURN" 'AB_REDIRECT -o lo -j RETURN' "${natdump}"
6768
assert "nat loopback cidr RETURN" 'AB_REDIRECT -d 127.0.0.0/8 -j RETURN' "${natdump}"
68-
assert "nat cluster cidr RETURN" 'AB_REDIRECT -d 10.0.0.0/8 -j RETURN' "${natdump}"
69+
# in-cluster DNS-over-TCP (TCP/53) is left direct so cluster name resolution is
70+
# not captured; all OTHER in-cluster TCP now falls through to the REDIRECT.
71+
assert "nat in-cluster DNS-over-TCP RETURN" 'AB_REDIRECT.*10\.0\.0\.0/8.*dport 53.*RETURN' "${natdump}"
72+
# the blanket in-cluster RETURN must be gone — in-cluster TCP is now captured.
73+
if echo "${natdump}" | grep -qE '^-A AB_REDIRECT -d 10\.0\.0\.0/8 -j RETURN$'; then
74+
echo "FAIL: nat AB_REDIRECT still blanket-RETURNs in-cluster (in-cluster TCP not captured)"; fail=1
75+
else echo "PASS: no blanket in-cluster RETURN — in-cluster TCP captured, only DNS/53 left direct"; fi
6976
assert "nat tcp REDIRECT to transparent" "AB_REDIRECT -p tcp -j REDIRECT --to-ports ${TPORT}" "${natdump}"
7077
if echo "${natdump}" | grep -qE 'AB_REDIRECT -j DROP'; then
7178
echo "FAIL: nat AB_REDIRECT must not contain DROP (nat table forbids it)"; fail=1

0 commit comments

Comments
 (0)