NetStacksNetStacks

Jump Hosts & Bastions

Reach internal devices through bastion/jump hosts with native russh ProxyJump, per-hop credentials, and host-key TOFU prompts on every hop of the path.

Overview

A jump host (also called a bastion) is an intermediary SSH server you connect through to reach devices that are not directly routable from your workstation — management VLANs, isolated lab networks, or anything behind a hardened edge. NetStacks reaches the final target by authenticating to the bastion, opening a forwarded tunnel from the bastion to the target, and then running a second, independent SSH session to the target over that tunnel.

Each hop authenticates with its own credentials and each hop is subject to host-key verification. This page covers configuring jump hosts, attaching them to sessions, using a saved Session as a jump endpoint, setting a profile-wide default jump, and how Trust-On-First-Use (TOFU) host-key prompts behave along the path.

Where this lives

Jump hosts are a Personal Mode (Local Agent) concept. Open them in Settings → Jump Hosts. Trusted host keys live in Settings → Trusted Hosts. In Controller-managed (enterprise) mode the Controller is itself the egress/jump, so the per-session Jump (Bastion) picker is hidden — the path is managed centrally.

How It Works

NetStacks implements ProxyJump natively in Rust on top of russh. It does not shell out to the system ssh -J. The agent performs these steps for every jumped connection:

  1. Authenticate to the jump host using the jump host's own credential profile.
  2. Open a direct-tcpip channel from the jump host to the target's host:port. This requires the bastion to permit TCP forwarding (AllowTcpForwarding yes).
  3. Run a second russh client over that channel and authenticate to the target with the target's own credentials.
  4. The target handle keeps a reference to the jump channel alive. When you close the target session, the channel closes and the bastion session exits cleanly.
Per-hop credentials, not credential sharing

Because each hop is a distinct SSH session, the bastion and the target each use their own username, password, and key. The bastion never sees the target's credentials, and the target authenticates you as the intended user — not as whoever logged into the bastion.

A NetStacks jump path is a single hop: client → bastion → target. The jump itself can be a dedicated Jump Host record or another saved Session, but the agent resolves exactly one effective jump per connection (see Using Another Session as the Jump).

Configure a Jump Host

Open Settings → Jump Hosts and click Add Jump Host. A jump host record has four fields:

Name
A label you'll pick from the session's Jump dropdown, e.g. "Production Bastion".
Host
The bastion hostname or IP, e.g. bastion.example.com.
Port
SSH port on the bastion. Defaults to 22.
Profile
The credential profile used to authenticate to the bastion. The profile's username is shown next to the host as user@host:port.
Create a profile first

A jump host references a credential profile for authentication — you cannot add a jump host until at least one profile exists. If the Jump Hosts tab shows "You need to create a profile before adding jump hosts", go to the Profiles tab first. See SSH Passwords & Keys.

Deleting a jump host that is in use is safe: sessions referencing it have their jump reference cleared (they fall back to a direct connection), and the dialog warns you before deleting.

Attach a Jump to a Session

Open a saved session's settings and find the Jump (Bastion) picker on the SSH tab. The dropdown lets you choose one of:

  • None — connect directly to the target.
  • A Jump Host record — the dedicated bastions you configured above.
  • Another Session — use an existing saved session as the jump endpoint (covered next).

Internally the choice is stored as one of two mutually-exclusive fields: jump_host_id (a Jump Host record) or jump_session_id (another Session). The backend rejects setting both at once.

What the API sees

The session record carries jump_host_id and jump_session_id as nullable fields. The UI encodes your selection as host:<id> or session:<id> and decodes it back into exactly one of those two fields on save.

Using Another Session as the Jump

Sometimes the most convenient bastion is a device you already have a saved session for — a management box, a console server, or a router with a routable management address. Rather than duplicating it as a separate Jump Host record, pick it directly: set the session's jump to that other session.

When a session is used as a jump, NetStacks resolves its host, port, and its profile's credentials to authenticate the bastion hop. The same per-hop credential rules apply: the jump session's profile authenticates the bastion, and the target session's profile authenticates the target.

See what depends on a session

A session's settings shows a "used as jump by N" indicator so you know before editing or deleting a box that other sessions route through it. Deleting a jump endpoint clears the jump reference on its dependents.

The effective jump is a single hop. Choosing a Session as a jump does not recursively chain through that session's own jump — the resolver dereferences exactly one level to obtain the bastion's host, port, and credentials.

Profile-Level Default Jump

A credential profile can carry a default jump so that every session or tunnel using that profile routes through the same bastion without configuring it per-session. The profile stores the same mutually-exclusive pair: jump_host_id or jump_session_id.

When a connection runs, NetStacks resolves the effective jump with a simple precedence rule:

effective_jump =
    session-level jump   (if the session sets one)
    else profile-level jump   (the profile's default)
    else None  (direct connection)

In other words, a jump set on the session overrides the profile's default. Set None explicitly on a session to bypass a profile default and connect directly. The same precedence applies to port-forward tunnels, which can also carry an override jump.

Connection pooling keys on the resolved jump

For tunnels, NetStacks pools SSH connections by (host, port, profile, effective jump). Two tunnels that resolve to the same target through the same bastion share one underlying connection — regardless of whether the jump came from a Jump Host record or a Session.

Host-Key TOFU on the Jump Path

Host-key verification is enforced on every hop — the bastion and the target. NetStacks uses Trust-On-First-Use (TOFU) with an explicit prompt: there is no silent acceptance of new keys. Keys are stored in an OpenSSH-style known_hosts file keyed by host:port.

When a hop presents its host key, the agent classifies it:

Matches
The presented key equals the trusted key on file. The handshake proceeds silently.
Unknown (first connection)
No record exists for this host:port. NetStacks pauses the handshake and surfaces a Confirm SSH host key modal showing the SHA-256 fingerprint. This is the accept-new behavior — nothing is written to known_hosts until you click Trust This Key.
Changed
The host:port is known but the key is different. The modal turns red and warns of a possible man-in-the-middle (or a re-imaged device), showing the previously trusted fingerprint and the new one side by side. You must explicitly click Trust New Key Anyway to proceed.
A changed key on the jump path is a strong MITM signal

Because the path traverses a bastion you may not fully control, a changed key — on either the bastion or the target — deserves scrutiny. Verify the new SHA-256 fingerprint out of band (the device's show crypto key mypubkey, show ssh server host-keys, or your inventory record) before accepting.

The prompt blocks the handshake for up to 120 seconds. If you do not respond in time, the prompt auto-rejects and the connection aborts with a host-key error. The same modal is used whether the unknown/changed key belongs to the bastion or the final target.

When the bastion refuses to forward, TOFU never runs for the target

The target's host key can only be seen through the bastion's tunnel. If the bastion declines to open the forwarding channel, you'll get a forwarding error before ever reaching the target's key — see Troubleshooting.

Post-RMA / known-change escape hatch

Automated flows that expect a key to change — for example a Method of Procedure that just RMA'd a device — can opt into auto-accepting the new key for that run (auto_accept_changed_keys). This is never the default for interactive sessions; an interactive jump always prompts.

Managing Trusted Hosts

Everything you've accepted via the TOFU prompt is listed in Settings → Trusted Hosts, showing each host:port, key type, and SHA-256 fingerprint. From there you can Revoke an individual entry. Revoking removes it from known_hosts, so the next connection to that host re-prompts you to verify and accept the fingerprint again.

Revoke a trusted key if you accepted one by mistake, if a device was legitimately re-keyed, or after decommissioning a bastion. Both the bastion and the target appear as separate host:port entries.

~/.ssh/known_hoststext
# Default known_hosts location (managed by NetStacks)
~/.ssh/known_hosts

# Format: host:port keytype base64-key
bastion.example.com:22 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGqJ...
10.20.0.5:22 ssh-rsa AAAAB3NzaC1yc2EAAAADAQAB...

Examples

Conceptual equivalent of a NetStacks jumped session

A session targeting 10.20.0.5 through the bastion bastion.example.com is the native-russh equivalent of this OpenSSH config. NetStacks does not run this command — it's shown to map the mental model:

~/.ssh/configtext
# ~/.ssh/config equivalent (illustrative only)
Host target
    HostName 10.20.0.5
    Port 22
    User netadmin
    ProxyJump bastionuser@bastion.example.com:22

Bastion sshd must permit forwarding

The ProxyJump tunnel is a direct-tcpip channel, which the bastion's SSH daemon must allow:

/etc/ssh/sshd_configbash
# /etc/ssh/sshd_config on the bastion
AllowTcpForwarding yes

# Apply:
sudo systemctl reload sshd

Verify the bastion fingerprint out of band

Before clicking Trust This Key on the bastion prompt, confirm the SHA-256 fingerprint from the bastion itself:

# On the bastion host (Linux/OpenSSH), print all host key fingerprints
for f in /etc/ssh/ssh_host_*_key.pub; do
  ssh-keygen -lf "$f"
done

# Compare the SHA256:... value to the modal before accepting.

On a network device, print the host key fingerprint

! Cisco IOS / IOS-XE
show crypto key mypubkey rsa

! Cisco IOS-XR / many platforms
show ssh server host-keys

Q&A

Does the bastion ever see my target credentials?
No. The bastion and the target are independent SSH sessions, each authenticating with its own profile. The bastion only forwards an opaque TCP stream; the target handshake (including your target password or key) happens end-to-end over that tunnel.
How do I configure a jump host?
In Settings → Jump Hosts, add a record with a name, host, port (default 22), and a credential profile for the bastion. Then pick it from a session's Jump (Bastion) dropdown. See Configure a Jump Host.
Can I chain through multiple bastions?
A NetStacks connection resolves a single effective jump (client → bastion → target). Choosing a Session as the jump dereferences one level only; it does not recursively follow that session's own jump.
Session jump vs. profile jump — which wins?
The session-level jump overrides the profile default. If the session sets none, the profile's default jump applies; otherwise the connection is direct. See Profile-Level Default Jump.
Is the target's host key still verified through a jump?
Yes. TOFU runs on every hop. The first time you reach the target through the bastion, you'll see a fingerprint prompt for the target just as you would for a direct connection — and again for the bastion itself.
What happens if the host key changes on the bastion or target?
You get a red host key has CHANGED modal with the old and new fingerprints side by side. Nothing is trusted unless you explicitly click Trust New Key Anyway. Verify out of band first — on a jump path this is a meaningful MITM signal.
Why does my jumped connection fail with a forwarding error?
The bastion refused to open the direct-tcpip channel to the target. Enable AllowTcpForwarding yes in the bastion's sshd_config. See Troubleshooting.
Can I see and remove keys I've trusted?
Yes — Settings → Trusted Hosts lists every trusted host:port with its fingerprint and lets you revoke any entry, which forces a fresh prompt on the next connection.
Do jump hosts work in Controller (enterprise) mode?
The per-session Jump (Bastion) picker is hidden in enterprise mode because the Controller acts as the egress/jump and manages the path centrally. Jump Host records are a Personal Mode (Local Agent) feature.

Troubleshooting

"Jump host refused to open a tunnel ... AllowTcpForwarding yes"

The bastion declined the direct-tcpip forward to the target. Set AllowTcpForwarding yes in the bastion's sshd_config and reload sshd. If a Match block or a per-user setting disables forwarding for the bastion account, that takes precedence — check it too.

# On the bastion
sudo sshd -T | grep -i allowtcpforwarding
# Expect: allowtcpforwarding yes

Authentication failed at the jump host

The bastion rejected the jump credentials. The error is raised at the jump-auth step before the target is contacted. Confirm the jump host's profile has the correct username and password/key for the bastion (not the target).

Authentication failed at the target (jump succeeded)

If the bastion connected but the target rejected auth, the error comes from the target hop. The credentials in use are the target session's profile — not the bastion's. Verify the target profile.

The host-key prompt never resolves / times out

A pending fingerprint prompt blocks the handshake for up to 120 seconds, then auto-rejects. If the modal didn't appear, make sure the app is focused and the vault is unlocked. The prompt covers whichever hop presented an unknown or changed key — bastion first, then target.

"Jump host referenced by session/profile no longer exists"

A session or profile points at a Jump Host (or Session) that was deleted. Edit the dependent session or profile and pick a valid jump, or set it to None for a direct connection.

"Vault is locked — cannot read credentials for jump"

The bastion's credentials live in the vault. Unlock it in Settings → Security and retry. See Credential Vault.

Backend rejects setting both jump fields

jump_host_id and jump_session_id are mutually exclusive. The UI prevents this, but if you script the API, send at most one of the two.