NetStacksNetStacks

SSH Tunnels & Port Forwarding

Forward local and dynamic (SOCKS5) ports through an SSH session: configure tunnels, drive the tunnel popover, and understand pooling and reconnect.

Overview

A NetStacks tunnel forwards a TCP port across an SSH connection handled by the Local Agent. Instead of shelling out to the system ssh binary, the Agent opens its own authenticated SSH transport (the same engine that powers your terminal sessions) and runs the forwarding listener in-process. That means tunnels reuse your existing credential profiles, jump hosts, and host-key trust — no separate ~/.ssh/config required.

NetStacks supports two kinds of tunnel:

  • Persistent tunnels — named, saved definitions you create in Settings → SSH Tunnels. They survive restarts, can auto-start, and auto-reconnect with backoff.
  • Session tunnels — ephemeral forwards attached to a saved session's port-forward list. They start when you connect that session and stop when you disconnect.

Both share the same forwarding engine and appear together in the Tunnels popover in the status bar.

Where tunnels run

All forwarding happens inside the Local Agent on your machine. The local listener binds on your workstation; traffic is carried over the SSH channel to the remote SSH host, which opens a direct-tcpip channel to the final destination. Nothing about a tunnel leaves your machine except the encrypted SSH stream.

Forward Types

The Forward Type selector offers three modes, mirroring the classic OpenSSH flags. The spec shown on each tunnel card uses a compact notation: L, R, or D followed by the local port and the destination.

TypeFlagSpec shownStatus
Local-LL :8080 → remote_host:remote_portSupported
Dynamic-DD :1080 SOCKS5Supported
Remote-RR :remote_port → localhost:local_portNot yet implemented

Local Forward (-L)

A local forward opens a listener on your machine and pipes every connection to a fixed remote_host:remote_port reached from the SSH server's vantage point. Use it to reach a single service that is only routable from the jump/SSH host — a device's web UI, a management API, a database, or a NetBox instance behind the bastion.

For example, to reach a switch's HTTPS UI at 10.0.0.5:443 that only the bastion can route to, configure a local forward with local port 8443 and remote 10.0.0.5:443. Then open https://127.0.0.1:8443 in your browser.

Local forward exampletext
Forward Type:  Local (-L)
Local Port:    8443
Bind Address:  127.0.0.1
Remote Host:   10.0.0.5
Remote Port:   443

# Reachable locally at:
https://127.0.0.1:8443

The equivalent OpenSSH invocation for intuition (NetStacks does not shell out to this; it is shown only for comparison):

ssh -L 127.0.0.1:8443:10.0.0.5:443 user@bastion

Dynamic Forward (-D / SOCKS5)

A dynamic forward turns the local port into a SOCKS5 proxy. Instead of pointing at one fixed destination, each client connection negotiates its own target via the SOCKS5 handshake, and the Agent opens a fresh SSH direct-tcpip channel to whatever host the client asked for. Point a browser, curl, or any SOCKS-aware tool at the local port and all of its traffic egresses from the SSH host.

For a dynamic forward you only set the Local Port — the Remote Host and Remote Port fields disappear because the destination is chosen per-connection by the client.

Dynamic (SOCKS5) forward examplebash
Forward Type:  Dynamic (-D) SOCKS5
Local Port:    1080
Bind Address:  127.0.0.1

# Use it as a SOCKS5 proxy:
curl --socks5-hostname 127.0.0.1:1080 https://10.0.0.5/api/status
SOCKS5 details

The proxy implements SOCKS5 (RFC 1928) with the no-authentication method only and the CONNECT command. It accepts IPv4, IPv6, and domain-name target address types. Prefer --socks5-hostname (or your browser's "proxy DNS over SOCKS" option) so name resolution happens at the SSH host, not locally.

Remote Forward (-R)

Remote forwarding (-R) exposes a service running on your machine to the SSH host. The forward type appears in the selector for completeness, and the spec renders as R :remote_port → localhost:local_port.

Remote forwarding is not yet implemented

Starting a remote forward currently returns Remote forwarding not yet implemented from the Agent. For now, use Local (-L) or Dynamic (-D) forwards.

Persistent Tunnels

Persistent tunnels are managed in Settings → SSH Tunnels (the same view opens from Manage Tunnels... at the bottom of the popover). Each tunnel is a saved definition with its own SSH target, credential profile, optional jump, forward configuration, and reconnect policy.

Standalone vs. Controller (Enterprise) mode

Jump selection on a tunnel is a standalone-mode concept. In Controller (Enterprise) mode the Controller is the egress/jump for every connection, so the per-tunnel jump selector is hidden entirely and the Tunnels button is not shown in the status bar — tunnels there are created differently. The rest of this page describes standalone (Personal Mode) behavior.

Creating a Tunnel

  1. Open Settings → SSH Tunnels and click + New Tunnel.
  2. Give it a Name and enter the SSH Host and Port (defaults to 22).
  3. Choose a Credential Profile. The profile supplies the username and either a password or an SSH key (with optional passphrase). A profile is required.
  4. Optionally set a Jump. Leave it on Inherit from profile to reuse the profile's jump, or pick a specific jump host or another session as the jump endpoint.
  5. Pick the Forward Type and fill in Local Port, Bind Address, and (for Local) Remote Host / Remote Port.
  6. Set the toggles: Auto-start, Auto-reconnect, and Max retries.
  7. Click Create Tunnel. The new tunnel appears in the list with a status dot and Start / Edit / Delete actions.
Newly added profiles and jumps show up immediately

Opening the form refreshes the credential-profile, jump-host, and session dropdowns. If you just created a profile or jump host elsewhere, it will be available here without restarting the app.

The Agent stores tunnels via a small REST API on the Local Agent. The create call is a POST /tunnels with a JSON body like this:

POST /tunnelsjson
{
  "name": "Switch UI",
  "host": "bastion.example.net",
  "port": 22,
  "profile_id": "prof_abc123",
  "jump_host_id": null,
  "jump_session_id": null,
  "forward_type": "local",
  "local_port": 8443,
  "bind_address": "127.0.0.1",
  "remote_host": "10.0.0.5",
  "remote_port": 443,
  "auto_start": false,
  "auto_reconnect": true,
  "max_retries": 5
}

For a dynamic forward, set "forward_type": "dynamic" and leave remote_host and remote_port as null — the destination is chosen per-connection over SOCKS5.

Tunnel Field Reference

name
Display name for the tunnel. Required.
host / port
The SSH server to forward through. Port defaults to 22. Required.
profile_id
The credential profile used to authenticate to host. Supplies username and password or key (and passphrase). Required.
jump_host_id / jump_session_id
An optional jump. Mutually exclusive — at most one is set. When both are null, the tunnel inherits the jump configured on its profile. The jump authenticates with its own credentials and the connection is routed through it via SSH ProxyJump.
forward_type
"local", "dynamic", or "remote" (remote not yet implemented).
local_port
The port the listener binds on your machine.
bind_address
The local interface to bind. Defaults to 127.0.0.1. Must be a loopback address — see Security.
remote_host / remote_port
Required for Local forwards; the fixed destination reached from the SSH host. Sent as null for Dynamic forwards.
auto_start
When true, the tunnel starts automatically with the Agent (and is shown with an auto-start badge). Default off.
auto_reconnect
When true, a dropped connection is retried with exponential backoff. Default on.
max_retries
Maximum reconnect attempts. Default 5. A value of 0 means unlimited retries.

Session Tunnels

A saved session can carry its own list of port forwards. When you connect that session, the Agent automatically starts a tunnel for each enabled forward, and stops them all when you disconnect. These are the Session Tunnels grouped under their host in the popover.

Session tunnels differ from persistent tunnels in a few important ways:

  • They are ephemeral — tied to the live session, not saved to the tunnel list. Their internal ids are prefixed session: and they are named automatically, e.g. Session forward :8080.
  • They inherit the session's resolved jump (host or session jump), so they travel the same path as the terminal and can share the pooled SSH connection.
  • They start with auto_start: false, auto_reconnect: false, and max_retries: 0 — their lifecycle follows the session, so there is no independent toggle or context menu for them in the popover.
  • Each forward's bind_address defaults to 127.0.0.1 when not set on the session.

While a session with forwards is connected, the status bar shows a small row of colored forward dots labeled fwd, one per session tunnel, each tooltip showing its spec and status.

Configure session forwards on the session

Session forwards live in the session's settings (the port-forwarding section), with the same three types: Local (-L), Remote (-R), and Dynamic (-D). See Connecting to Devices for editing a saved session.

The Tunnel Popover

Click Tunnels in the status bar to open the popover. The button shows a badge with the active tunnel count, or, if any tunnel has failed, the failed count in an error color. The popover focuses a search box and lists everything in two groups:

  • Tunnels — your persistent tunnels, each with a name, spec, the SSH host (via host), uptime, and an on/off toggle.
  • Session Tunnels — ephemeral forwards grouped by host, shown read-only (no toggle, since they follow the session).

The search box filters by tunnel name or by its formatted spec, so you can type a port like 8443 or a destination to narrow the list. A colored status dot precedes each row, and a reconnecting tunnel additionally shows retry N.

Toggling and the right-click menu

Click a persistent tunnel's toggle to start or stop it. If it is connected, connecting, or reconnecting, the toggle stops it; otherwise it starts it. Right-click a persistent tunnel for a context menu:

  • Pause — stop a connected tunnel.
  • Reconnect — shown for a failed or disconnected tunnel; forces a fresh connection.
  • Copy local address — copies bind_address:local_port to the clipboard.
  • Edit — opens Manage Tunnels with this tunnel loaded.

Session tunnels have no context menu — right-clicking them does nothing because their lifecycle belongs to the session. The footer always offers Manage Tunnels..., which opens the full settings view.

Keyboard

Esc closes the context menu if one is open, otherwise it closes the popover. Clicking outside the popover also closes it.

Lifecycle, Pooling & Reconnect

Status values

A tunnel's status is one of:

  • disconnected — not running.
  • connecting — establishing the SSH connection / listener.
  • connected — listener is up and forwarding.
  • reconnecting — the connection dropped and backoff retries are in flight (with a retry_count).
  • failed — reconnect is disabled or all retries were exhausted; the tunnel stays visible with its last error.

Connection pooling

The Agent pools SSH connections by (host, port, profile_id, effective_jump). Two tunnels that resolve to the same SSH target and the same jump share one SSH connection — for example a Local forward and a SOCKS5 forward to the same bastion ride the same transport. When the last tunnel using a pooled connection stops, the Agent closes it.

Health monitor and reconnect

A background health monitor probes every pooled connection roughly every 30 seconds by opening a lightweight session channel (with a 10-second probe timeout). The probe never requests a shell or exec, so it works even for tunnel-only accounts (no-pty / forced-command). If a connection is dead, the Agent reacts per-tunnel:

  • Auto-reconnect on — the tunnel goes to reconnecting and is retried with exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, 60s (capped at 60s), up to max_retries (0 = unlimited).
  • Auto-reconnect off — the listener is torn down and the tunnel is marked failed with Connection lost (auto_reconnect disabled) so it stays visible rather than vanishing.

The frontend polls tunnel status about every 5 seconds, which is what keeps the popover dots, uptime, and retry counts current. Uptime is reported in seconds and rendered compactly as s / m / h / d.

Reconnect is automatic; Reconnect (menu) is manual

The exponential-backoff reconnect above is driven by the health monitor. The Reconnect entry in the right-click menu (shown on failed/disconnected tunnels) is a manual one-shot that forces a fresh connection immediately via POST /tunnels/{id}/reconnect.

Security: Loopback-Only Binding

NetStacks only allows loopback bind addresses for tunnels. Both the Agent and the settings form reject anything that is not in 127.0.0.0/8 or ::1. The default is 127.0.0.1.

This is deliberate. Binding a tunnel — especially a SOCKS5 dynamic forward — to 0.0.0.0 or a LAN-facing interface would turn your workstation into an unauthenticated pivot proxy that anyone on the network could use to reach hosts behind the bastion. The Agent therefore returns a 400 for a non-loopback bind, and the form blocks it client-side with an immediate error so you do not even round-trip:

bind_address '0.0.0.0' must be a loopback address (127.0.0.0/8 or ::1) —
binding tunnels to non-loopback exposes them to the LAN with no auth
Do not try to expose tunnels to the LAN

If you genuinely need other machines to reach a forwarded service, terminate the tunnel locally and put an authenticated reverse proxy in front of it — never bind the tunnel itself to a non-loopback address. The loopback rule cannot be overridden from the UI.

Tunnels also inherit the rest of the Agent's SSH posture: host-key verification and jump-host trust apply exactly as they do for terminal sessions. See Connecting to Devices and SSH Passwords & Keys.

Examples

Use a local forward in the browser

# Tunnel: Local (-L), local_port 8443 -> 10.0.0.5:443 via bastion
# Open the device UI through the loopback listener:
open https://127.0.0.1:8443        # macOS
xdg-open https://127.0.0.1:8443    # Linux

Reach a service through a local forward with curl

curl -k https://127.0.0.1:8443/restconf/data/...

Use a dynamic (SOCKS5) forward

# Tunnel: Dynamic (-D), local_port 1080
# Resolve names at the SSH host with --socks5-hostname:
curl --socks5-hostname 127.0.0.1:1080 https://internal-host.example/api

# Point a CLI tool at the proxy via env var (where supported):
ALL_PROXY=socks5h://127.0.0.1:1080 curl https://internal-host.example/api

Create a dynamic forward via the Agent API

POST /tunnels (dynamic)json
{
  "name": "Bastion SOCKS",
  "host": "bastion.example.net",
  "port": 22,
  "profile_id": "prof_abc123",
  "forward_type": "dynamic",
  "local_port": 1080,
  "bind_address": "127.0.0.1",
  "remote_host": null,
  "remote_port": null,
  "auto_start": true,
  "auto_reconnect": true,
  "max_retries": 0
}

Tunnel control endpoints (Local Agent)

GET    /tunnels                       # list tunnels with runtime state
POST   /tunnels                       # create a tunnel
PUT    /tunnels/{id}                  # update a tunnel
DELETE /tunnels/{id}                  # delete a tunnel
POST   /tunnels/{id}/start            # start one tunnel
POST   /tunnels/{id}/stop             # stop one tunnel
POST   /tunnels/{id}/reconnect        # force a fresh reconnect
GET    /tunnels/status                # runtime state only (polled ~5s)
POST   /tunnels/start-all             # start all auto-start tunnels
POST   /tunnels/stop-all              # stop every running tunnel

Q&A

What is the difference between a persistent tunnel and a session tunnel?
A persistent tunnel is a saved, named definition you start/stop yourself (and can auto-start and auto-reconnect). A session tunnel is an ephemeral forward attached to a saved session's port-forward list; it starts when you connect that session and stops when you disconnect, inheriting the session's jump.
Why can't I bind a tunnel to 0.0.0.0?
Only loopback addresses (127.0.0.0/8 or ::1) are allowed. A LAN-facing bind would expose the forward — especially a SOCKS5 proxy — as an unauthenticated pivot. Both the form and the Agent reject non-loopback binds. The default is 127.0.0.1.
Does the SOCKS5 proxy require credentials?
No. The dynamic forward implements SOCKS5 with the no-authentication method and the CONNECT command. Because it has no auth of its own, it is locked to loopback so only processes on your machine can use it.
What does "auto-reconnect" actually do?
When a pooled SSH connection is detected dead (probed every ~30s), tunnels with auto-reconnect retry with exponential backoff (1s up to 60s) until they succeed or hit max_retries (0 = unlimited). With it off, the tunnel is marked failed instead.
Two of my tunnels go to the same bastion. Are they separate connections?
No — the Agent pools by (host, port, profile, jump), so tunnels that resolve to the same target and jump share one SSH connection. The connection closes once the last tunnel using it stops.
Can I do remote (-R) forwarding?
Not yet. The Remote type is selectable but the Agent returns Remote forwarding not yet implemented. Use Local (-L) or Dynamic (-D) forwards.
Why don't I see a Tunnels button or a jump selector?
You are likely in Controller (Enterprise) mode, where the Controller is the egress/jump for all connections. The per-tunnel jump selector and the status-bar Tunnels button are hidden in that mode; tunnels are created differently.
How do I copy a tunnel's local address?
Right-click the persistent tunnel in the popover and choose Copy local address — it copies bind_address:local_port to the clipboard.

Troubleshooting

"Bind address must be a loopback address"

You entered something other than 127.0.0.1, another 127.0.0.0/8 address, or ::1. Loopback is required by design; change the bind address back to 127.0.0.1.

"No password found" or "No SSH key path found"

The tunnel's credential profile is missing the secret it needs. For a password profile, add a password; for a key profile, set a key path (and passphrase if the key is encrypted) in the profile's settings, then start the tunnel again. See SSH Passwords & Keys.

Tunnel keeps going to "reconnecting" then "failed"

The SSH connection is dropping faster than it can stabilize, or all max_retries were exhausted. Check the SSH host/port, the jump path, and that the account allows the channel the probe needs. The tunnel card shows the last error; hover it for the full message. Increase max_retries (or set 0 for unlimited) if the remote is simply slow to come back.

"Tunnel is already running"

You tried to start a tunnel that is already active. Stop it first (or use the toggle, which flips based on current status).

SOCKS5: "Client does not support no-auth method"

Your client offered only authenticated SOCKS5 methods. Configure the client to use SOCKS5 with no authentication (it is loopback-only, so this is safe).

Remote forward fails immediately

Remote (-R) forwarding is not yet implemented and returns an error on start. Switch the forward type to Local or Dynamic.