What it should be
- A small, separate server that runs SSH and nothing else: no database, no website, no developer tools.
- Keys only, with automatic security updates.
- No private keys on it. A jump makes this unnecessary. The login to the internal server is encrypted from your own machine, and the bastion just passes the data along.
- The only way in. Internal servers should accept SSH only from the bastion's address, at the firewall.
For the rest of the server's SSH settings, see how to harden sshd_config.
Create the users
sudo groupadd jump
sudo useradd -m -G jump -s /bin/bash alice
sudo install -d -m 700 -o alice -g alice /home/alice/.ssh
sudo install -m 600 -o alice -g alice alice.pub /home/alice/.ssh/authorized_keys
Keep your own admin account outside the jump group, so that you still have a shell.
The sshd config
# /etc/ssh/sshd_config.d/10-bastion.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
AllowAgentForwarding no
AllowTcpForwarding no
AllowStreamLocalForwarding no
X11Forwarding no
PermitTunnel no
GatewayPorts no
LogLevel VERBOSE
Match Group jump
AllowTcpForwarding local
PermitOpen 10.0.1.10:22 10.0.1.11:22
PermitTTY no
ForceCommand /usr/sbin/nologin
Each setting in the block has a job:
AllowTcpForwarding localallows jumps and-L, but not-R.PermitOpenlists where they can go. List each server:*can stand for a whole host or port, but not for part of an address such as10.0.1.*.PermitTTY noandForceCommandremove the shell.ForceCommandalone does not stop forwarding, and that is what allows it to work here.
Keep it in its own file under /etc/ssh/sshd_config.d/, and first check that no other file sets the same options earlier: sshd keeps the first value it reads. A Match block in such a file ends where the file ends. Then check and reload, with a session left open:
sudo sshd -t
sudo sshd -T -C user=alice,host=client,addr=203.0.113.10 | grep -iE '^(allowtcpforwarding|permitopen|permittty|forcecommand) '
sudo systemctl reload ssh
permittty no
allowtcpforwarding local
forcecommand /usr/sbin/nologin
permitopen 10.0.1.10:22 10.0.1.11:22
The nologin path can be /sbin/nologin on other systems, so check it with command -v nologin.
DisableForwarding is for other users
DisableForwarding yes switches off all forwarding and overrides the other settings, so it is handy for a group that must never forward anything. Do not use it for jump users. A jump is a forward, and in our test it failed with stdio forwarding failed.
Connect through it
ssh -J alice@bastion.example.com user@10.0.1.10
Or, to save typing, in ~/.ssh/config on your own machine:
Host bastion
HostName bastion.example.com
User alice
Host app1
HostName 10.0.1.10
User user
ProxyJump bastion
Then run ssh app1. Your key must be on both the bastion and the internal server. There is more on the client side in jump hosts and ProxyJump. Avoid ForwardAgent, for the reasons in agent forwarding risks.
Test what is refused
These are the results we got with this config, and you should see the same:
| Test | Result |
|---|---|
ssh -J alice@bastion user@10.0.1.10 | Logged in to the internal server |
ssh -W 10.0.1.10:80 alice@bastion | stdio forwarding failed |
ssh -tt alice@bastion | PTY allocation request failed on channel 0 |
ssh alice@bastion id | This account is currently not available. |
ssh -N -o ExitOnForwardFailure=yes -R 8080:localhost:22 alice@bastion | Error: remote port forwarding failed for listen port 8080 |
Logging: what you do and do not get
Already at the default level, each login is logged with the fingerprint of its key, which tells you who connected. LogLevel VERBOSE also logs the keys that were refused. A login looks like this:
Accepted publickey for alice from 203.0.113.10 port 51018 ssh2: ED25519 SHA256:NxFS05Q+k1zf...
Refused targets are logged too:
Received request from 203.0.113.10 port 51036 to connect to host 10.0.1.10 port 80, but the request was denied.
Successful targets are not, at INFO or VERBOSE. We checked: the bastion's log shows that alice connected, but not which server she went on to. In our test that appeared only at LogLevel DEBUG1, and the manual warns that debug levels can record private details of users:
debug1: server_request_direct_tcpip: originator 127.0.0.1 port 65535, target 10.0.1.10 port 22
So read both sides. The internal server's log shows who logged in and with which key, with the bastion's address as the source. Put the two together by time, and send both to a machine outside the bastion, since an attacker with root there could edit the local copy. See how to check login attempts for reading these logs.
Removing access
Take the user's public key off the bastion, and they lose this route to every server behind it. They can still use any internal server that they can reach some other way, so remove the key there too. See how to revoke a key.
Questions
What is the difference between a bastion host and a jump host?
Little. A jump host is any server you pass through to reach another. A bastion is a jump host set up and hardened for that job alone, and it is the only way in from outside.
Does the bastion need my private key?
No. With ProxyJump, the login to the internal server starts on your own machine and is encrypted end to end. The bastion only forwards the connection.
Why does ssh -J fail with stdio forwarding failed?
The bastion refused the forward. Check that AllowTcpForwarding allows it for your user, that the target is listed in PermitOpen, and that DisableForwarding is not set.