SSH Tunneling: Local, Remote and Dynamic Port Forwarding - 夜莺博客

SSH Tunneling: Local, Remote and Dynamic Port Forwarding

SSH tunneling (port forwarding) carries unencrypted or firewalled traffic inside an encrypted SSH connection — reaching an internal database from home, exposing a local dev server to the internet, or turning any SSH server into a SOCKS proxy for private browsing. The three flavors are easy to confuse, so this guide maps local (-L), remote (-R) and dynamic (-D) forwarding to the exact commands, plus the ssh_config shortcuts that keep tunnels reproducible.

Every tunnel rides on a single SSH connection. The client authenticates to a server exactly as it would for an interactive shell, then, instead of running a shell, it multiplexes forwarded TCP connections over that same encrypted channel. Because the channel is authenticated and encrypted end to end, services that speak plaintext on the wire — MySQL, Redis, VNC, RDP, an HTTP admin panel — become safe to reach across an untrusted network. Nothing changes on the destination service and no new firewall port opens on the server; the only port that must be reachable is the one sshd already listens on.

One rule dissolves most of the confusion met in the field: the side where the listening socket appears is the side the flag names. -L listens locally, -R listens remotely, and -D also listens locally but defers the decision about the destination until an application connects. Keep that in mind and the direction of every example below becomes predictable.

Quick Reference

ssh -L 8080:localhost:80 user@server     local forwarding
ssh -R 8080:localhost:3000 user@server    remote forwarding
ssh -D 9090 user@server                   dynamic SOCKS proxy
ssh -L 8080:host1:80 -L 8081:host2:80 user@server   multiple
ssh -J jump.host user@destination         jump host

Anatomy of a Forward Specification

Local and remote forwards share one four-part syntax, and reading it left to right tells you exactly what happens:

[bind_address:]port:host:hostport
  • port — the TCP port the SSH client (for -L) or the SSH server (for -R) opens and listens on. This is what your application connects to.
  • host — the destination, resolved from the SSH server's point of view in both directions. localhost therefore always means "the machine running sshd", never your laptop.
  • hostport — the destination TCP port where the real service listens.
  • bind_address — optional. When omitted, the listener binds loopback only, which is the safe default. 0.0.0.0 exposes it on every IPv4 interface; :: does the same for IPv6.

Port forwarding in SSH is TCP-only, and no flag changes that. DNS servers that use UDP, SNMP traps, syslog, DHCP and QUIC cannot ride a plain -L tunnel; you would need a UDP-to-TCP shim or a different tool entirely. Bear this in mind before promising a colleague that a tunnel will carry a UDP application, because the tunnel will come up and the application will simply time out.

Local Port Forwarding (-L)

-L binds a port on your machine; the SSH server then connects onward to the destination you name. The destination is resolved from the server's perspective — localhost means the SSH server itself. Reach a database that only listens inside the corporate network:

ssh -L 3336:db001.host:3306 user@pub001.host
mysql -h 127.0.0.1 -P 3336

The client listens on 127.0.0.1:3336. When the MySQL client connects, sshd on pub001.host opens a TCP connection to db001.host:3306, and bytes flow back over the encrypted channel. db001.host never sees your address — it sees the jump host — and your database credentials cross the internet inside SSH rather than in the clear.

Forward a web admin panel the same way and browse it locally:

ssh -L 8443:10.0.0.15:443 -N user@bastion.example.com
# then open https://127.0.0.1:8443 in a browser

Note what did not have to change: no iptables rule, no NAT on the bastion, no exposure of the panel to the network. The listening socket is unprivileged, so nothing needs root on your side. If you want the port reachable by other machines on your LAN — for instance letting a colleague behind the same NAT borrow your tunnel — add a bind address:

ssh -L 0.0.0.0:3336:db001.host:3306 user@pub001.host

That also opens the port to anyone who can reach your host, so think before typing 0.0.0.0. Ports below 1024 need elevated privileges on the client; running the SSH client under sudo works but a high port plus a local redirect is usually cleaner and leaves fewer surprises in the audit log.

Two more habits make local forwards pleasant. First, add -o ExitOnForwardFailure=yes so a client that cannot bind the port exits loudly instead of starting an unusable session. Second, run with -N when you only want the tunnel: no shell prompt is allocated, and the session lives exactly as long as the forwarding needs it.

Remote Port Forwarding (-R)

-R reverses the direction: a port opens on the SSH server and forwards back through the tunnel to your machine. Expose a local web app on a public jump host:

ssh -R 8080:localhost:3000 -N -f user@remote.host

By default the server binds the remote port on loopback only — nothing outside can reach it. Opening it to the world requires GatewayPorts yes (or clientspecified) in the server's /etc/ssh/sshd_config, and an explicit bind address:

ssh -R 0.0.0.0:8080:127.0.0.1:3000 -N -f user@remote.host

Without GatewayPorts the client silently keeps the port on loopback — a classic "it worked in the lab" trap. The fallback is deliberately quiet: OpenSSH treats a wildcard bind request as best-effort and drops back to loopback rather than refusing the forward, so the only reliable confirmation is to look at the listening socket on the server itself:

# on the SSH server
ss -lntp | grep 8080
# 127.0.0.1:8080 versus 0.0.0.0:8080 tells you which bind actually happened

Remote forwards are how engineers reach a laptop behind carrier NAT, publish a home lab on a cheap VPS, or give a vendor temporary access to a single internal port. They are also the mechanism behind reverse SSH shells and many IoT back-channel designs. Because a reverse tunnel asks a shared server to open a listening port, treat it as a privilege: GatewayPorts clientspecified lets the connecting user pick the bind address, and PermitListen restricts which ports and addresses are acceptable.

# /etc/ssh/sshd_config on the server that accepts reverse tunnels
GatewayPorts clientspecified
PermitListen 127.0.0.1:8080 0.0.0.0:8080

After editing sshd_config, validate and reload rather than restart, so an existing session is not cut: sshd -t && systemctl reload sshd.

Dynamic Port Forwarding (-D): SOCKS Proxy

-D opens a local SOCKS proxy that can reach any destination through the SSH server — no per-service rules needed. Route a browser through it:

ssh -D 9090 -N -f user@remote.host

Point the browser or application at SOCKS5 127.0.0.1:9090 and all its traffic rides the tunnel to the server before going out. Unlike -L, nothing about the destination is fixed at connect time: the client tells the proxy "connect me to example.com:443" and sshd resolves that name and dials the socket. That makes -D the one-flag answer for reaching an entire remote network rather than a single service.

One detail matters in practice. Modern OpenSSH sends hostnames to the proxy instead of resolving them locally, so DNS resolution happens on the SSH server — exactly what you want both for privacy and for split-horizon internal names that only exist inside the remote network. If internal names fail to resolve while public names work, the client is resolving locally; forcing SOCKS5h semantics in the application, or upgrading the client, fixes it. Confirm the proxy came up with:

ss -lntp | grep 9090
curl -x socks5h://127.0.0.1:9090 https://ifconfig.me   # confirms the exit IP is the server's

Applications that ignore system proxy settings — most Java and Electron tools, many CLI utilities — must be told explicitly. Environment variables cover the common cases: export ALL_PROXY=socks5h://127.0.0.1:9090 for curl and most Unix tooling, while Firefox and Chrome each have their own proxy settings page.

Useful Options

  • -N — no remote command; pure tunnel.
  • -f — background after authentication.
  • -o ExitOnForwardFailure=yes — fail visibly if the bind fails.
  • -o ServerAliveInterval=60 — keep long-lived tunnels from being reaped by idle NAT timeouts.
  • -p — non-default SSH port.
  • -C — request compression; helps on slow links carrying text protocols and hurts on already-compressed or high-throughput traffic.
  • -T — never allocate a TTY, useful alongside -N.
  • -v — verbose; the fastest way to see why a forward was refused.
  • -M with -S — master mode and a control socket, so later ssh -S /tmp/ctl commands can add forwards to one existing connection.

ssh_config Aliases for Tunnels

Long command lines are hard to remember and impossible to keep consistent across a team. Put the tunnel in ~/.ssh/config and the command shrinks to the alias:

Host tunnel-db
    HostName pub001.host
    User user
    LocalForward 3336 db001.host:3306
    ExitOnForwardFailure yes
    ServerAliveInterval 60
    ServerAliveCountMax 3
    ControlMaster auto
    ControlPath ~/.ssh/cm-%r@%h:%p

Host tunnel-socks
    HostName remote.host
    User user
    DynamicForward 9090
ssh -N tunnel-db

An alias is also the right place for RemoteForward, so a reverse tunnel is described once and started with a single word. Because the configuration lives in a file, it can be reviewed, version-controlled and shared, which matters more than it sounds: a mistyped host in a command line becomes a tunnel to the wrong network, and nobody notices until something leaks.

Jump Hosts and Chained Forwards

Jumping through a bastion no longer needs a separate ssh -L to the bastion and a second session to the target. ProxyJump handles both hops:

ssh -J jump.host user@destination
ssh -J user@jump.host:2222 user@10.0.0.5
# equivalent long form
ssh -o ProxyJump=jump.host user@destination
Host internal-host
    HostName 10.0.0.5
    ProxyJump jump.host

For chained forwards that older documentation describes as -L ... -o ProxyCommand, ProxyJump is shorter and avoids the accidental double encryption of a plain ProxyCommand. When a hop itself must be reached through another hop, list them comma-separated: ssh -J a,b user@c.

Keeping Tunnels Alive: autossh and systemd

A tunnel that survives a network blip needs supervision. autossh restarts a dropped SSH session:

autossh -M 0 -N -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
    -R 8080:localhost:3000 user@remote.host

-M 0 disables autossh's own monitoring port and relies on SSH keepalives, which is the recommended modern usage. For anything permanent, a systemd unit is clearer and integrates with the rest of the host:

[Unit]
Description=SSH reverse tunnel to remote.host
After=network-online.target
Wants=network-online.target

[Service]
User=tunnel
ExecStart=/usr/bin/ssh -NT -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 \
    -o ServerAliveCountMax=3 -o StrictHostKeyChecking=yes \
    -R 8080:localhost:3000 user@remote.host
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

Run the unit under a dedicated user with a passphrase-less key restricted by command="",no-pty,no-agent-forwarding in its authorized_keys entry on the far side. That way a compromised tunnel key cannot become an interactive login somewhere else.

Security Considerations

  • Bind to loopback by default. Every extra interface a forward listens on is an extra attack surface; reach for 0.0.0.0 only with a firewall rule behind it.
  • Restrict reverse-tunnel keys. On a shared jump server, limit which users may request remote forwards at all with AllowTcpForwarding and PermitListen.
  • Understand what a tunnel bypasses. A tunnel to a host inside a protected network side-steps perimeter controls for the traffic it carries; if your policy forbids that, the correct control is on the server, not on the client.
  • Prefer key-based auth and disable agent forwarding when it is not needed, since a forwarded agent on a compromised server hands over your keys.
  • Do not tunnel to close a change ticket. Make sure someone else can reproduce the tunnel — that is what ssh_config aliases and systemd units are for.

Troubleshooting

  • channel 2: open failed: connect failed: Connection refused — the tunnel is up but the destination service is unreachable from the SSH server; test from that server.
  • Remote port unreachable from outside — check GatewayPorts and the server firewall.
  • bind [127.0.0.1]:8080: Address already in use — something local already holds the port; pick another or find it with ss -lntp | grep 8080.
  • Tunnel drops after idle — add ServerAliveInterval; for production reverse tunnels wrap the command with autossh or systemd.
  • Permission denied (publickey) on a key that worked interactively — the unit runs as a different user and cannot see the key file; check ownership and permissions on ~/.ssh.
  • Slow throughput through the tunnel — -C compression is usually the culprit on high-bandwidth links; remove it and retest.

SSH tunnels are the network engineer's swiss knife — combine them with ss/netstat/tcpdump to validate what the tunnel actually carries, use tcpdump filters to prove which side opens the connection, and see Juniper SRX IPsec VPN or Cisco GRE tunnels for the device-level alternatives when a per-host tunnel is the wrong tool.

原文链接:https://linuxize.com/post/how-to-setup-ssh-tunneling