Skip to content

exit-node: fix RST/EPERM spam and yandex transport handshake race - #54

Open
D13tre33 wants to merge 2 commits into
p1neappleXpress:mainfrom
D13tre33:fix/exit-node-rst-eperm-and-ws-handshake-race
Open

exit-node: fix RST/EPERM spam and yandex transport handshake race#54
D13tre33 wants to merge 2 commits into
p1neappleXpress:mainfrom
D13tre33:fix/exit-node-rst-eperm-and-ws-handshake-race

Conversation

@D13tre33

Copy link
Copy Markdown

Summary

Two correctness bugs in the exit-node path, found while running a
local exit node for a while and watching the logs closely.

1. [RAW-NIC2] Sendto failed: operation not permitted spam (tunnel/rawsocket_linux.go, docker/entrypoint.sh)

The exit node's TCP stack runs entirely in userspace (gVisor), so the
kernel has no socket for these connections and answers with its own
spurious RSTs - entrypoint.sh's OUTPUT DROP rule exists to swallow
exactly those. But it matched all outbound RSTs, including the ones
WritePackets legitimately sends via the raw socket to close real
connections. A local netfilter DROP on a raw-socket send surfaces back
to the caller as EPERM instead of silently dropping - that's the
source of the repeated log line. Under real traffic this fired well
over a thousand times in 5 minutes.

Fix: mark every packet the raw socket sends with SO_MARK=100, and
add a RETURN rule ahead of the DROP rule that exempts that mark.
Only truly kernel-originated RSTs (never marked, since they don't go
through our socket) still get dropped.

Verified live: EPERM count went from ~1166/5min to 0/5min at
equivalent traffic volume; iptables counters confirm the split
(RETURN rule catching our marked RSTs, DROP rule still catching the
kernel's unmarked ones).

2. yandex transport: handshake race causes near-instant close 1005 (transport/yandex/yandex.go)

connectToDoc() sent the socket.io 40{token} connect packet and the
42[...] auth event immediately after the WebSocket upgrade, without
ever reading anything from the server first. The server sends its own
engine.io OPEN packet (0{"sid":...}) first per protocol - writing
before reading that raced it, and the server closed the connection
with websocket: close 1005 (no status) within ~50-100ms almost every
time. This meant a working session only ever happened by luck, when
the timing happened to land in the right order on its own.

Confirmed by temporarily logging otherwise-unhandled incoming
messages: the OPEN packet consistently arrived right before the close.

Fix: block on one ReadMessage() for the OPEN packet before sending
40/42. Handshakes now reliably complete (auth ack, license,
waitAuth, documentOpen all arrive in order) and real tunnel traffic
flows afterward.

Test plan

  • CGO_ENABLED=0 go build succeeds (built via the repo's own
    Dockerfile)
  • Deployed as the exit-node compose profile against a real
    Yandex Disk share link; confirmed via docker logs +
    iptables -L OUTPUT -n -v that RSTs split correctly between the
    RETURN and DROP rules under real traffic
  • Confirmed the yandex transport now reaches documentOpen and
    carries real tunnel traffic, where before it essentially never
    did deterministically

Not touched: a separate, deeper issue where even a successful yandex
session eventually gets an explicit server-side disconnectReason
(code 4007, "drop") after roughly a minute - that looks like Yandex's
own collab-session participant limits, not a client bug, and is out of
scope here.

The exit node's TCP stack lives entirely in userspace (gVisor), so the
kernel has no socket for these connections and fires its own spurious
RSTs at them - entrypoint.sh's OUTPUT DROP rule exists specifically to
swallow those. But that rule matched *all* outbound RSTs, including
the ones WritePackets legitimately sends via the raw socket to close
real connections on the application's behalf. A local netfilter DROP
on a raw-socket send surfaces back to the caller as EPERM rather than
silently dropping, which is what produced the recurring
"[RAW-NIC2] Sendto failed: operation not permitted" errors - observed
firing over a thousand times in 5 minutes under real traffic.

Fix: tag every packet sent through the raw socket with SO_MARK=100
(rawSocketMark), and exempt that mark from the DROP rule in
entrypoint.sh via a RETURN rule that runs first. Now only truly
kernel-originated RSTs (unmarked, since they never go through our
socket) get dropped; our own legitimate RSTs go out normally.

Verified on a live exit node: EPERM count went from ~1166/5min to
0/5min under equivalent traffic, with iptables counters confirming
the split (RETURN rule matching our marked RSTs, DROP rule still
catching the kernel's unmarked ones).
connectToDoc() was writing the socket.io "40{token}" connect packet
and the "42[...]" auth event immediately after the WebSocket upgrade
completed, without ever reading anything from the server first. Per
the engine.io/socket.io protocol the server sends its own OPEN packet
("0{"sid":...,"pingInterval":...}") first, and this raced it: writing
our packets before reading that OPEN packet got the connection closed
by the server with "websocket: close 1005 (no status)" within roughly
50-100ms of connecting, almost every time.

Confirmed by logging otherwise-unhandled incoming messages: the
server's "0{...}" packet was consistently the very next thing to
arrive, immediately followed by the close - i.e. the server appears to
treat receiving our packets before its own handshake packet as a
protocol violation and drops the connection.

Fix: block on a single ReadMessage() for that OPEN packet before
sending "40"/"42". With this, handshakes now reliably complete (auth
ack, license, waitAuth, documentOpen all arrive in order) and real
tunnel traffic flows - previously this only happened by chance, when
the write and the server's OPEN packet happened to land in the right
order on their own.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant