Skip to content

Commit 21663be

Browse files
authored
Merge pull request #499 from huang195/fix/proxy-init-dns-resolvconf
Fix: Exempt DNS by resolv.conf nameservers, retire CLUSTER_CIDRS
2 parents ad0787e + d175b97 commit 21663be

3 files changed

Lines changed: 151 additions & 73 deletions

File tree

authbridge/proxy-init/README.md

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -48,18 +48,19 @@ 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 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
51+
loopback, and **DNS-over-TCP** (`-p tcp --dport 53`) to each
52+
`/etc/resolv.conf` nameserver (so cluster name resolution stays direct);
53+
then `REDIRECT` all remaining **TCP** — external **and** in-cluster — to
5454
`TRANSPARENT_PORT`, so agent→in-cluster calls (e.g. agent→tool) are
5555
captured by the egress pipeline too.
56-
- **`mangle` OUTPUT / `AB_NOTCP`** (position 1): the same exemptions
57-
(plus `ESTABLISHED,RELATED` first, so UDP conntrack replies like DNS
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.
56+
- **`mangle` OUTPUT / `AB_NOTCP`** (position 1): the same UID/mark/loopback
57+
exemptions (plus `ESTABLISHED,RELATED` first, so UDP conntrack replies
58+
like DNS pass), then **DNS-over-UDP** (`-p udp --dport 53`) to each
59+
resolv.conf nameserver so cluster DNS keeps working; then `-p tcp -j
60+
RETURN` (TCP is handled by the nat REDIRECT) and a terminal `DROP` for
61+
all other **non-TCP** (UDP/QUIC), so HTTP/3 cannot bypass and non-DNS
62+
in-cluster UDP is dropped too — well-behaved clients fall back to TCP and
63+
get captured.
6364

6465
Because the OUTPUT hook order is `raw → mangle → nat → filter`, the
6566
mangle chain drops non-TCP on its original destination while TCP falls
@@ -73,25 +74,26 @@ bypassing it. IPv6 mirrors apply the same rules. See
7374
[`test-enforce-redirect.sh`](./test-enforce-redirect.sh), which proves
7475
the capture, the preemption, and the non-TCP drop via packet counters.
7576

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.
77+
> **DNS stays direct by following the pod's actual resolvers — no CIDR
78+
> guessing.** The only thing left direct is DNS (`tcp/53` + `udp/53`) to the
79+
> `nameserver` IPs in `/etc/resolv.conf`, which kubelet writes per the pod's
80+
> `dnsPolicy`. This is cluster-agnostic by construction: it works whether the
81+
> resolver is a Kind/OpenShift/EKS service ClusterIP (any service CIDR — incl.
82+
> OpenShift's `172.30.0.0/16`, which is **outside** `10/8`) or a NodeLocal
83+
> DNSCache at a link-local `169.254.x` address. The script logs the resolved
84+
> nameservers at startup; override the file path with `RESOLV_CONF` (mainly
85+
> for tests). There is **no** in-cluster CIDR knob — `enforce-redirect` no
86+
> longer needs one. (A prior `CLUSTER_CIDRS` env was removed; its `10.0.0.0/8`
87+
> default silently dropped DNS on OpenShift, where the resolver sits outside
88+
> `10/8`.)
8789
8890
> **`enforce-redirect` intentionally ignores `OUTBOUND_PORTS_EXCLUDE`** (a
8991
> `redirect`-mode knob). Any destination previously bypassed that way —
9092
> e.g. a direct LLM endpoint at `host.docker.internal:11434` — is now
9193
> 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.
94+
> captured as well (only DNS to the resolvers stays direct). That is the
95+
> point: `enforce-redirect` closes direct-egress holes, and the DNS
96+
> exemption is scoped to `port 53` to the resolver IPs — not a TCP bypass.
9597
9698
## iptables backend
9799

@@ -111,8 +113,7 @@ whichever the host kernel exposes. Override with `IPTABLES_CMD` (and
111113
| `OUTBOUND_PORTS_EXCLUDE` | (empty) | redirect | Comma-separated outbound port list to skip (e.g. `8080`) |
112114
| `INBOUND_PORTS_EXCLUDE` | (empty) | redirect | Comma-separated inbound port list to skip |
113115
| `POD_IP` | (required in `redirect`) | redirect | Set via Downward API; DNAT target for ambient-mesh inbound. Not used by `enforce-redirect`. |
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. |
115-
| `CLUSTER_CIDRS6` | (empty) | enforce-redirect | IPv6 in-cluster CIDRs (dual-stack); empty drops all external v6 egress |
116+
| `RESOLV_CONF` | `/etc/resolv.conf` | enforce-redirect | Path read at init for `nameserver` IPs; DNS (`tcp/53` + `udp/53`) to those IPs is left direct (IPv4→`iptables`, IPv6→`ip6tables`). Override mainly for tests. |
116117
| `IPTABLES_CMD` | auto-detected | all | Override iptables binary (`iptables-legacy` / `iptables-nft`) |
117118
| `IP6TABLES_CMD` | derived from `IPTABLES_CMD` | enforce-redirect | Override ip6tables binary |
118119

authbridge/proxy-init/init-iptables.sh

Lines changed: 69 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -178,15 +178,28 @@ 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 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.
188-
CLUSTER_CIDRS="${CLUSTER_CIDRS:-10.0.0.0/8}"
189-
CLUSTER_CIDRS6="${CLUSTER_CIDRS6:-}" # IPv6 in-cluster CIDRs (dual-stack); empty = none
181+
# enforce-redirect mode: under the egress guard, cluster DNS must stay direct
182+
# (the forward proxy is HTTP-only and cannot carry DNS), while every other
183+
# in-cluster TCP flow is captured by the pipeline. Rather than guess in-cluster
184+
# CIDRs — the resolver may sit OUTSIDE them (OpenShift service net 172.30/172.31,
185+
# outside 10/8) or at a link-local address (NodeLocal DNSCache, 169.254.x) — we
186+
# read the pod's actual resolvers from /etc/resolv.conf and leave only DNS
187+
# (UDP/53 + TCP/53) to THOSE IPs direct. kubelet writes resolv.conf per the pod's
188+
# dnsPolicy, so it is the authoritative, cluster-agnostic source for "where is my
189+
# resolver". Override the path with RESOLV_CONF (e.g. for tests).
190+
RESOLV_CONF="${RESOLV_CONF:-/etc/resolv.conf}"
191+
192+
# Emit the `nameserver` IPs from resolv.conf, one per line (IPv4 and IPv6 mixed;
193+
# callers split by address family). Empty output if the file is missing/unreadable.
194+
get_nameservers() {
195+
[ -r "${RESOLV_CONF}" ] || return 0
196+
while read -r _key _val _rest; do
197+
[ "${_key}" = "nameserver" ] && [ -n "${_val}" ] && echo "${_val}"
198+
done < "${RESOLV_CONF}"
199+
# `read` returns non-zero at EOF; force success so `NS=$(get_nameservers)`
200+
# under `set -e` does not abort the script (missing/empty resolv.conf is fine).
201+
return 0
202+
}
190203

191204
# IPv6 counterpart of the detected iptables backend (iptables-legacy ->
192205
# ip6tables-legacy, iptables -> ip6tables). Override with IP6TABLES_CMD.
@@ -228,11 +241,12 @@ fi
228241
# Rule order: RETURN ztunnel's own sockets (fwmark 0x539, no-op without ambient)
229242
# -> RETURN the proxy's own re-originated egress (PROXY_UID, avoids the loop) ->
230243
# RETURN loopback (app -> forward proxy via HTTP_PROXY, and any loopback) ->
231-
# RETURN in-cluster DNS-over-TCP (TCP/53 to CLUSTER_CIDRS, left direct) ->
244+
# RETURN DNS-over-TCP (TCP/53) to the resolv.conf nameservers (left direct) ->
232245
# 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.
246+
# (so agent->in-cluster calls are captured too) -> DROP all other egress
247+
# (UDP/QUIC, so HTTP/3 can't bypass; clients fall back to TCP). DNS-over-UDP
248+
# (UDP/53) to the resolvers is left direct by the mangle chain below; all other
249+
# non-TCP -- including non-DNS in-cluster UDP -- is dropped.
236250
#
237251
# The nat REDIRECT chain has no conntrack ESTABLISHED rule: nat only evaluates
238252
# the first packet of a flow, so replies and established connections are not
@@ -250,9 +264,22 @@ setup_enforce_redirect() {
250264
REDIR_CHAIN="AB_REDIRECT"
251265
NOTCP_CHAIN="AB_NOTCP"
252266

267+
# Fail closed and LOUD on zero resolvers: without a nameserver to exempt, the
268+
# rules below would drop UDP/53 and capture TCP/53, leaving a running-but-DNS-
269+
# dead pod that is far harder to triage than a failed init container. In a
270+
# Kubernetes pod kubelet always populates resolv.conf, so an empty result means
271+
# a real misconfiguration — surface it as Init:Error rather than silent breakage.
272+
NAMESERVERS=$(get_nameservers)
273+
if [ -z "${NAMESERVERS}" ]; then
274+
echo "enforce-redirect: ERROR: no nameservers found in ${RESOLV_CONF}" >&2
275+
echo "enforce-redirect: refusing to start — DNS egress would be dropped (UDP/53) / captured (TCP/53), silently breaking name resolution." >&2
276+
echo "enforce-redirect: set RESOLV_CONF to a file with valid 'nameserver' entries if running outside Kubernetes." >&2
277+
exit 1
278+
fi
279+
253280
echo "enforce-redirect: installing fail-closed egress capture"
254281
echo "enforce-redirect: external TCP -> 127.0.0.1:${TRANSPARENT_PORT} (nat REDIRECT); external non-TCP -> DROP (mangle)"
255-
echo "enforce-redirect: exempt proxy UID=${PROXY_UID}; in-cluster TCP captured (DNS/53 + non-TCP left direct; CIDRs=${CLUSTER_CIDRS})"
282+
echo "enforce-redirect: exempt proxy UID=${PROXY_UID}; in-cluster TCP captured; DNS/53 to resolvers left direct (resolvers=$(echo "${NAMESERVERS}" | tr '\n' ' '))"
256283

257284
# --- IPv4: nat REDIRECT for TCP ---
258285
${IPT} -t nat -N "${REDIR_CHAIN}" 2>/dev/null || true
@@ -265,13 +292,15 @@ setup_enforce_redirect() {
265292
# app -> forward proxy over loopback (HTTP_PROXY target), and any loopback.
266293
${IPT} -t nat -A "${REDIR_CHAIN}" -o lo -j RETURN
267294
${IPT} -t nat -A "${REDIR_CHAIN}" -d 127.0.0.0/8 -j RETURN
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.
273-
for cidr in $(echo "${CLUSTER_CIDRS}" | tr ',' ' '); do
274-
[ -n "${cidr}" ] && ${IPT} -t nat -A "${REDIR_CHAIN}" -p tcp --dport 53 -d "${cidr}" -j RETURN
295+
# DNS-over-TCP (TCP/53) to the pod's resolvers — left direct so cluster name
296+
# resolution is not captured (the forward proxy can't carry DNS). All OTHER
297+
# in-cluster TCP falls through to the REDIRECT below, so the egress pipeline
298+
# sees agent->in-cluster calls (e.g. agent->tool). The proxy's re-originated
299+
# egress (PROXY_UID, RETURNed above) is exempt, and in an Istio ambient mesh it
300+
# falls through to ISTIO_OUTPUT -> ztunnel for mTLS.
301+
for ns in ${NAMESERVERS}; do
302+
case "${ns}" in *:*) continue ;; esac # IPv6 resolver — handled in the v6 block
303+
${IPT} -t nat -A "${REDIR_CHAIN}" -p tcp --dport 53 -d "${ns}" -j RETURN
275304
done
276305
# all remaining TCP (external + in-cluster, minus in-cluster DNS) — capture it transparently.
277306
${IPT} -t nat -A "${REDIR_CHAIN}" -p tcp -j REDIRECT --to-port "${TRANSPARENT_PORT}"
@@ -288,11 +317,13 @@ setup_enforce_redirect() {
288317
${IPT} -t mangle -A "${NOTCP_CHAIN}" -m owner --uid-owner "${PROXY_UID}" -j RETURN
289318
${IPT} -t mangle -A "${NOTCP_CHAIN}" -o lo -j RETURN
290319
${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).
294-
for cidr in $(echo "${CLUSTER_CIDRS}" | tr ',' ' '); do
295-
[ -n "${cidr}" ] && ${IPT} -t mangle -A "${NOTCP_CHAIN}" -d "${cidr}" -j RETURN
320+
# DNS-over-UDP (UDP/53) to the pod's resolvers — left direct (the terminal DROP
321+
# below would otherwise kill cluster name resolution). Scoped to the resolvers
322+
# and port 53: all other non-TCP, including non-DNS in-cluster UDP, is dropped
323+
# so nothing can bypass the pipeline over UDP.
324+
for ns in ${NAMESERVERS}; do
325+
case "${ns}" in *:*) continue ;; esac # IPv6 resolver — handled in the v6 block
326+
${IPT} -t mangle -A "${NOTCP_CHAIN}" -p udp --dport 53 -d "${ns}" -j RETURN
296327
done
297328
# TCP is handled by the nat REDIRECT above — let it pass mangle untouched.
298329
${IPT} -t mangle -A "${NOTCP_CHAIN}" -p tcp -j RETURN
@@ -304,9 +335,9 @@ setup_enforce_redirect() {
304335
echo "enforce-redirect: IPv4 egress capture configured"
305336

306337
# --- IPv6 ---
307-
# Mirror of IPv4. Until v6 cluster CIDRs are wired (CLUSTER_CIDRS6), allow
308-
# loopback + link-local (fe80::/10 unicast, ff02::/16 NDP/MLD multicast) and
309-
# the proxy UID / ztunnel mark; REDIRECT external v6 TCP; DROP other v6 egress.
338+
# Mirror of IPv4: allow loopback + link-local (fe80::/10 unicast, ff02::/16
339+
# NDP/MLD multicast) and the proxy UID / ztunnel mark; leave DNS to any IPv6
340+
# resolv.conf nameservers direct; REDIRECT external v6 TCP; DROP other v6 egress.
310341
if command -v "${IP6T%% *}" >/dev/null 2>&1 && ${IP6T} -t nat -L >/dev/null 2>&1; then
311342
${IP6T} -t nat -N "${REDIR_CHAIN}" 2>/dev/null || true
312343
${IP6T} -t nat -F "${REDIR_CHAIN}"
@@ -316,10 +347,11 @@ setup_enforce_redirect() {
316347
${IP6T} -t nat -A "${REDIR_CHAIN}" -d ::1/128 -j RETURN
317348
${IP6T} -t nat -A "${REDIR_CHAIN}" -d fe80::/10 -j RETURN
318349
${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.
321-
for cidr in $(echo "${CLUSTER_CIDRS6}" | tr ',' ' '); do
322-
[ -n "${cidr}" ] && ${IP6T} -t nat -A "${REDIR_CHAIN}" -p tcp --dport 53 -d "${cidr}" -j RETURN
350+
# DNS-over-TCP (TCP/53) to IPv6 resolvers only — mirror of IPv4; all other
351+
# in-cluster v6 TCP is captured below.
352+
for ns in ${NAMESERVERS}; do
353+
case "${ns}" in *:*) ;; *) continue ;; esac # IPv4 resolver — handled in the v4 block
354+
${IP6T} -t nat -A "${REDIR_CHAIN}" -p tcp --dport 53 -d "${ns}" -j RETURN
323355
done
324356
${IP6T} -t nat -A "${REDIR_CHAIN}" -p tcp -j REDIRECT --to-port "${TRANSPARENT_PORT}"
325357
if ! ${IP6T} -t nat -C OUTPUT -j "${REDIR_CHAIN}" 2>/dev/null; then
@@ -335,8 +367,10 @@ setup_enforce_redirect() {
335367
${IP6T} -t mangle -A "${NOTCP_CHAIN}" -d ::1/128 -j RETURN
336368
${IP6T} -t mangle -A "${NOTCP_CHAIN}" -d fe80::/10 -j RETURN
337369
${IP6T} -t mangle -A "${NOTCP_CHAIN}" -d ff02::/16 -j RETURN
338-
for cidr in $(echo "${CLUSTER_CIDRS6}" | tr ',' ' '); do
339-
[ -n "${cidr}" ] && ${IP6T} -t mangle -A "${NOTCP_CHAIN}" -d "${cidr}" -j RETURN
370+
# DNS-over-UDP (UDP/53) to IPv6 resolvers only — mirror of IPv4.
371+
for ns in ${NAMESERVERS}; do
372+
case "${ns}" in *:*) ;; *) continue ;; esac # IPv4 resolver — handled in the v4 block
373+
${IP6T} -t mangle -A "${NOTCP_CHAIN}" -p udp --dport 53 -d "${ns}" -j RETURN
340374
done
341375
${IP6T} -t mangle -A "${NOTCP_CHAIN}" -p tcp -j RETURN
342376
${IP6T} -t mangle -A "${NOTCP_CHAIN}" -j DROP

0 commit comments

Comments
 (0)