Packets are not held until tunnels are open #4
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
keyop-go/nfq_forwarder#4
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Once the SSH tunnel is initiated, the packet is accepted, but the tunnel will not be open yet, causing the packet to drop.
assigned to @danny
This will be resolved by the changes in https://gitlab.keyop.co.uk/keyop/go/nfq_forwarder/-/merge_requests/5 (from https://gitlab.keyop.co.uk/keyop/go/nfq_forwarder/-/issues/6)
This is not true before the SSH connection is open - packets will hit the yet to open SSH connection and drop on the floor.
This needs further investigation. Ideally the tool should be entirely transparent. Delays (especially in initial packets which SSH tunnels are set up) are acceptable, but no packet should ever be incorrectly dropped, unless a s severe timeout has occurred.
mentioned in issue #31
mentioned in issue #32
mentioned in issue #33
Investigation findings and root cause.
Traced the full first-packet path through
getNfCallback(internal/nfq_forwarder/nfq_forwarder.go) andinternal/sshtunnel. The local tunnel listener and the DNAT rule are both genuinely, synchronously ready (listener bound+listening, DNAT rule actually present in the kernel's nat table) before the current packet's verdict is issued - there's no listener-vs-DNAT timing race in this code.The real problem is structural: the NFQUEUE interception rule lives in
filter/OUTPUT(priority 0), while the DNAT rule is inserted intonat/OUTPUT(priority -100). For a single packet's traversal of theOUTPUThook, Linux netfilter evaluates registered hook priorities in a fixed order - nat runs before filter. Once a packet reaches the NFQUEUE target infilter/OUTPUT,nat/OUTPUThas already run for it (with no match, since the DNAT rule didn't exist yet) and cannot run again for that same packet - a verdict (ACCEPT/REPEAT/QUEUE) can only continue forward or repeat the same hook stage, never rewind to an earlier-priority table.So the very first packet of every new destination is, structurally, unrecoverable once queued - no verdict this code can issue will make that packet benefit from the DNAT rule just added. The connection only survives today because the client's TCP stack retransmits the SYN a moment later, and that genuinely new packet correctly traverses nat -> filter and gets forwarded. Any traffic that doesn't retransmit (short client connect-timeouts, non-retrying application logic) fails outright on the first attempt, every time - not intermittently.
Proposed fix: move the NFQUEUE interception rule from
filter/OUTPUTtomangle/OUTPUT(priority -150, which runs before nat/OUTPUT's -100). Once the tunnel is open and the DNAT rule is inserted, verdict withNfAcceptinstead of requeueing - since we're now upstream ofnat/OUTPUTin the same traversal, continuing via ACCEPT lets the packet actually flow through the just-inserted DNAT rule as intended. This is a small, surgical change (one table name, one verdict constant) rather than a redesign.Caveat: I can't verify netfilter hook-ordering behaviour against a real kernel in this sandbox - I want to confirm this with a packet capture (tcpdump +
iptables -t nat -L OUTPUT -v --line-numberstiming) on a real host as part of testing.Related bugs spun out to their own tickets (found along the way, each independently provable and worth fixing regardless of how this one is resolved): #31 (unconditional drop with no retry + a 5s blanket cooldown that drops unrelated destinations' traffic too), #32 (a malformed packet permanently kills that queue's entire read loop - silently ending all packet processing for every destination on that connection), #33 (the
NfQeueueverdict this codebase relies on for redelivery actually always requeues to queue 0, not the real queue number, so it silently fails regardless of the hook-ordering issue above).Planned order of work (agreed with Danny): this ticket (#4) first - most severe, causes guaranteed packet loss on every new connection; then #32 - narrower trigger but the worst blast radius (permanently kills a whole connection's packet processing); then #31 - real but session-bounded impact; then #33 - likely made moot for the primary path once #4's fix removes reliance on
NfQeueue, but still worth auditing/fixing for any remaining call sites.mentioned in commit
b8717269e9mentioned in merge request !70
Implementation is up for review: !70 (branch
issue4_hold_packets_until_tunnel_open). Moves the NFQUEUE rule fromfilter/OUTPUTtomangle/OUTPUTand switches the success/already-open verdicts fromNfQeueuetoNfAccept, per the root-cause analysis above. Waiting on review before merging.Real-host verification procedure for this fix.
Goal: prove the very first packet of a brand-new connection now actually reaches the tunnel, rather than being silently dropped and only surviving via a kernel-level TCP retransmit ~1s later (the old, broken behaviour). Needs a real host with the fixed binary running (
--debug/--verboserecommended sogetNfCallback's log lines are visible) and root fortcpdump/iptables -L -v.0. Confirm rule placement
1. Start a capture covering both the real egress interface and loopback, for the duration of the test:
2. Pick a destination:port from your config that's genuinely cold - i.e. nfq_forwarder hasn't tunnelled it since it last started (restart the service, or use an entry you know hasn't been touched yet), so there's no already-open tunnel/DNAT rule to short-circuit the test.
3. Note the 'before' packet counters on both rules:
4. From the SAME host (nfq_forwarder only intercepts locally-originated packets, via the OUTPUT chain - the 'client' has to be a process on this same box), time a single connection attempt:
(or
curl --max-time 5 -v telnet://<DEST_IP>:<DEST_PORT>if nc isn't available)Expected with the fix: connects in well under a second - bounded only by the SSH dial + tunnel-open time (typically tens to a couple hundred ms against a healthy jump host). A time of ~1s or more (Linux's default initial SYN retransmit timeout) would indicate the first SYN was still lost and only a retransmit got through - i.e. the bug is still present.
5. Inspect the capture for the SYNs to this destination:
Expect exactly one SYN to the real destination IP (captured before DNAT rewrite, on the egress interface) and its SYN-ACK coming back - no second, retransmitted SYN a second or so later with the same source port/sequence number. A retransmitted SYN here means the first attempt was still lost.
6. Re-check the 'after' packet counters from step 3 - both the
mangleNFQUEUE rule and the newly-insertednatDNAT rule for this destination should show exactly +1 packet, confirming the same packet was counted by both rules in sequence - only possible if it genuinely traversed mangle -> nat in one pass, which is the whole mechanism this fix relies on.7. Sanity-check kernel queue drops (unrelated to this fix specifically, but worth a glance given the queue-overflow risk discussed under #31):
queue_dropped/queue_user_dropped(the last two numeric columns) shouldn't increase across the test.8. Repeat step 2-6 against a second, different cold destination:port to confirm it's not a fluke.
9. Confirm the trade-off doesn't regress steady-state use: immediately open a second new connection to the same now-already-tunnelled destination:port from step 2-6, and confirm it still succeeds quickly, with
getNfCallback's "Mapping already exists for ..." log line appearing (the fast userspace round-trip path) rather than any error/delay.Optional but the strongest possible evidence: check out
mainat the commit just before this fix (or the running production binary, if it predates this change), repeat steps 0-6 once, and confirm you do see the ~1s+ delay and the retransmitted SYN in that case - a clean before/after contrast.mentioned in commit
68a4eaef87Real-host verification results (from
testing.txt) - the fix checks out. Thanks for running this.Rule placement (step 0/3): confirmed - the NFQUEUE rule is in
mangle/OUTPUT (queue 21086, matching10.66.16.0/20:9100), andfilter/OUTPUT is clean (no leftover rule).Packet counters (step 6) - the cleanest evidence in the whole test:
Both show exactly +1 packet for the same connection attempt. That's only possible if the identical first packet was queued in
mangle, accepted, and then actually traversednat/OUTPUT and got DNAT'd there in the same pass - precisely the mechanism this fix is supposed to guarantee. This on its own is solid confirmation the fix works as designed.Packet capture (step 5) - my test methodology had a gap, not a bug: the empty result for a SYN filtered on port 9100 isn't a problem - it's expected, and I should have anticipated it. DNAT rewrites the packet's destination before it's ever actually put on a real interface within that same netfilter traversal, so there's no wire-level capture point where the pre-DNAT form of that first packet (destined to port 9100) ever exists to be captured - by the time it would hit a device for real transmission, its destination is already
127.0.0.1:<tunnel-port>. The three packets that did show up (SYN-ACK/ACK/FIN between10.66.16.2:9100and10.79.24.110:56562onlo) are consistent with a working connection, just reflecting loopback+conntrack's un-DNAT view for the return direction - not something to chase further. The packet-counter comparison above is the reliable way to confirm this on a real host; I'll drop the SYN-capture step from the procedure for next time.Timing - this is what actually nails it down. Your first run (2.999s) understandably looked like a possible problem, but once you controlled for it by restarting
nfq_forwarder(forcing a genuinely cold tunnel) with the jump host's SSSD auth cache already warm, a very clean pattern emerged:main(v1.1.0, pre-fix)~1.0-1.06s is essentially Linux's default initial TCP SYN retransmission timeout - that's the signature of a silently dropped first SYN, only rescued by the kernel's automatic retransmit a second later. That's exactly the bug this ticket describes. The fix consistently completes in ~0.25s - no retransmit-shaped delay at all, roughly 4x faster, and consistent across both fixed-branch runs.
The initial 2.999s reading was a real but unrelated confound: an SSSD lookup delay on the jump host's own PAM auth after a period of inactivity, not anything in this code - it's why the clean comparison above always restarts
nfq_forwarder(forcing a fresh tunnel dial) rather than the jump host's own auth state.Conclusion: both lines of evidence (packet counters and timing) independently confirm the fix resolves the reported bug. I've recorded this verification in
CLAUDE.md's Known Issues checklist (commit68a4eaeon this branch) so it's not re-litigated later, and noted the tcpdump methodology gap there too. Ready for your final review/merge whenever you're happy.mentioned in commit
a6eaee7e01Merged via !70 (merge commit
a6eaee7) and released as v1.1.1 (a patch bump - a bug fix, unlike v1.1.0's feature addition). Confirmed on a real host via packet-counter and timing evidence (see above).mentioned in merge request !71
mentioned in commit
6aa6c1b88dmentioned in merge request !72
mentioned in commit
42cd04857bmentioned in merge request !73