GHOSTPORT
← Back to Dev Log

Why your PS5's TCP couldn't come home

An nft FORWARD-hook L2-timing gotcha — and the one-line fix that took us a day of staring at conntrack to find.
May 13, 2026 • GhostPort Technologies • Engineering

GhostPort has a feature called route_via=isp. You tag a device — usually a game console or smart TV — and instead of being tunneled through our infrastructure, that one device exits via your residential IP. Streaming services that geo-fence to your home address keep working. Game matchmaking gets your real ASN. Everything else on the network stays behind the tunnel.

It shipped. UDP worked. PSN sign-in didn't.

This is the story of a netfilter bug that lived in our killswitch logic for longer than we'd like to admit, and an nftables packet-flow detail we had quietly wrong.

The symptom

A customer's PS5 was on a trusted-device exit. They could load the dashboard. They could ping the gateway. STUN-style UDP punched through to PSN voice fine. But every TCP connection sat in SYN_RECV forever. Game matchmaking timed out. Store wouldn't load. Sign-in spun.

From conntrack -L on the router:

# 31 stuck flows, all SYN_RECV, all to PSN endpoints
tcp  6 60 SYN_RECV src=192.168.1.42 dst=23.45.x.x sport=51234 dport=443 \
  src=23.45.x.x dst=<ISP-WAN-IP> sport=443 dport=51234 [UNREPLIED] mark=0

The conntrack table knew the connections existed. The kernel was tracking them. But they never advanced past SYN_RECV. Which meant: the SYN was leaving the PS5, getting to PSN, PSN was returning a SYN-ACK… and that SYN-ACK was vanishing somewhere between arriving on our WAN interface and leaving toward the PS5.

The debug chain

The diagnostic walk took a few hours and looked like this:

The rule that didn't match

Here's the rule (paraphrased — full version in the commit linked below):

insert rule inet filter killswitch \
  oifname "wlan0" ether daddr AA:BB:CC:DD:EE:FF \
  ct state established,related counter accept

The intent: "on the LAN egress interface, if the L2 destination is the PS5's MAC and conntrack says this is a return packet, let it through." Reads fine. Looks fine. Counters say it never matches a single packet.

The mistake is the assumption that ether daddr in the FORWARD hook refers to the L2 destination the frame will be transmitted with. It does not. Not for routed traffic. Not on a router doing IP forwarding.

What's actually in the skb at FORWARD time

For an IPv4 packet being forwarded between two L3 interfaces, the kernel's path is roughly:

The next-hop MAC — the PS5's MAC, in our case — gets stamped onto the frame after the FORWARD chain has already run. At FORWARD time, what you read from ether daddr is whatever was in the L2 header when the frame arrived: the router's own ingress MAC.

So our rule said "if the destination MAC is the PS5's MAC" and the kernel said "the destination MAC is the router's WAN-side MAC, which I'll rewrite later." The rule could not have matched a return packet if it tried.

And our killswitch chain had a default drop policy. So every SYN-ACK fell off the end and into the bit bucket. UDP looked like it worked because the outbound flow primed conntrack and a related-state rule earlier in the chain caught the response before the broken rule got a chance to not-match. TCP's three-way handshake hits the killswitch differently and depended entirely on the bad rule.

One sentence summary: in the FORWARD chain on a routing host, ether daddr reads the ingress L2 header, not the egress one. If you need to match the device the packet is going to, you need something that lives at L3 or above.

(Bridged forwarding — ebtables, or routed forwarding when br_netfilter is loaded for compatibility — behaves differently because no L2 rewrite is happening, so ether daddr matches the way naive intuition suggests. That special case is where this misconception comes from, we suspect.)

The fix: conntrack mark

The right primitive for "I want the return half of this flow to be identifiable" is conntrack mark. ct mark is stored on the connection-tracking entry, not the packet, and it's preserved across both directions of a flow.

The new shape, distilled:

# 1. On outbound, mark the flow at the conntrack layer.
chain mangle_out {
  iifname "wlan0" ether saddr AA:BB:CC:DD:EE:FF \
    meta mark set 0x100 ct mark set 0x100
}

# 2. On return, match ct mark, not ether daddr.
chain killswitch {
  oifname "wlan0" ct mark 0x100 \
    ct state established,related counter accept
}

The outbound rule still uses ether saddr — and that one is fine, because for inbound packets, the L2 source is the device we care about; it's the egress side where MAC resolution hasn't happened yet that bites you. The mark goes onto the conntrack tuple, survives the return trip, and the return rule matches on something that actually exists at the right moment.

Live evidence post-fix

Within seconds of regenerating and applying the rules on the same PS5:

tcp  6 432000 ESTABLISHED src=192.168.1.42 dst=23.45.x.x sport=51234 dport=443 \
  src=23.45.x.x dst=<ISP-WAN-IP> sport=443 dport=51234 \
  [ASSURED] mark=256

mark=256 is 0x100 in decimal — conntrack confirming the flow carried the mark across both directions. ASSURED means traffic has flowed in both directions. The 31 stuck SYN_RECV entries cleared. PSN signed in. Game matchmaking connected. Done.

The part of the story we owe you

We didn't go straight from "TCP broken" to "ct mark fix" cleanly. Before we found the real cause, we shipped an extra watchdog — a process that would notice when a route_via=isp device's flows looked stuck and try to "heal" them by flushing conntrack state for that source IP.

It made the symptom worse. Now the device couldn't even get a UDP handshake to stay alive long enough to be useful, because the watchdog kept blowing away half-formed entries thinking it was helping. We reverted that watchdog before we shipped the real fix. The dead-end is in the git history.

We mention it because every clean debug story has at least one of those, and pretending otherwise is dishonest. The reason we shipped the watchdog first is that we hadn't yet figured out the root cause — so we reached for "automate a recovery" instead of "understand why." That's almost always backwards.

Read the actual fix

COMMIT • 050eecc • PUBLIC REPO
trusted-devices: fix TCP return path for route_via=isp
The full commit message describes the bug and fix in our own words; the diff is in scripts/gp-allow, the rule generator that emits the killswitch entries. One file. Diff is small. Inspect away.

This is one of the reasons we made GhostPort Phantom OS source-available under Elastic License v2 — so when we tell a debug story, you can verify it. The commit is real, the diff is small, and you can trace it back to the symptom we described above.

What we'd tell our past self

References

Source-available, auditable, no proprietary black box.

Read the code
Every line. Every fix. Every dead end.
🎨
ACCENT COLOR
A+
TEXT SIZE