Making ssh -D use a specific outbound interface
Context
.----------------.
.-------------------. .--| devices |
.--------. .---'--. .---'---. / '----------------'
| client |-[SSH]->| eth0 | server | wlan0 |-<
'--------' '---.--' '---.---' \ .----------------.
172.16.100.101 '-------------------' '--| captive portal |
eth0: 172.16.200.201 '----------------'
wlan0: 10.10.10.10
I needed to analyze a captive portal locally, while the actual WiFi connection
lived on server.
Problem: ssh -D X user@server gives me a SOCKS endpoint on the client, but it
doesn’t control which interface server uses for outbound connections. On this
specific setup, outbound flows follow the host routing policy (usually eth0),
so the portal never appears.
Why not just curl from the server, get the portal IP, then add routes?
You can, but it’s annoying:
- Captive portals are often not a single fixed IP (redirect chains, CDNs, multiple domains, etc….).
- If you roam across many WiFi networks, you don’t want a “per-network discover IPs, add routes, clean up routes” workflow.
- A wrong route can leak traffic out eth0. This is not really a problem, but why bother even having to deal with this :).
So instead of chasing portal endpoints, I forced all SOCKS egress to use wlan0, regardless of what the portal does.
My overcomplicated fix: policy routing only for the SOCKS user (on server)
1. Dedicated user for the tunnel
root@server$ useradd -m -s /bin/bash socks2. Create a routing table (100) with default via wlan0
root@server$ WLAN_GW=$(ip route show default dev wlan0 | awk '{print $3}')
root@server$ WLAN_NET=$(ip -o -f inet route show dev wlan0 scope link | awk '{print $1}')
root@server$ ip route flush table 100
root@server$ ip route add "$WLAN_NET" dev wlan0 scope link table 100
root@server$ ip route add default via "$WLAN_GW" dev wlan0 table 1003. Mark traffic owned by user socks
root@server$ iptables -t mangle -A OUTPUT -m owner --uid-owner socks -j MARK --set-mark 0x14. Route marked packets using table 100
root@server$ ip rule add fwmark 0x1 lookup 100 priority 1005. Start SOCKS from my client to server
user@client$ ssh -N -D 127.0.0.1:7890 [email protected]6. tcpdump check
From the client:
user@client$ curl -ki --socks5-hostname 127.0.0.1:7890 https://blog.kippel.org
HTTP/1.1 302 Captive Portal
Server:
Date: Wed, 21 Dec 2022 10:04:43 GMT
Cache-Control: no-cache,no-store,must-revalidate,post-check=0,pre-check=0
Location: https://portal.example.com:443/guest/arubalogin.php?cmd=login&...&ip=172.16.200.201&url=https%3A%2F%2Fblog.kippel.org%2F
Content-Type: text/html; charset=utf-8
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Strict-Transport-Security: max-age=604800
Connection: close
<HTML>
<HEAD><TITLE>302 Captive Portal</TITLE></HEAD>
<BODY BGCOLOR="#cc9999" TEXT="#000000" LINK="#2020ff" VLINK="#4040cc">
<H4>302 Captive Portal</H4>
<ADDRESS><A HREF="http://www.arubanetworks.com"></A></ADDRESS>
</BODY>
</HTML>From the server:
root@server$ tcpdump -i wlan0 -n
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on wlan0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
...
15:01:42.855746 IP 10.10.10.10.45301 > 10.10.10.1.53: 18557+ A? blog.kippel.org. (29)
15:01:42.870100 IP 10.10.10.1.53 > 10.10.10.10.45301: 18557 1/0/0 A 104.21.34.11 (45)
15:01:42.889385 IP 10.10.10.10.60302 > 104.21.34.11.443: Flags [S], seq 712479030, win 64240, options [mss 1460,sackOK,TS val 1789227809 ecr 0,nop,wscale 10], length 0
15:01:42.890882 IP 104.21.34.11.443 > 10.10.10.10.60302: Flags [S.], seq 2006562653, ack 712479031, win 24960, options [mss 1260,sackOK,TS val 1556975851 ecr 1789227809,nop,wscale 7], length 0
15:01:42.890934 IP 10.10.10.10.60302 > 104.21.34.11.443: Flags [.], ack 1, win 63, options [nop,nop,TS val 1789227811 ecr 1556975851], length 0
...7. Cleanup
root@server$ ip rule del fwmark 0x1 lookup 100
root@server$ iptables -t mangle -D OUTPUT -m owner --uid-owner socks -j MARK --set-mark 0x1
root@server$ ip route flush table 100
root@server$ userdel -r socksBonus: auto-rebuild table 100 when WiFi changes
When connecting to a new network, ip rule and the packet marking stays the same, only table 100 needs to be rebuilt.
root@server$ cat /usr/local/sbin/rebuild-table100.sh
#!/usr/bin/env bash
set -euo pipefail
IFACE="$1"
TABLE=100
# Horrible way to wait for new default gateway and subnrt
GW=""
NET=""
for _ in {1..30}; do
GW=$(ip route show default dev "$IFACE" | awk '{print $3}' || true)
NET=$(ip -o -f inet route show dev "$IFACE" scope link | awk '{print $1}' || true)
[[ -n "$GW" && -n "$NET" ]] && break
sleep 1
done
[[ -n "$GW" && -n "$NET" ]] || exit 0
ip route flush table "$TABLE"
ip route add "$NET" dev "$IFACE" scope link table "$TABLE"
ip route add default via "$GW" dev "$IFACE" table "$TABLE"root@server$ cat /etc/NetworkManager/dispatcher.d/90-table100
#!/bin/sh
IFACE="$1"
ACTION="$2"
case "$IFACE:$ACTION" in
wlan0:up|wlan0:dhcp4-change|wlan0:dhcp6-change)
/usr/local/sbin/rebuild-table100.sh wlan0
;;
wlan0:down)
ip route flush table 100
;;
esac