SSH Jump-Host Tunneling (Bastion)¶
For the normal workstation-wide experience, use Active Bastion Routing. It connects once and routes all toolkit TCP device traffic automatically. This guide documents the older per-device inventory override, which remains available for compatibility.
Who this is for¶
You have a Linux jump box (bastion) that's the only thing with network reachability to your switches, but it isn't maintained, its Python is old, and you don't want to run netops-toolkit on it. You have a Windows machine where you can install a current Python as your own user (no admin rights needed). This guide gets you running the full toolkit natively on Windows, using the jump box for exactly one thing: as SSH network transport to reach the devices.
What changes for you: - netops-toolkit runs entirely on your Windows machine (discovery, health checks, config collection, reports - all of it). - The jump box never runs any netops-toolkit code, never needs a newer Python, and needs no maintenance beyond staying reachable over SSH. - Reports, backups, and inventory files land directly on your Windows machine - no extra step copying files to/from Linux.
How it works¶
netops-toolkit opens an SSH connection to the jump box using
Paramiko, then asks that connection for a
direct-tcpip channel pointed at the target switch's IP and port. That
channel is handed to Netmiko via its
sock= parameter - Netmiko treats it exactly like a normal TCP socket and
runs the same vendor-aware CLI logic on top of it as it would for a direct
connection. The jump box is not used to run Netmiko, Python, or any
netops-toolkit code; it only relays SSH bytes.
Windows (netops-toolkit)
│
│ 1. SSH connect (Paramiko)
▼
Jump box / bastion (pure network transport, no toolkit code runs here)
│
│ 2. direct-tcpip channel → switch:22
▼
Switch (Netmiko CLI session runs *through* the channel, driven from Windows)
This mechanism was chosen over local SSH port-forwarding or spawning an
external OpenSSH client subprocess because it needs no local port
allocation/lifecycle management and adds no new dependency beyond the
paramiko version already required by netops-toolkit. See "Why this
approach" below for the full comparison.
Windows setup (no admin rights)¶
- Install Python for your own user (e.g. from python.org, "Install for me only" — no admin prompt).
pip install --user netops-toolkit(or clone the repo andpip install --user -e .).- Confirm
paramikois available (it's a core dependency, installed automatically):python -c "import paramiko; print(paramiko.__version__)".
No changes are needed on the jump box. It only needs to keep accepting SSH connections and forwarding TCP to your switches, which it presumably already does today.
Inventory configuration¶
Add jump-host fields to your inventory YAML, either per-device or once in
defaults: so every device in the file tunnels through the same bastion:
defaults:
username: admin
transport: ssh
jump_host: bastion.example.com
jump_username: netops
# jump_port defaults to 22; set jump_key_file to use key auth on the bastion
devices:
core-sw-01:
host: 10.0.3.1 # only reachable FROM the jump box, not from Windows directly
vendor: cisco_ios
role: core
edge-sw-02:
host: 10.0.3.2
vendor: arista_eos
role: edge
# Per-device override: tunnel through a different bastion for this one device
jump_host: other-bastion.example.com
jump_port: 2222
Fields:
| Field | Meaning |
|---|---|
jump_host |
Bastion hostname/IP. Omit entirely for direct (non-tunneled) connections — this is fully backward compatible. |
jump_port |
Bastion SSH port. Defaults to 22. |
jump_username |
Username on the bastion. If omitted, resolved from the credential vault when the calling integration unlocks it. |
jump_password |
Bastion password. Prefer the encrypted vault rather than putting this in YAML. |
jump_key_file |
Private key path for key-based bastion auth. If omitted, password auth is used. |
jump_key_passphrase |
Private-key passphrase. Prefer the encrypted vault rather than putting this in YAML. |
Devices with no jump_host set (the common case today) connect exactly as
before — there is no behavior change for non-tunneled devices.
Jump-box credentials (vault)¶
Bastion credentials use the exact same encrypted vault as device
credentials (netops/core/vault.py, AES-256-GCM) — no separate secrets
mechanism. Store the bastion's password once, keyed by the bastion's own
hostname:
When an integration passes an unlocked CredentialVault to
resolve_jump_host_params() (or jump_host_from_inventory()),
netops-toolkit looks up credentials for jump_host through the vault the
same way it looks up device credentials — environment-variable override,
then the device entry. For password authentication, the vault's encrypted
password is the bastion password. For a jump_key_file, that same
encrypted field is used as the key passphrase; no parallel secrets store is
introduced.
The existing standalone check/collect CLI commands do not currently unlock the vault for device credentials either; use the inventory fields or a GUI/ programmatic caller that has already unlocked the vault. This is deliberate scope honesty, not a second credential mechanism.
Programmatic usage¶
If you're calling netops.core.connection directly (as the CLI tools do):
from netops.core.connection import ConnectionParams, JumpHostParams, DeviceConnection
jump = JumpHostParams(host="bastion.example.com", username="netops", password="jumppw")
params = ConnectionParams(
host="10.0.3.1",
username="admin",
password="devicepw",
device_type="cisco_ios",
jump_host=jump,
)
with DeviceConnection(params) as conn:
print(conn.send("show version"))
Or resolve jump-host params straight from an inventory Device and the
vault:
from netops.core.connection import resolve_jump_host_params
from netops.core.vault import CredentialVault
vault = CredentialVault()
vault.unlock(master_password)
jump = resolve_jump_host_params(
device.jump_host,
device.jump_port,
device.jump_username,
device.jump_password,
device.jump_key_file,
device.jump_key_passphrase,
vault=vault,
)
Limitations¶
- Telnet transport cannot be tunneled through a jump host (
ConnectionParamsraisesValueErrorif you combinetransport=Transport.TELNETwithjump_host) — jump-host tunneling is an SSH-to-SSH mechanism. - Each device connection opens its own
direct-tcpipchannel on a fresh SSH session to the bastion. For large fleets this means one bastion SSH login per device in the run, same as an equivalentssh -Jfan-out would do; the bastion's own connection limits still apply.
Why this approach (design rationale)¶
Three tunnel mechanisms were considered for the Windows→bastion→switch path for this document's legacy per-device jump-host override; a fourth (single shared/multiplexed bastion connection reused across devices) was noted here but not implemented for that per-device path. It has since been implemented separately as Active Bastion Routing, described below.
- Paramiko in-process
direct-tcpipchannel → Netmikosock=(chosen). No local port ever gets bound, nothing to clean up, no new dependency (paramiko is already a core requirement). Netmiko's own documented pattern for jump-host support. Risk: relies on Netmiko/ Paramiko fully supporting the target vendor's SSH quirks through a channel object rather than a raw socket — mitigated by this being Netmiko's own sanctioned mechanism, not a workaround. - Local SSH port-forward (
127.0.0.1:<ephemeral>→ bastion → device), Netmiko pointed at the local port. More "standard" and easier to reason about in isolation, but requires managing ephemeral port lifecycle (allocation, collision avoidance, cleanup on error) for every device in a scan/health-check run across potentially hundreds of devices. Rejected for this pass as unnecessary complexity given option 1 works; could be revisited if a future vendor proves incompatible with in-process channels. - Spawn a real OpenSSH client subprocess with
-J/ProxyJump(Windows ships OpenSSH natively). Avoids reimplementing any SSH logic in Python, but adds process-management/lifecycle complexity (spawn, health monitoring, cleanup, cross-platform argument quoting) for a problem Paramiko already solves in-process. Rejected: higher operational surface area for no functional gain over option 1. - Single shared/multiplexed bastion connection reused across all
devices in a run (open one SSH session to the bastion, open many
direct-tcpipchannels on it). Would reduce bastion login count for large fleets. Not implemented for this per-device, inventory-drivenjump_hostmechanism —DeviceConnectionhere is scoped to a single device's lifecycle, and sharing aparamiko.SSHClientacross concurrentDeviceConnectioninstances needs an explicit pooling/lifecycle owner aboveDeviceConnection. That owner now exists as a separate, workstation-wide mechanism: Active Bastion Routing owns exactly one shared SSH connection to the selected bastion and multiplexes adirect-tcpipchannel per device connection over it, which is this option's shared/multiplexed design. It is a distinct opt-in mode from the per-devicejump_hostfields documented in this guide, not a replacement for them.
Option 1 was selected: it satisfies the real constraint (jump box does zero toolkit work, Windows does everything, no extra moving parts) with the least new surface area, and reuses an already-required dependency.