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 cluster.
j@llmbits:~$ ssh -V
OpenSSH_10.0p2 Debian-7+deb13u4, OpenSSL 3.5.6 7 Apr 2026SSH 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:cPBAaJdw1dIyRtKHSdO8hqHKwcUyJ2b8krA8ieUynQgThis 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 noThe 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/1Quote 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 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/24BatchMode=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-edgej@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 minutesWildcards 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: bytesThe 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: 200The 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 2026The 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.cfgThe -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> byeFor 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 refusedWhat 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 30Send 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 yesWithout 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 autoReuses 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-edgeRun 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 cluster. Netmiko and NAPALM ride on this same SSH transport and handle the device-specific behavior for you.
Key takeaways
-Llistens locally and connects from the server.-Dmakes the whole session a SOCKS5 proxy.-Rlistens on the server and connects from your side.-f -Nis the tunnel idiom: authenticate, background, run no remote command.--socks5-hostnameresolves DNS at the far end. On a split-DNS network that is the difference between working and not.ProxyJumpbeats agent forwarding, and beats logging into the bastion and typing ssh again. Put it in~/.ssh/configunder a wildcard host block.scp -Oforces the legacy protocol that network devices actually implement. Withip scp server enable,running-configis 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 yesandServerAliveInterval 30prevent 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
-Don a jump host is a permanent bypass of your segmentation.
This closes the transfer and swiss-army tools group. It builds on curl and wget for the HTTP layer, netcat for raw port checks and socat for relays that do not need a login. All of it, plus the rest of the toolset, is indexed on the Linux networking commands pillar.