Cilium Ingress Dropped as Martian Source
The symptom
Single-node cluster at home. Cilium as the CNI, doing ingress and L2 load
balancing. The goal was mundane: https://api.llama.internal/ should hit a
llamacpp pod through Cilium’s ingress.
It didn’t. From any machine on my LAN:
$ curl -k --max-time 15 https://api.llama.internal/
curl: (28) Connection timed out after 15004 milliseconds
The connection never even got to TLS. But here’s the part that sent me down a three-hour rabbit hole: from the node itself, it worked fine.
# on the node
$ curl -k https://api.llama.internal/health
{"status":"ok"}
Ingress “up” from the node, dead from everywhere else. That asymmetry is the whole story, and it took a while to understand why.
Following the packet
The VIP (192.168.1.14) answered ARP with the node’s MAC, so L2 was fine. A
tcpdump on the node’s uplink showed the SYNs arriving, and no SYN-ACK, ever:
IP 192.168.1.158.50015 > 192.168.1.14.443: Flags [S] ...
IP 192.168.1.158.50015 > 192.168.1.14.443: Flags [S] ... # retransmit, forever
Cilium’s ingress runs an Envoy proxy on the host, bound to 127.0.0.1:17564.
North-south traffic to the ingress VIP is supposed to be handed to Envoy via the
kernel’s TPROXY facility: eBPF tags the packet with a proxy mark, an iptables
TPROXY rule in mangle PREROUTING redirects it to the loopback proxy, and a
policy-routing rule delivers it locally.
Checking the TPROXY rule counter, it was incrementing. The packets were being matched and redirected. But Envoy’s admin socket told the real story. Its downstream connection counter moved for localhost curls and stayed flat for LAN curls:
listener.127.0.0.1_17564.downstream_cx_total: 9 # before 5 LAN curls
listener.127.0.0.1_17564.downstream_cx_total: 9 # after 5 LAN curls (!)
listener.127.0.0.1_17564.downstream_cx_total: 12 # after 3 localhost curls
So TPROXY fired, but the packet never reached Envoy’s socket. It was dying between the TPROXY redirect and local delivery.
nft monitor trace pinned the exact spot. The SYN sailed through
raw → mangle → nat PREROUTING, the TPROXY target set the mark to 0x200
and accepted it… and then the trace simply stopped. No filter INPUT, no
FORWARD. The packet vanished at the input-routing decision, a FIB step, not a
netfilter hook. That’s why nothing logged it.
Not Cilium’s fault
At this point I did the single most useful thing in the whole investigation: I
reproduced it without Cilium at all. A plain Python listener with
IP_TRANSPARENT on 127.0.0.1:19999, and one hand-written TPROXY rule pointing a
spare port at it:
iptables -t mangle -I ... -p tcp --dport 9999 \
-j TPROXY --on-port 19999 --on-ip 127.0.0.1 --tproxy-mark 0x200/0xffffffff
Same failure. TPROXY counter climbed, listener never saw a connection, curl timed out. This wasn’t Cilium, wasn’t Envoy. It was the host’s kernel datapath. That reframed everything and let me iterate fast.
The usual suspects, all innocent
TPROXY-to-loopback problems have two famous causes, and neither was it:
rp_filter(strict reverse-path filtering), the classic cause ofmartian sourcedrops in Cilium. I set it to0on every interface. Still failed.route_localnet, needed when delivering to127.0.0.0/8. Set it to1. Still failed.
I also flipped Cilium’s bpf.hostLegacyRouting back and forth (BPF host routing
vs. the legacy stack) on the theory that the datapath mode mattered. It didn’t.
The drop was identical either way.
The smoking gun
Enable martian logging and the kernel finally admits what it’s doing:
$ sysctl net.ipv4.conf.all.log_martians=1
$ dmesg
IPv4: martian source 192.168.1.102 from 192.168.1.158, on dev enp18s0
A martian source, even with rp_filter=0. That’s the clue that cracks it,
because rp_filter is not the only source-validation the kernel does.
Here’s the mechanism. TPROXY rewrites the packet’s mark to 0x200. Cilium’s
policy-routing rule steers 0x200 traffic to a dedicated table:
ip rule: 9: from all fwmark 0x200/0xf00 lookup 2004
table 2004: local default dev lo
local default dev lo means deliver everything locally via loopback. That’s the
standard TPROXY pattern. But when the kernel validates the packet’s source
address under that same mark, the lookup resolves through table 2004 too, so the
source 192.168.1.158 comes back as RTN_LOCAL.
And the kernel has a rule: a packet whose source address resolves to a local
route is a martian unless net.ipv4.conf.<dev>.accept_local = 1. From the
kernel ip-sysctl docs:
accept_local — Accept packets with local source addresses. […] default FALSE
It was 0. So every external SYN, once TPROXY assigned it the 0x200 mark, had a
source that “looked local” and got dropped as a martian.
The fix
# /etc/sysctl.d/99-cilium-tproxy-accept-local.conf
net.ipv4.conf.all.accept_local = 1
net.ipv4.conf.default.accept_local = 1
$ curl -k https://api.llama.internal/health
{"status":"ok"} # HTTP 200, ~20ms
One setting. The full chain came alive immediately: LAN client → VIP → Envoy
(TLS terminates) → llamacpp pod. The martian logs stopped. Verified it
was the sole necessary change by resetting rp_filter and route_localnet back
to their defaults; ingress kept working.
Why it fooled me for so long
- The node worked. Cilium’s socket-level load balancing rewrites a local
connect()straight to127.0.0.1:17564, so on-node traffic never touches the TPROXY/input-routing path. “Works from the node” was a false signal of health. - The drop is invisible. It’s a FIB martian drop: no Hubble event, no
cilium monitorline, norp_filtercounter, noInNoRoutes. Withoutlog_martians, there is nothing to see. rp_filter=0didn’t fix it. Every guide points you atrp_filterfor martian drops. This one isaccept_local, a different and much less discussed knob, triggered by the source resolving local through the proxy’s own policy-routing table.
Is this documented anywhere?
cilium#46260 independently
arrived at the same root cause: TPROXY’d L7-proxy packets dropped as martian
source in ip_route_input_slow, with the same diagnosis that Cilium sets
accept_local=1 only on its own devices, not on the native L2-announced ingress
interface. The issue is still open as of this writing.
The building blocks each exist in isolation: the kernel accept_local
definition, the TPROXY + policy-routing pattern, and the well-known rp_filter-loose
fix for a different Cilium martian case
(cilium#13130). There are also
several “ingress works from the node but not outside” reports
(cilium#42275,
cilium#25021) that never reach
this diagnosis.
If you hit “Cilium ingress times out from the LAN but works from the node,” this is worth checking:
sysctl net.ipv4.conf.all.log_martians=1
dmesg -w # curl from a LAN client; watch for "martian source"
# if you see it:
sysctl net.ipv4.conf.all.accept_local=1
Environment
Cilium v1.19.6 (tunnel/VXLAN, kube-proxy replacement, external Envoy DaemonSet on
hostNetwork), kernel 6.19.13, Debian 13, single node. The reverse-path
validation resolving the source through the marked policy table is the kind of
behavior that can vary across kernels, so your mileage, and whether you ever see
this at all, may depend on the kernel you’re running.