126126
127127set -e
128128
129+ # --- Mode selection ---
130+ # MODE selects the interception strategy:
131+ # redirect (default) — envoy-sidecar: transparently REDIRECT pod traffic
132+ # to the Envoy listeners (the behavior documented above).
133+ # enforce-drop — proxy-sidecar: a fail-closed egress guard. The app is
134+ # configured with HTTP_PROXY pointing at AuthBridge's forward
135+ # proxy; this mode DROPs any egress that bypasses the proxy,
136+ # forcing all external traffic through AuthBridge regardless of
137+ # whether the app honors HTTP_PROXY. It installs no REDIRECT and
138+ # no PREROUTING/inbound rules. See setup_enforce_drop() below.
139+ MODE=" ${MODE:- redirect} "
140+ case " ${MODE} " in
141+ redirect|enforce-drop) ;;
142+ * ) echo " ERROR: unknown MODE='${MODE} ' (expected: redirect | enforce-drop)" >&2 ; exit 1 ;;
143+ esac
144+
129145# --- Auto-detect iptables backend ---
130146# Prefer iptables-legacy for maximum compatibility with Kubernetes networking.
131147# The nft backend sets rules in a different netfilter table that may not be
@@ -153,6 +169,17 @@ SSH_PORT="${SSH_PORT:-22}"
153169OUTBOUND_PORTS_EXCLUDE=" ${OUTBOUND_PORTS_EXCLUDE:- } "
154170INBOUND_PORTS_EXCLUDE=" ${INBOUND_PORTS_EXCLUDE:- } "
155171
172+ # enforce-drop mode: in-cluster destinations the agent may reach directly
173+ # (pods / services / DNS) — everything else egressing the pod is dropped.
174+ # Defaults to the RFC1918 10/8 block which covers typical Kind pod (10.244/16)
175+ # and service (10.96/16) CIDRs; override with the cluster's actual ranges.
176+ CLUSTER_CIDRS=" ${CLUSTER_CIDRS:- 10.0.0.0/ 8} "
177+ CLUSTER_CIDRS6=" ${CLUSTER_CIDRS6:- } " # IPv6 in-cluster CIDRs (dual-stack); empty = none
178+
179+ # IPv6 counterpart of the detected iptables backend (iptables-legacy ->
180+ # ip6tables-legacy, iptables -> ip6tables). Override with IP6TABLES_CMD.
181+ IP6T=" ${IP6TABLES_CMD:- $(echo " ${IPT} " | sed ' s/iptables/ip6tables/' )} "
182+
156183# Istio ztunnel defaults
157184ZTUNNEL_HBONE_PORT=" ${ZTUNNEL_HBONE_PORT:- 15008} "
158185ZTUNNEL_MARK=" ${ZTUNNEL_MARK:- 0x539/ 0xfff} " # 0x539 = 1337 decimal, ztunnel's socket fwmark
@@ -162,12 +189,115 @@ ISTIO_HEALTH_PROBE_SRC="${ISTIO_HEALTH_PROBE_SRC:-169.254.7.127}"
162189# It must be passed via the Kubernetes Downward API (status.podIP) or set manually.
163190# We use DNAT to the pod IP instead of REDIRECT to avoid needing route_localnet=1,
164191# which would require a privileged init container (to write to read-only /proc/sys).
165- if [ -z " ${POD_IP} " ]; then
166- echo " ERROR: POD_IP environment variable is not set." >&2
192+ # POD_IP is only needed by redirect mode (DNAT target for the ambient inbound
193+ # rule). enforce-drop does no DNAT, so it does not require it.
194+ if [ " ${MODE} " = " redirect" ] && [ -z " ${POD_IP} " ]; then
195+ echo " ERROR: POD_IP environment variable is not set (required for redirect mode)." >&2
167196 echo " Set it via the Kubernetes Downward API (status.podIP) or manually." >&2
168197 exit 1
169198fi
170199
200+ # =============================================================================
201+ # enforce-drop mode (proxy-sidecar fail-closed egress guard)
202+ # =============================================================================
203+ #
204+ # proxy-sidecar configures the app with HTTP_PROXY=127.0.0.1:<forward-proxy>.
205+ # Unlike redirect mode we do NOT transparently REDIRECT — you cannot redirect
206+ # raw traffic into a CONNECT forward proxy. Instead we DROP any egress that
207+ # leaves the pod without going through the proxy, forcing all external traffic
208+ # through AuthBridge regardless of whether the app honors HTTP_PROXY.
209+ #
210+ # Placement — a dedicated chain hooked from *mangle* OUTPUT at position 1:
211+ # * Istio ambient, when active, installs an in-pod `nat OUTPUT` REDIRECT
212+ # (ISTIO_OUTPUT -> ztunnel :15001). The netfilter OUTPUT hook order is
213+ # raw -> mangle -> nat -> filter, so a DROP in mangle evaluates the
214+ # ORIGINAL destination and fires BEFORE ambient's nat redirect can rewrite
215+ # it. A DROP in `filter` would run after nat and be defeated (dst already
216+ # rewritten to 127.0.0.1). -I 1 also places us ahead of Istio's appended
217+ # (-A) mangle ISTIO_OUTPUT chain.
218+ # * Works identically with no ambient, in-pod ambient, or node-level ambient
219+ # (in the node-level case our pod-netns rule runs before the packet ever
220+ # reaches the host netns).
221+ #
222+ # Rule order in the chain: RETURN ztunnel's own sockets (fwmark 0x539, a no-op
223+ # when ambient is absent) -> RETURN the proxy's own egress (PROXY_UID) ->
224+ # RETURN loopback (app -> proxy) -> RETURN in-cluster CIDRs (mesh/DNS) ->
225+ # DROP everything else (direct external egress, incl. UDP/QUIC).
226+ setup_enforce_drop () {
227+ CHAIN=" AB_EGRESS"
228+
229+ echo " enforce-drop: installing fail-closed egress guard (mangle OUTPUT, chain ${CHAIN} )"
230+ echo " enforce-drop: exempt proxy UID=${PROXY_UID} ; allowed in-cluster CIDRs=${CLUSTER_CIDRS} "
231+
232+ # --- IPv4 ---
233+ ${IPT} -t mangle -N " ${CHAIN} " 2> /dev/null || true
234+ ${IPT} -t mangle -F " ${CHAIN} "
235+ # Replies to inbound connections (and related flows) are locally generated and
236+ # also traverse OUTPUT — a reply is never a "bypass". Let established/related
237+ # traffic through FIRST, so e.g. kubelet health-probe responses to an
238+ # off-cluster node IP (in Kind the node is 172.18.0.0/16, outside CLUSTER_CIDRS)
239+ # are not caught by the terminal DROP. Only NEW app-initiated flows are gated.
240+ ${IPT} -t mangle -A " ${CHAIN} " -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
241+ # ztunnel's own sockets (ambient) carry fwmark 0x539 — let them through so the
242+ # mesh/HBONE path keeps working. No-op when ambient is not installed.
243+ ${IPT} -t mangle -A " ${CHAIN} " -m mark --mark " ${ZTUNNEL_MARK} " -j RETURN
244+ # the AuthBridge proxy's own re-originated egress (must run as PROXY_UID).
245+ ${IPT} -t mangle -A " ${CHAIN} " -m owner --uid-owner " ${PROXY_UID} " -j RETURN
246+ # app -> proxy over loopback (HTTP_PROXY target), and any loopback traffic.
247+ ${IPT} -t mangle -A " ${CHAIN} " -o lo -j RETURN
248+ ${IPT} -t mangle -A " ${CHAIN} " -d 127.0.0.0/8 -j RETURN
249+ # in-cluster traffic (pods / services / DNS) — carried by the mesh, not the proxy.
250+ for cidr in $( echo " ${CLUSTER_CIDRS} " | tr ' ,' ' ' ) ; do
251+ [ -n " ${cidr} " ] && ${IPT} -t mangle -A " ${CHAIN} " -d " ${cidr} " -j RETURN
252+ done
253+ # everything else == direct external egress that bypassed the proxy. Drop it.
254+ # No -p filter, so UDP (QUIC/HTTP-3) is dropped as well as TCP.
255+ ${IPT} -t mangle -A " ${CHAIN} " -j DROP
256+ # Hook at position 1 so we run before any appended Istio mangle chain and
257+ # before nat OUTPUT.
258+ if ! ${IPT} -t mangle -C OUTPUT -j " ${CHAIN} " 2> /dev/null; then
259+ ${IPT} -t mangle -I OUTPUT 1 -j " ${CHAIN} "
260+ fi
261+ echo " enforce-drop: IPv4 egress guard configured"
262+
263+ # --- IPv6 ---
264+ # Cluster is IPv4-only by default; until v6 cluster CIDRs are wired
265+ # (CLUSTER_CIDRS6), drop external v6 egress while allowing: established/related
266+ # replies, loopback, link-local unicast (fe80::/10) and link-local multicast
267+ # (ff02::/16, which carries NDP neighbor/router solicitations and MLD), the
268+ # proxy UID, and ztunnel's mark.
269+ if command -v " ${IP6T%% * } " > /dev/null 2>&1 && ${IP6T} -t mangle -L > /dev/null 2>&1 ; then
270+ ${IP6T} -t mangle -N " ${CHAIN} " 2> /dev/null || true
271+ ${IP6T} -t mangle -F " ${CHAIN} "
272+ ${IP6T} -t mangle -A " ${CHAIN} " -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
273+ ${IP6T} -t mangle -A " ${CHAIN} " -m mark --mark " ${ZTUNNEL_MARK} " -j RETURN
274+ ${IP6T} -t mangle -A " ${CHAIN} " -m owner --uid-owner " ${PROXY_UID} " -j RETURN
275+ ${IP6T} -t mangle -A " ${CHAIN} " -o lo -j RETURN
276+ ${IP6T} -t mangle -A " ${CHAIN} " -d ::1/128 -j RETURN
277+ ${IP6T} -t mangle -A " ${CHAIN} " -d fe80::/10 -j RETURN
278+ ${IP6T} -t mangle -A " ${CHAIN} " -d ff02::/16 -j RETURN
279+ for cidr in $( echo " ${CLUSTER_CIDRS6} " | tr ' ,' ' ' ) ; do
280+ [ -n " ${cidr} " ] && ${IP6T} -t mangle -A " ${CHAIN} " -d " ${cidr} " -j RETURN
281+ done
282+ ${IP6T} -t mangle -A " ${CHAIN} " -j DROP
283+ if ! ${IP6T} -t mangle -C OUTPUT -j " ${CHAIN} " 2> /dev/null; then
284+ ${IP6T} -t mangle -I OUTPUT 1 -j " ${CHAIN} "
285+ fi
286+ echo " enforce-drop: IPv6 egress guard configured"
287+ else
288+ echo " enforce-drop: ip6tables unavailable — skipping IPv6 egress guard"
289+ fi
290+
291+ echo " enforce-drop: fail-closed egress guard active"
292+ }
293+
294+ # Dispatch enforce-drop here and exit; redirect mode falls through to the
295+ # transparent-interception logic below.
296+ if [ " ${MODE} " = " enforce-drop" ]; then
297+ setup_enforce_drop
298+ exit 0
299+ fi
300+
171301# =============================================================================
172302# OUTBOUND traffic interception (nat OUTPUT)
173303# =============================================================================
0 commit comments