| title | SSH Reverse Tunnel — Quick Setup Guide |
|---|---|
| geometry | a4paper,top=2cm,left=1cm,right=1cm,bottom=1.5cm |
| gist | https://gist.github.com/gwpl/44a114bcd3c33b2b79dc3815a02812ee |
Control remote computers (behind NAT) from your master machine, where remote computers initiate the connection.
All variables follow the WHAT_on_WHERE naming pattern — the suffix tells you which machine the value lives on or describes.
| Variable | Description | Example |
|---|---|---|
| master | Your trusted controlling machine | your main workstation |
| remote | Machine(s) you want to control (behind NAT) | laptops, servers |
| vpn | Relay server with public IP (Appendices only) | a VPS or cloud instance |
# -- master (your controlling machine) --
MASTER_IP=192.168.7.123 # master's IP, reachable by remotes
SSHD_PORT_on_MASTER=3333 # sshd listening port on master
USER_on_MASTER=alice # your username on master
# -- remote (machines you control) --
USER_on_REMOTE=remoteuser # username on the remote machine
# -- vpn (relay server, Appendices only) --
VPN_IP=203.0.113.50 # VPN's public IP
SSHD_PORT_on_VPN=22 # sshd listening port on VPN
USER_on_VPN=tunnel # username on VPN| Port | Lives on | Tunnels to | Used in |
|---|---|---|---|
| 2220 | master or VPN | Remote A → localhost:22 | Direct, Appendix A, B |
| 2221 | master or VPN | Remote B → localhost:22 | Appendix B |
| 2222 | master or VPN | Remote C → localhost:22 | Appendix B |
| ... | ... | ... | up to 2229 |
| 2210 | VPN | master → SSHD_PORT_on_MASTER | Appendix A only |
Remote (behind NAT) Master (reachable)
| |
|--- ssh -R 2220:localhost:22 master -N ---------> | (tunnel established)
| |
|<--- ssh -p 2220 USER_on_REMOTE@localhost -------| (you control remote)
There are two independent SSH sessions — session 1 is the tunnel (raw TCP pipe), session 2 is your actual shell connection that travels through it. The remote's sshd handles session 2 normally — it doesn't know it arrived via a tunnel. That's why the remote must have sshd running.
ssh -R P1:L:P2 S
- P1 — port to open on S (the server you're connecting to)
- L:P2 — where the caller forwards incoming connections to (resolved from the caller's side)
- S — the server to connect to
L can be localhost, an IP, or a hostname — anything the caller can resolve and reach. Examples:
ssh -R 2220:localhost:22 S— tunnel to caller's own sshdssh -R 2220:10.0.0.50:22 S— tunnel to a third machine on caller's LANssh -R 2220:example.com:80 S— tunnel to a website (raw TCP, not an HTTP proxy)
sudo systemctl enable --now sshssh-keygen -t ed25519
cat ~/.ssh/id_ed25519.pub # copy thisPaste the remote's public key with this prefix (single line):
restrict,port-forwarding,permitlisten="localhost:2220",permitopen="255.255.255.255:9",command="/bin/false" ssh-ed25519 AAAA...key... USER_on_REMOTE@remote-host
What each option does:
restrict— disables everything (shell, pty, agent, X11, forwarding)port-forwarding— re-enables forwarding (needed for the tunnel)permitlisten="localhost:2220"— only allows reverse tunnel on port 2220permitopen="255.255.255.255:9"— blocks local forwarding (-L)command="/bin/false"— no shell execution possible
Optional: add from="SUBNET/MASK" to restrict source IPs.
grep -i gatewayports /etc/ssh/sshd_configIf missing or set to yes, add GatewayPorts no and restart sshd.
Copy master user's public keys to the remote's ~/.ssh/authorized_keys:
cat ~/.ssh/*.pub # on master, copy these to the remotessh -R 2220:localhost:22 ${USER_on_MASTER}@${MASTER_IP} -p ${SSHD_PORT_on_MASTER} \
-o ServerAliveInterval=120 -Nssh -p 2220 ${USER_on_REMOTE}@localhostHost remote-a
HostName localhost
Port 2220
User USER_on_REMOTE
Then just: ssh remote-a
Install autossh on the remote for auto-reconnection:
sudo apt install autossh
autossh -M 0 -fN -R 2220:localhost:22 \
${USER_on_MASTER}@${MASTER_IP} -p ${SSHD_PORT_on_MASTER} \
-o ServerAliveInterval=120- Permission denied — check that the correct key fingerprint is in master's authorized_keys:
ssh-keygen -l -f ~/.ssh/authorized_keys - Tunnel not working —
permitlistenrequiresport-forwardingto also be set (it's silently ignored otherwise) - Connection drops — increase
ServerAliveIntervalor useautossh - Debug — add
-vvvto the ssh command on the remote to see what's happening
These two authorized_keys options control opposite directions of SSH port forwarding. Since restrict,port-forwarding re-enables both -R and -L (there is no per-direction flag in OpenSSH), you must use these options to lock down whichever direction is not needed.
Whitelists which [host:]port the key holder can ask the server to listen on.
When a client runs ssh -R 2220:localhost:22 server, it asks the server to start listening on port 2220. permitlisten restricts which listen addresses are allowed.
permitlisten="localhost:2220"— client can only-Ron port 2220permitlistennot specified +port-forwardingenabled → client can-Ron any port (security gap!)
Blocking all -R: Use permitlisten="255.255.255.255:9" — an unreachable address that parses as valid but never matches a real request.
Whitelists which destinations the server will open connections to on behalf of the client. This also controls direct-tcpip channels used by ProxyJump (-J).
permitopen="localhost:2220"— server can only connect tolocalhost:2220(enables ProxyJump to that tunnel port)permitopen="255.255.255.255:9"— effectively blocks all-Land ProxyJump (unreachable destination)
| Syntax | sshd_config |
authorized_keys |
|---|---|---|
PermitListen none |
Valid (blocks all -R) |
INVALID — parse error, key rejected |
permitlisten="" |
N/A | INVALID — parse error, key rejected |
permitlisten="localhost:0" |
N/A | INVALID — port 0 fails validation |
permitlisten="255.255.255.255:9" |
N/A | Valid — effective block |
This asymmetry exists because sshd_config parsing (servconf.c) explicitly handles the none keyword, but authorized_keys parsing (auth-options.c) validates every value strictly as host:port via a2port(). Source: OpenSSH 10.2p1 auth-options.c:handle_permit().
restrict → all forwarding OFF
restrict,port-forwarding → both -L and -R ON (no restrictions!)
restrict,port-forwarding,permitlisten= → -R scoped, -L unrestricted
restrict,port-forwarding,permitopen= → -L scoped, -R unrestricted
restrict,port-forwarding,permitlisten=X,permitopen=Y → both scoped (secure)
Rule of thumb: when using restrict,port-forwarding, always specify both permitlisten and permitopen to lock down the unused direction with the 255.255.255.255:9 trick.
| Key belongs to | Needs -R |
Needs -L/ProxyJump |
permitlisten |
permitopen |
|---|---|---|---|---|
| Remote on master (direct) | Yes (tunnel) | No | "localhost:2220" |
"255.255.255.255:9" |
| Master on VPN (Appendix A) | Yes (expose sshd) | No | "localhost:2210" |
"255.255.255.255:9" |
| Remote on VPN (Appendix A) | No | Yes (ProxyJump) | "255.255.255.255:9" |
"localhost:2210" |
| Remote on VPN (Appendix B) | Yes (tunnel) | No | "localhost:222X" |
"255.255.255.255:9" |
| Master on VPN (Appendix B) | No | Yes (ProxyJump) | "255.255.255.255:9" |
"localhost:2220",... |
When both master and the remote are behind NAT, use a VPN server (with a public IP) as a relay. Two chained reverse tunnels route through it.
Neither master nor the remote has a reachable IP. A VPN server with a public IP acts as the meeting point.
Remote VPN Server Master
(behind NAT) (public IP) (behind NAT)
? <--- ? <--- ?
no public IP relay point no public IP
Remote VPN Server Master
(behind NAT) (public IP) (behind NAT)
| | |
| |<--- ssh -R 2210 ---------| (1) master exposes sshd on VPN
| | :localhost:SSHD_PORT |
| | _on_MASTER |
| | |
|--- ssh -J VPN ------->|--- localhost:2210 ------->| (2) remote reaches master
| -R 2220:localhost:22 | (through tunnel) | via ProxyJump, sets up
| | | reverse tunnel on master
| | |
|<========= ssh -p 2220 USER_on_REMOTE@localhost ==| (3) master controls remote
| (travels back through both tunnels) |
sequenceDiagram
participant M as Master<br/>(behind NAT)
participant V as VPN Server<br/>(public IP)
participant R as Remote<br/>(behind NAT)
Note over M,V: Step 1: Master exposes sshd on VPN
M->>V: ssh -R 2210:localhost:SSHD_PORT_on_MASTER
Note over V: VPN now listens on<br/>localhost:2210 → master sshd
Note over R,M: Step 2: Remote reaches master via ProxyJump
R->>V: ssh -J USER_on_VPN@VPN (ProxyJump)
V->>M: relay via localhost:2210 (tunnel from Step 1)
Note over M: -R 2220:localhost:22 negotiated<br/>master now listens on localhost:2220
Note over M,R: Step 3: Master controls remote
M->>M: ssh -p 2220 USER_on_REMOTE@localhost
M-->>V: through Step 2 tunnel
V-->>R: through ProxyJump relay
Note over R: lands on remote's sshd (port 22)
graph LR
subgraph "Behind NAT"
M["Master<br/>sshd :SSHD_PORT_on_MASTER"]
end
subgraph "Public Internet"
V["VPN Server<br/>sshd :SSHD_PORT_on_VPN<br/>localhost:2210 (tunnel to master)"]
end
subgraph "Behind NAT "
R["Remote<br/>sshd :22"]
end
M -->|"1. ssh -R 2210:localhost:SSHD_PORT_on_MASTER"| V
R -->|"2. ssh -J VPN -R 2220:localhost:22"| V
V -->|"2. relayed to localhost:2210"| M
M -.->|"3. ssh -p 2220 USER_on_REMOTE@localhost"| R
style M fill:#4a9eff,color:#fff
style V fill:#ff9f43,color:#fff
style R fill:#26de81,color:#fff
| Session | Initiated by | Path | Purpose |
|---|---|---|---|
| 1 | Master | master → VPN | Reverse tunnel exposing master's sshd on VPN:2210 |
| 2 | Remote | remote → VPN → master | ProxyJump + reverse tunnel opening master:2220 |
| 3 | Master | master → localhost:2220 → tunnel → remote:22 | Your actual shell on the remote |
master$ ssh -R 2210:localhost:${SSHD_PORT_on_MASTER} \
${USER_on_VPN}@${VPN_IP} -p ${SSHD_PORT_on_VPN} \
-o ServerAliveInterval=120 -NThis opens localhost:2210 on VPN, tunneling back to master's sshd. With GatewayPorts no on VPN (recommended), port 2210 is only reachable from VPN's localhost — not from the internet.
remote$ ssh -J ${USER_on_VPN}@${VPN_IP}:${SSHD_PORT_on_VPN} \
-R 2220:localhost:22 \
${USER_on_MASTER}@localhost -p 2210 \
-o ServerAliveInterval=120 -NWhat happens:
- SSH connects to VPN (the jump host) — remote authenticates to VPN
- From VPN, it connects to
localhost:2210— which tunnels to master's sshd - Remote authenticates to master's sshd
-R 2220:localhost:22is negotiated with master's sshd — port 2220 opens on master
master$ ssh -p 2220 ${USER_on_REMOTE}@localhostOr: ssh remote-a (if configured in ~/.ssh/config)
restrict,port-forwarding,permitlisten="localhost:2210",permitopen="255.255.255.255:9",command="/bin/false" ssh-ed25519 AAAA...master-key...
restrict,port-forwarding,permitopen="localhost:2210",permitlisten="255.255.255.255:9",command="/bin/false" ssh-ed25519 AAAA...remote-key...
The remote needs permitopen="localhost:2210" so the ProxyJump can connect through to master's tunnel. permitlisten="255.255.255.255:9" blocks any -R attempts — the remote should not open ports on VPN.
restrict,port-forwarding,permitlisten="localhost:2220",permitopen="255.255.255.255:9",command="/bin/false" ssh-ed25519 AAAA...remote-key...
GatewayPorts no # tunnels bind to localhost only
AllowTcpForwarding yes # need both -R (master's tunnel) and direct-tcpip (remote's ProxyJump)
Note: AllowTcpForwarding yes is required here because the remote's ProxyJump uses direct-tcpip (same mechanism as -L). Per-key permitopen and permitlisten restrictions ensure each key can only use its intended direction.
autossh -M 0 -fN -R 2210:localhost:${SSHD_PORT_on_MASTER} \
${USER_on_VPN}@${VPN_IP} -p ${SSHD_PORT_on_VPN} \
-o ServerAliveInterval=120autossh -M 0 -fN \
-J ${USER_on_VPN}@${VPN_IP}:${SSHD_PORT_on_VPN} \
-R 2220:localhost:22 \
${USER_on_MASTER}@localhost -p 2210 \
-o ServerAliveInterval=120The ProxyJump (-J) is what makes this work without GatewayPorts yes. The jump goes through VPN to reach master's tunnel endpoint on localhost — so master's sshd port never needs to be exposed on VPN's public interface. The entire chain stays on localhost bindings.
A simpler architecture where remotes maintain persistent reverse tunnels to VPN, and master connects whenever it wants via ProxyJump. Master does not need to be online or maintain any tunnel — it just SSHes through VPN on demand.
- Master can be offline — no persistent tunnel from master needed
- Simpler setup — remotes only talk to VPN, never to master
- Scales — add more remotes by assigning each a unique port on VPN
- VPN is the gatekeeper — only users with SSH access to VPN can reach the remotes
- No ports exposed to internet —
GatewayPorts nokeeps everything on VPN's localhost
Remote A Remote B
(behind NAT) (behind NAT)
| |
|--- ssh -R 2220:localhost:22 ---. .--- ssh -R 2221:localhost:22 ---|
| (autossh, persistent) \ / (autossh, persistent) |
| \/ |
| /\ |
| +-----------/--\-----------+ |
| | VPN Server (public IP) | |
| | localhost:2220 -> A:22 | |
| | localhost:2221 -> B:22 | |
| +------------|-------------+ |
| | |
| Master (behind NAT) |
| connects on demand |
| ssh -J VPN user@localhost -p 2220 |
| | |
|<---------------------------------+ |
(ProxyJump through VPN)
graph TB
subgraph "Behind NAT — Remotes"
A["Remote A<br/>sshd :22<br/>autossh → VPN :2220"]
B["Remote B<br/>sshd :22<br/>autossh → VPN :2221"]
C["Remote C<br/>sshd :22<br/>autossh → VPN :2222"]
end
subgraph "Public Internet"
V["VPN Server<br/>sshd :SSHD_PORT_on_VPN<br/>localhost:2220 → A<br/>localhost:2221 → B<br/>localhost:2222 → C"]
end
subgraph "Behind NAT — Master"
K["Master<br/>connects when needed<br/>no persistent tunnel"]
end
A -->|"ssh -R 2220:localhost:22 (persistent)"| V
B -->|"ssh -R 2221:localhost:22 (persistent)"| V
C -->|"ssh -R 2222:localhost:22 (persistent)"| V
K -.->|"ssh -J VPN user@localhost -p 222X"| V
style K fill:#4a9eff,color:#fff
style V fill:#ff9f43,color:#fff
style A fill:#26de81,color:#fff
style B fill:#26de81,color:#fff
style C fill:#26de81,color:#fff
sequenceDiagram
participant A as Remote A<br/>(behind NAT)
participant B as Remote B<br/>(behind NAT)
participant V as VPN Server<br/>(public IP)
participant M as Master<br/>(behind NAT)
Note over A,V: Remotes maintain persistent tunnels (autossh)
A->>V: ssh -R 2220:localhost:22 (persistent)
B->>V: ssh -R 2221:localhost:22 (persistent)
Note over V: VPN localhost:2220 → A:22<br/>VPN localhost:2221 → B:22
Note over M: Master comes online, wants to control Remote A
M->>V: ssh -J USER_on_VPN@VPN (ProxyJump)
V->>V: connects to localhost:2220
V-->>A: tunneled to A's sshd :22
Note over M,A: Master now has shell on Remote A
Note over M: Later, wants Remote B
M->>V: ssh -J USER_on_VPN@VPN (ProxyJump)
V->>V: connects to localhost:2221
V-->>B: tunneled to B's sshd :22
Note over M,B: Master now has shell on Remote B
| Session | Initiated by | Path | Purpose |
|---|---|---|---|
| 1 | Remote | remote → VPN | Persistent reverse tunnel (autossh) |
| 2 | Master | master → VPN → localhost:PORT → remote:22 | On-demand shell via ProxyJump |
Compare with Appendix A which needs three sessions. This is simpler because master doesn't maintain its own tunnel.
Assign each remote a unique port on VPN from the 2220–2229 range:
| Remote | Port on VPN | Tunnel |
|---|---|---|
| Remote A | 2220 | localhost:2220 → A:22 |
| Remote B | 2221 | localhost:2221 → B:22 |
| Remote C | 2222 | localhost:2222 → C:22 |
| ... | ... | up to 2229 |
On each remote, set up autossh:
# Remote A
autossh -M 0 -fN -R 2220:localhost:22 \
${USER_on_VPN}@${VPN_IP} -p ${SSHD_PORT_on_VPN} \
-o ServerAliveInterval=120
# Remote B
autossh -M 0 -fN -R 2221:localhost:22 \
${USER_on_VPN}@${VPN_IP} -p ${SSHD_PORT_on_VPN} \
-o ServerAliveInterval=120Each remote needs sshd running (sudo systemctl enable --now ssh).
master$ ssh -J ${USER_on_VPN}@${VPN_IP}:${SSHD_PORT_on_VPN} \
${USER_on_REMOTE}@localhost -p 2220That's it. One command, two hops: master → VPN → remote.
In ~/.ssh/config on master:
# VPN jump host (shared by all remote entries)
Host vpn-jump
HostName VPN_IP
Port SSHD_PORT_on_VPN
User USER_on_VPN
# Individual remotes
Host remote-a
HostName localhost
Port 2220
User USER_on_REMOTE_A
ProxyJump vpn-jump
Host remote-b
HostName localhost
Port 2221
User USER_on_REMOTE_B
ProxyJump vpn-jump
Host remote-c
HostName localhost
Port 2222
User USER_on_REMOTE_C
ProxyJump vpn-jump
Then just:
master$ ssh remote-a # → VPN → Remote A
master$ ssh remote-b # → VPN → Remote B
master$ ssh remote-c # → VPN → Remote CEach remote gets its own restricted entry, locked to its assigned port:
restrict,port-forwarding,permitlisten="localhost:2220",permitopen="255.255.255.255:9",command="/bin/false" ssh-ed25519 AAAA...remote-a-key...
restrict,port-forwarding,permitlisten="localhost:2221",permitopen="255.255.255.255:9",command="/bin/false" ssh-ed25519 AAAA...remote-b-key...
Each remote can only open its assigned port — no shell, no other forwarding.
Master needs permitopen to connect through to the remote tunnel ports, and permitlisten="255.255.255.255:9" to block any -R attempts (master should not open ports on VPN):
restrict,port-forwarding,permitopen="localhost:2220",permitopen="localhost:2221",permitopen="localhost:2222",permitlisten="255.255.255.255:9",command="/bin/false" ssh-ed25519 AAAA...master-key...
This means master can ProxyJump to any registered remote port, but cannot open reverse tunnels on VPN or get a shell. Without the permitlisten block, port-forwarding would allow -R on any port — see the "Understanding permitlisten and permitopen" section for details.
On each remote's ~/.ssh/authorized_keys, add master's public key without restrictions (or with whatever access level you want):
ssh-ed25519 AAAA...master-key... USER_on_MASTER@master-host
Master is trusted — it gets full shell access on the remotes.
GatewayPorts no # tunnel ports bind to localhost only
AllowTcpForwarding yes # need both -R (remotes) and direct-tcpip (master ProxyJump)
Note: we need AllowTcpForwarding yes (not just remote) because master's ProxyJump uses direct-tcpip which is the same mechanism as local forwarding (-L). The per-key permitopen restrictions ensure master can only connect to registered remote tunnel ports.
Create /etc/systemd/system/ssh-tunnel-vpn.service on each remote:
[Unit]
Description=Reverse SSH tunnel to VPN
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/bin/autossh -M 0 -N \
-R 2220:localhost:22 \
USER_on_VPN@VPN_IP -p SSHD_PORT_on_VPN \
-o ServerAliveInterval=120 \
-o ExitOnForwardFailure=yes
Restart=always
RestartSec=10
User=tunnel_user
[Install]
WantedBy=multi-user.targetsudo systemctl enable --now ssh-tunnel-vpn@reboot autossh -M 0 -fN -R 2220:localhost:22 USER_on_VPN@VPN_IP -p SSHD_PORT_on_VPN -o ServerAliveInterval=120| Aspect | A: Double Reverse Tunnel | B: VPN Hub (this) |
|---|---|---|
| Master must be online | Yes (persistent tunnel) | No (connects on demand) |
| Sessions needed | 3 | 2 |
| Remote connects to | VPN then master (ProxyJump) | VPN only |
| Remote knows about master | Yes (has master's key) | No (only knows VPN) |
| Scales to many remotes | Awkward (each needs master tunnel) | Clean (assign port per remote) |
| Master goes offline | Remotes lose tunnel to master | No impact — remotes stay connected to VPN |
| Best for | Both machines always on | Fleet of remotes, master connects as needed |
This is essentially a poor man's bastion host / jump server. The VPN acts as a rendezvous point where remotes park their sshd via reverse tunnels, and authorized users (master) hop through on demand. The remotes never expose any port to the internet — they only make outbound connections to VPN. The VPN's GatewayPorts no + per-key permitlisten ensures tunnel ports are only reachable from VPN's localhost, and only users whose keys have the right permitopen entries can reach them.
- OpenSSH authorized_keys(5) man page — canonical reference for
restrict,port-forwarding,permitlisten,permitopen - OpenSSH sshd_config(5) man page —
GatewayPorts,AllowTcpForwarding,PermitListen(server-wide) - OpenSSH Cookbook: Proxies and Jump Hosts — comprehensive guide to SSH proxying, tunneling, and gateway configurations
- OpenSSH Cookbook: ProxyJump — multi-hop gateway traversal with
-J/ProxyJump - OpenSSH source: auth-options.c —
handle_permit()function: validatespermitlisten/permitopenas stricthost:port(nononekeyword) - OpenSSH source: session.c —
set_fwdpermit_from_authopts(): how per-key forwarding permissions are applied - OpenSSH source: channels.c —
channel_permit_all(): explains why a dummypermitlistenentry blocks all-R(setsnum_permitted_user > 0, preventingall_permitted = 1)