> ## Content Index
> Fetch the complete content index at: https://www.pinglabz.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# SSH Beyond Login: Tunnels, Jump Hosts, scp/sftp
- URL: https://www.pinglabz.com/ssh-tunneling-scp/
- Published: 2026-08-19T15:39:06.000Z
- Updated: 2026-08-19T15:39:06.000Z
- Description: Local, remote and dynamic forwards, ProxyJump instead of agent forwarding, scp -O against IOS XE, and why telnet is still a fine port tester.
- Author: Jaime
- Tags: Linux, SSH, Tools, Network Automation, Labs

Most engineers use SSH for one thing: getting a prompt on a remote box. The client is capable of a great deal more, and the extra features are exactly the ones that solve the awkward problems. Reaching a management interface that only listens on a private VLAN. Pulling a config off a router without setting up TFTP. Running a command on fifty devices without logging into any of them. Getting a browser to a web UI that is four hops and one firewall away.

Every capture below is real output from a Debian 13 host inside a Cisco Modeling Labs topology, against an IOS XE router at 10.77.0.1, an nginx server two hops away and a JSON API three hops away. It is part of the [Linux networking commands](https://www.pinglabz.com/linux-networking-commands/) cluster.

```
j@llmbits:~$ ssh -V
OpenSSH_10.0p2 Debian-7+deb13u4, OpenSSL 3.5.6 7 Apr 2026
```

## SSH to a router, and what it negotiated

Start with the ordinary case, because there is more in it than people look at. R1 is an IOS XE router with `ip ssh version 2` and a local user:

```
j@llmbits:~$ ssh labadmin@10.77.0.1
(labadmin@10.77.0.1) Password:

R1#show ip interface brief
Interface              IP-Address      OK? Method Status                Protocol
Ethernet0/0            10.77.0.1       YES TFTP   up                    up
Ethernet0/1            10.77.12.1      YES TFTP   up                    up
Ethernet0/2            unassigned      YES unset  administratively down down
Ethernet0/3            unassigned      YES unset  administratively down down
Loopback0              1.1.1.1         YES TFTP   up                    up
R1#show users
    Line       User       Host(s)              Idle       Location
   0 con 0                idle                 00:15:10
*  2 vty 0     labadmin   idle                 00:00:00 10.77.0.100

  Interface    User               Mode         Idle     Peer Address

R1#exit
Connection to 10.77.0.1 closed.
```

`show users` confirms the session arrived from 10.77.0.100 on vty 0, which is a useful habit when you are trying to work out whether a firewall rule or a source-interface setting is doing what you think.

The crypto negotiation is where router SSH gets interesting. `-v` shows what both sides agreed on:

```
j@llmbits:~$ ssh -v r1 exit 2>&1 | grep -E 'Remote protocol|kex:|Server host key'
debug1: Remote protocol version 2.0, remote software version Cisco-1.25
debug1: kex: algorithm: curve25519-sha256
debug1: kex: host key algorithm: rsa-sha2-512
debug1: kex: server->client cipher: chacha20-poly1305@openssh.com MAC: <implicit> compression: none
debug1: kex: client->server cipher: chacha20-poly1305@openssh.com MAC: <implicit> compression: none
debug1: Server host key: ssh-rsa SHA256:cPBAaJdw1dIyRtKHSdO8hqHKwcUyJ2b8krA8ieUynQg
```

This modern IOS XE image negotiates curve25519 and ChaCha20-Poly1305 quite happily. Older gear will not, and modern OpenSSH has removed the algorithms it used to offer, which produces the familiar `no matching key exchange method found`. The fix is to re-enable them for that host only, never globally:

```
Host old-switch
    HostName 10.20.30.40
    KexAlgorithms +diffie-hellman-group14-sha1
    HostKeyAlgorithms +ssh-rsa
    PubkeyAuthentication no
```

The leading `+` appends to the default list rather than replacing it. Scope it per host in `~/.ssh/config` so the rest of your estate keeps its modern defaults.

## One command, no session

Anything after the destination runs as a remote command and exits. This is the primitive that every "run this on all the switches" loop is built from:

```
j@llmbits:~$ ssh r1 'show ip route ospf | begin Gateway'

Gateway of last resort is not set

      2.0.0.0/32 is subnetted, 1 subnets
O        2.2.2.2 [110/11] via 10.77.12.2, 00:37:06, Ethernet0/1
      3.0.0.0/32 is subnetted, 1 subnets
O        3.3.3.3 [110/21] via 10.77.12.2, 00:36:56, Ethernet0/1
      10.0.0.0/8 is variably subnetted, 7 subnets, 2 masks
O        10.77.2.0/24 [110/20] via 10.77.12.2, 00:37:06, Ethernet0/1
O        10.77.3.0/24 [110/30] via 10.77.12.2, 00:36:56, Ethernet0/1
O        10.77.23.0/24 [110/20] via 10.77.12.2, 00:37:00, Ethernet0/1
```

Quote the remote command in single quotes so your local shell does not expand anything inside it. Pipes, redirects and variables belong to whichever side of the quotes you put them on, and getting that backward is the most common mistake in SSH one-liners.

For a handful of devices this is enough. Past that, move to the libraries in the [network automation](https://www.pinglabz.com/network-automation/) cluster. Netmiko, NAPALM and Nornir all ride on exactly this transport, they just handle the prompts, paging and error detection you would otherwise write yourself.

## Keys, and a config file

Key authentication is what makes any of this scriptable. Generate, install, verify:

```
j@llmbits:~$ ssh-keygen -lf ~/.ssh/id_ed25519.pub
256 SHA256:/EI0Y+VKOq0N1OGLqxLJkpaU4TebXrZGjqThOGBd5jQ j@llmbits lab (ED25519)

j@llmbits:~$ ssh -o BatchMode=yes lab-edge 'hostname; ip -br addr show ens224'
llmbits
ens224           UP             10.77.0.100/24
```

`BatchMode=yes` is the flag to put in every script: it disables all password prompting, so a broken key fails immediately instead of hanging a cron job on a prompt nobody will ever answer.

The config file is where you stop typing flags. Everything above becomes a name:

```
j@llmbits:~$ cat ~/.ssh/config
Host lab-edge
    HostName 10.77.0.100
    User j
    IdentityFile ~/.ssh/id_ed25519

Host r1
    HostName 10.77.0.1
    User labadmin
    IdentityFile ~/.ssh/id_rsa_r1
    PubkeyAcceptedAlgorithms +ssh-rsa
    HostKeyAlgorithms +ssh-rsa
    StrictHostKeyChecking no

Host web1
    HostName 10.77.2.10
    ProxyJump lab-edge
```

```
j@llmbits:~$ ssh r1 'show version | include uptime|Version'

Cisco IOS Software [IOSXE], Linux Software (X86_64BI_LINUX-ADVENTERPRISEK9-M), Version 17.18.2, RELEASE SOFTWARE (fc3)
R1 uptime is 38 minutes
```

Wildcards work too, so `Host 10.77.*` can carry the legacy algorithms for an entire lab range while everything else stays strict. Keep `StrictHostKeyChecking no` for lab gear that gets rebuilt constantly, and nowhere else.

## Local forward: bring a remote service to your machine

`-L localport:target:targetport` opens a listener on your machine. Anything that connects to it comes out of the SSH server and connects onward to the target. The target is resolved and reached *from the server*, which is the whole point: it can be something you have no route to at all.

```
j@llmbits:~$ ssh -f -N -L 18080:10.77.2.10:80 lab-edge

j@llmbits:~$ ss -lnt | grep 18080
LISTEN 0      128        127.0.0.1:18080      0.0.0.0:*
LISTEN 0      128            [::1]:18080         [::]:*

j@llmbits:~$ curl -sS -I http://127.0.0.1:18080/
HTTP/1.1 200 OK
Server: nginx/1.29.8
Date: Wed, 19 Aug 2026 15:33:22 GMT
Content-Type: text/html
Content-Length: 896
Last-Modified: Tue, 07 Apr 2026 11:37:12 GMT
Connection: keep-alive
ETag: "69d4ec68-380"
Accept-Ranges: bytes
```

The same thing pointed at the JSON API three hops away, which is how you get a local Postman or a curl one-liner onto a controller API that is firewalled off from your desk:

```
j@llmbits:~$ ssh -f -N -L 18001:10.77.3.10:8001 lab-edge

j@llmbits:~$ curl -sS http://127.0.0.1:18001/api/devices
{
  "device": "SRV1",
  "os": "net-tools",
  "interfaces": [
    {
      "name": "eth0",
      "ipv4": "10.77.3.10/24",
      "state": "up"
    }
  ],
  "uptime_seconds": 0
}
```

`-f` backgrounds after authentication and `-N` means "no remote command, just the tunnel". Together they are how you start a tunnel and get your prompt back. Note the listener bound to 127.0.0.1 only. That is the default and it is correct; `-L 0.0.0.0:18080:...` would let the entire local network use your tunnel, and your credentials.

## Dynamic forward: one tunnel, every destination

`-D` turns the SSH connection into a SOCKS5 proxy. Instead of naming one target, every destination is chosen by the client at connection time. For reaching a management network with a dozen web UIs on it, this replaces a dozen `-L` flags:

```
j@llmbits:~$ ssh -f -N -D 1080 lab-edge

j@llmbits:~$ curl -sS --socks5 127.0.0.1:1080 -o /dev/null -w 'WEB1 via SOCKS: %{http_code}\n' http://10.77.2.10/
WEB1 via SOCKS: 200

j@llmbits:~$ curl -sS --socks5 127.0.0.1:1080 -o /dev/null -w 'SRV1 site via SOCKS: %{http_code}\n' http://10.77.3.10:8000/
SRV1 site via SOCKS: 200

j@llmbits:~$ curl -sS --socks5-hostname 127.0.0.1:1080 http://10.77.3.10:8001/api/status
{
  "status": "ok",
  "role": "lab-api"
}
```

Two different targets, three different ports, one tunnel, no reconfiguration between them. The difference between `--socks5` and `--socks5-hostname` matters: the first resolves DNS locally and sends an IP through the proxy, the second sends the name and lets the far side resolve it. On a network with split DNS, only the second gets the right answer, and it is also the one that stops your local resolver learning which internal hosts you are visiting.

Point a browser at `SOCKS5 127.0.0.1:1080` with remote DNS enabled and every internal web UI opens as if you were sitting in the datacenter.

## Remote forward: open a door back to yourself

`-R remoteport:target:targetport` is the mirror image. The listener opens on the SSH server, and connections to it come back down the tunnel to be made from your side:

```
j@llmbits:~$ ssh -f -N -R 19090:10.77.2.10:80 lab-edge

j@llmbits:~$ ss -lnt | grep 19090
LISTEN 0      128        127.0.0.1:19090      0.0.0.0:*
LISTEN 0      128            [::1]:19090         [::]:*

j@llmbits:~$ curl -sS -o /dev/null -w 'through the reverse tunnel: %{http_code}\n' http://127.0.0.1:19090/
through the reverse tunnel: 200
```

The classic use is giving a vendor or a colleague temporary access to something behind your NAT without a VPN, or letting a device on an isolated segment reach a repository on your side. Note that by default the remote listener also binds to localhost on the server; making it reachable by other machines requires `GatewayPorts yes` in the server's `sshd_config`, which is deliberately not the default.

Reverse tunnels are also how a lot of persistence works after a compromise, so if you find an unexplained `-R` in a process list on a production box, that is worth a conversation.

## Jump hosts

`-J` (or `ProxyJump` in the config file) hops through a bastion without you having to log into it. Your keys stay on your machine and the traffic is encrypted end to end, which is the important difference from logging in and typing `ssh` again:

```
j@llmbits:~$ ssh -J lab-edge r1 'show clock'

*15:33:47.958 UTC Wed Aug 19 2026
```

The debug output shows both hops authenticating separately:

```
j@llmbits:~$ ssh -v -J lab-edge r1 exit 2>&1 | grep -iE 'Executing proxy|Connecting to|Authenticated to'
debug1: Executing proxy command: exec ssh -v -W '[10.77.0.1]:22' lab-edge
debug1: Connecting to 10.77.0.100 [10.77.0.100] port 22.
Authenticated to 10.77.0.100 ([10.77.0.100]:22) using "publickey".
Authenticated to 10.77.0.1 (via proxy) using "publickey".
```

Chain as many as you need with commas: `ssh -J bastion1,bastion2 target`. Put `ProxyJump bastion` under a `Host 10.77.*` block and every device in that range routes through it automatically, including `scp` and `sftp`.

## scp, sftp, and pulling configs off a router

With `ip scp server enable`, an IOS XE box will serve its own filesystem over SSH, and `running-config` is a valid source path. No TFTP server, no FTP credentials, no extra daemon:

```
j@llmbits:~$ scp -O r1:running-config ./r1-running.cfg

j@llmbits:~$ head -12 r1-running.cfg

!
! Last configuration change at 15:32:33 UTC Wed Aug 19 2026
!
version 17.18
service timestamps debug datetime msec
service timestamps log datetime msec
!
hostname R1
!
boot-start-marker
boot-end-marker

j@llmbits:~$ wc -l r1-running.cfg
132 r1-running.cfg
```

The `-O` flag matters. Modern OpenSSH defaults `scp` to the SFTP protocol, and network devices generally implement only the legacy SCP protocol, so without `-O` you get an unhelpful error. That one flag fixes most "scp stopped working against my switches after an upgrade" reports.

`sftp` is the interactive alternative, and it is the better choice against Linux hosts because it can list, resume and navigate:

```
j@llmbits:~$ sftp lab-edge
Connected to lab-edge.
sftp> pwd
Remote working directory: /home/j
sftp> ls -l r9.cfg
-rw-rw-r--    ? j        j              68 Aug 19 08:04 r9.cfg
sftp> get r9.cfg /tmp/r9-via-sftp.cfg
Fetching /home/j/r9.cfg to /tmp/r9-via-sftp.cfg
r9.cfg                                        100%   68    43.5KB/s   00:00
sftp> put r1-running.cfg /tmp/r1-uploaded.cfg
Uploading r1-running.cfg to /tmp/r1-uploaded.cfg
r1-running.cfg                                100% 1490     1.4MB/s   00:00
sftp> bye
```

For scripted transfers use `sftp -b batchfile`, which reads the same commands from a file and exits non-zero on the first failure.

## Sidebar: telnet is not dead, it is just not a login protocol

Nobody should be using telnet to manage anything. It is still a useful diagnostic, because it opens a raw TCP session and shows you the banner, and it is present on machines where netcat is not.

```
j@llmbits:~$ telnet 10.77.2.10 80
Trying 10.77.2.10...
Connected to 10.77.2.10.
Escape character is '^]'.
HEAD / HTTP/1.0

HTTP/1.1 200 OK
Server: nginx/1.29.8
Date: Wed, 19 Aug 2026 15:16:18 GMT
Content-Type: text/html
Content-Length: 896
Last-Modified: Tue, 07 Apr 2026 11:37:12 GMT
Connection: close
ETag: "69d4ec68-380"
Accept-Ranges: bytes

Connection closed by foreign host.
```

Three lines of output tell you the route works, the port is open and the service is nginx. A closed port is equally clear:

```
j@llmbits:~$ telnet 10.77.2.10 8080
Trying 10.77.2.10...
telnet: Unable to connect to remote host: Connection refused
```

What you should not do is this, which the lab router still permits purely to make the point:

```
j@llmbits:~$ telnet 10.77.0.1
Trying 10.77.0.1...
Connected to 10.77.0.1.
Escape character is '^]'.

User Access Verification

Username: labadmin
Password:
R1#
```

That username and password crossed the network in plaintext, readable by anything with a span port. `transport input ssh` on your vty lines, and keep telnet as a client-side test tool only. Remember `Ctrl-]` then `quit` to escape a session that will not close.

## Keeping tunnels alive

A backgrounded tunnel dies quietly when an idle NAT translation expires, and you find out at the worst moment. Three settings in `~/.ssh/config` prevent most of it:

**`ServerAliveInterval 30`**Send a keepalive every 30 seconds. Keeps NAT and firewall state fresh, and detects a dead peer instead of hanging on a socket that will never answer. 

**`ExitOnForwardFailure yes`**Without it, a tunnel whose local port is already in use connects anyway and silently forwards nothing. This is the single most common reason a tunnel "does not work". 

**`ControlMaster auto`**Reuses one authenticated connection for subsequent sessions to the same host. Turns a hundred-device loop from a hundred handshakes into one, and pairs well with `ControlPersist 5m`. 

Auditing what you have open is one command:

```
j@llmbits:~$ pgrep -a ssh | grep -E ' -L | -R | -D '
1272 sshd: /usr/sbin/sshd -D [listener] 0 of 10-100 startups
13929 ssh -f -N -L 18080:10.77.2.10:80 lab-edge
13936 ssh -f -N -L 18001:10.77.3.10:8001 lab-edge
13941 ssh -f -N -D 1080 lab-edge
13946 ssh -f -N -R 19090:10.77.2.10:80 lab-edge
```

Run that on a production jump box occasionally. Tunnels outlive the reason they were created, and a forgotten `-D` is a permanent hole through your segmentation.

## FAQ

### How do I remember which way -L and -R go?

`-L` opens the listener *locally* and the connection is made from the remote end. `-R` opens the listener *remotely* and the connection is made from your end. In both cases the port you name first is where the listener appears.

### Why does my tunnel connect but forward nothing?

Usually the local port is already taken, and without `ExitOnForwardFailure yes` SSH warns once and carries on. Second most likely: you bound to 127.0.0.1 (the default) and are trying to reach it from another machine, or you need `GatewayPorts` on the server for a `-R`.

### Is a SOCKS tunnel the same as a VPN?

No. It carries only TCP, only from applications configured to use the proxy, and it does not touch your routing table. That is often an advantage: it is scoped, it needs no privileges, and it disappears when you close the session. It will not carry ICMP, so ping will not work through it.

### Is scp deprecated?

The legacy SCP protocol is, and modern OpenSSH quietly uses SFTP underneath. For Linux to Linux use `sftp` or `rsync -e ssh`. For network devices you generally still need `scp -O` to force the old protocol, because that is all the device implements.

### Should I use agent forwarding to reach the next hop?

Prefer `ProxyJump`. Agent forwarding exposes your agent socket to root on every intermediate host, so anyone with root on the bastion can use your key for as long as you are connected. `-J` keeps the key on your machine and gives end-to-end encryption to the final host.

### How do I run one command across fifty devices?

A bash loop with `BatchMode=yes` and a short `ConnectTimeout` works and is a fine place to start. Past that, or as soon as you need structured output and error handling, use the tools in the [network automation](https://www.pinglabz.com/network-automation/) cluster. Netmiko and NAPALM ride on this same SSH transport and handle the device-specific behavior for you.

## Key takeaways

- `-L` listens locally and connects from the server. `-D` makes the whole session a SOCKS5 proxy. `-R` listens on the server and connects from your side.
- `-f -N` is the tunnel idiom: authenticate, background, run no remote command.
- `--socks5-hostname` resolves DNS at the far end. On a split-DNS network that is the difference between working and not.
- `ProxyJump` beats agent forwarding, and beats logging into the bastion and typing ssh again. Put it in `~/.ssh/config` under a wildcard host block.
- `scp -O` forces the legacy protocol that network devices actually implement. With `ip scp server enable`, `running-config` is a valid remote path.
- Put legacy KEX and host key algorithms in a per-host config block, never globally, and always with a leading `+`.
- `ExitOnForwardFailure yes` and `ServerAliveInterval 30` prevent most silent tunnel failures.
- Telnet is a fine port tester and a terrible login protocol. Use it as a client, never enable it on a vty.
- Audit long-lived tunnels. A forgotten `-D` on a jump host is a permanent bypass of your segmentation.

This closes the transfer and swiss-army tools group. It builds on [curl](https://www.pinglabz.com/curl-network-engineers/) and [wget](https://www.pinglabz.com/wget-linux/) for the HTTP layer, [netcat](https://www.pinglabz.com/netcat-nc-linux/) for raw port checks and [socat](https://www.pinglabz.com/socat-linux/) for relays that do not need a login. All of it, plus the rest of the toolset, is indexed on the [Linux networking commands](https://www.pinglabz.com/linux-networking-commands/) pillar.