Skip to main content

SSH tunnel server + agent with reverse port forwarding

Project description

ssh-tunnel-gateway

Single Python package that ships both the server and agent CLIs.

Project Description

ssh-tunnel-gateway provides a gateway server (ssh-tunnel-server) and an agent (ssh-tunnel-agent) for reverse SSH access to hosts that are not directly reachable from clients.

The server manages control-plane registration over HTTP and allocates a tunnel port (port_b).
The agent establishes and maintains the SSH reverse tunnel data plane to that allocated port.

Design Philosophy

  • Use a public gateway host as a bastion to reach private/internal servers that do not expose inbound SSH.
  • Use standard SSH as the transport layer for mature security properties and operational reliability.
  • Stay native to SSH workflows so teams can integrate with existing clients, keys, config files, and ProxyJump.
  • Keep control-plane logic minimal: HTTP API only for registration, identity, and lease lifecycle.

Usage (Start Here)

Server foreground:

API_KEY="change-me" ssh-tunnel-server

Server systemd mode:

API_KEY="change-me" ssh-tunnel-server -d

Agent foreground:

ssh-tunnel-agent --api-key change-me --endpoint http://server:12000

Agent foreground (control plane over SSH config host alias):

ssh-tunnel-agent --api-key change-me --endpoint http://127.0.0.1:12000 --over-ssh GWServer

Example SSH config alias:

Host GWServer
    HostName <gateway_public_ip_or_dns>
    User <gateway_ssh_user>
    Port 22

Agent systemd mode:

ssh-tunnel-agent -d --api-key change-me --endpoint http://server:12000

Notes:

  • Server auth supports either API_KEY (single key) or API_KEYS (comma-separated multiple keys).
  • Restarting ssh-tunnel-server does not restart active SSH tunnels; existing tunnels stay up as long as the underlying SSH session to gateway sshd remains alive.
  • --over-ssh <ssh_config_host> makes agent reach API through SSH local forwarding over port 22, so gateway 12000 does not need to be publicly exposed.
  • --over-ssh <ssh_config_host> is used only when that host alias exists in SSH config (~/.ssh/config by default, or SSH_CONFIG_PATH).
  • --over-ssh alias matching is intentionally strict by design; if alias check does not pass, agent falls back to standard endpoint mode.
  • In --over-ssh mode, API_URL host/IP is ignored for compatibility; agent uses only the API_URL port and forwards to gateway 127.0.0.1:<port>.
  • If the alias is not found, --over-ssh is ignored for both control-plane API and default tunnel host selection.
  • In --over-ssh mode, agent prefers the same local endpoint port as API_URL; if that local port is unavailable, it falls back to a free local port.
  • If --agent-id is not provided, agent generates a UUID once and caches it locally for future restarts.
  • Agent writes current session info (including port_b) to ${STATE_DIR}/session.json by default (~/.ssh-tunnel/session.json).
  • Server reuses the same port_b for the same agent_id when possible.
  • If the previous port is busy, server tries to reclaim (kill listener on that port) and reuse it.
  • If reclaim fails, server rotates to a new free port.
  • Default reverse bind host is 0.0.0.0 (public bind on gateway).
  • Use --reverse-bind-host 127.0.0.1 if you want loopback-only bind for strict bastion usage.
  • If not set, agent follows the server-provided reverse bind host.
  • In -d mode, register/startup failures exit immediately so systemd can restart the unit.

Install

On the gateway server host:

pip install ssh-tunnel-gateway

On each agent host:

pip install ssh-tunnel-gateway

Quick Start (ProxyJump)

  1. On the gateway server host, start server:
API_KEY="change-me" ssh-tunnel-server

Or with multiple API keys:

API_KEYS="key1,key2,key3" ssh-tunnel-server

With default public reverse bind (AGENT_REVERSE_BIND_HOST=0.0.0.0), gateway sshd_config must include:

  • AllowTcpForwarding remote
  • GatewayPorts clientspecified

To avoid exposing gateway port 12000, run server API on localhost:

API_KEY="change-me" SERVER_HOST=127.0.0.1 SERVER_PORT=12000 ssh-tunnel-server
  1. On the agent host, start agent:
ssh-tunnel-agent --api-key change-me --endpoint http://<gateway_host>:12000

Or over SSH control tunnel (no public 12000):

ssh-tunnel-agent --api-key change-me --endpoint http://127.0.0.1:12000 --over-ssh GWServer
  1. Get port_b from the agent side:
  • Foreground mode prints a log line with port_b.
  • Foreground logs also print:
    • ssh_user: tunnel login user returned by server.
    • jump_user: ProxyJump user returned by server.
    • agent_user_hint: defaults to local $USER on the agent host.
  • Session file is always written to ${STATE_DIR}/session.json (default ~/.ssh-tunnel/session.json):
cat ~/.ssh-tunnel/session.json

Get only port_b:

python3 -c 'import json, os; print(json.load(open(os.path.expanduser("~/.ssh-tunnel/session.json")))["port_b"])'
  1. Add user SSH config (~/.ssh/config):
Host GWServer
    HostName <gateway_public_ip_or_dns>
    User <gateway_ssh_user>
    Port 22

Host AgentServer
    HostName localhost
    User <agent_ssh_user>
    Port <port_b_from_agent_session_json>
    ProxyJump GWServer
  1. Connect:
ssh AgentServer

Systemd Usage

Server unit (/etc/systemd/system/ssh-tunnel-server.service):

[Unit]
Description=ssh-tunnel-gateway server
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=gw-tunnel
WorkingDirectory=/home/gw-tunnel
EnvironmentFile=/etc/ssh-tunnel/server.env
ExecStart=/usr/local/bin/ssh-tunnel-server -d
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

Example server env (/etc/ssh-tunnel/server.env):

API_KEY=change-me
# Or multiple keys:
# API_KEYS=key1,key2,key3
STATE_DIR=/home/gw-tunnel/.ssh-tunnel/server
SERVER_PORT=12000
SSH_USER=gw-tunnel
SSH_JUMP_USER=li
SSH_PUBLIC_HOST=gateway.example.com
AUTHORIZED_KEYS_PATH=/home/gw-tunnel/.ssh/authorized_keys
AGENT_REVERSE_BIND_HOST=0.0.0.0

Agent unit (/etc/systemd/system/ssh-tunnel-agent.service):

[Unit]
Description=ssh-tunnel-gateway agent
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=li
WorkingDirectory=/home/li
EnvironmentFile=/etc/ssh-tunnel/agent.env
ExecStart=/usr/local/bin/ssh-tunnel-agent -d
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

Example agent env (/etc/ssh-tunnel/agent.env):

API_KEY=change-me
API_URL=http://<gateway_host>:12000
SSH_HOST=<gateway_host>
SSH_PORT=22
STATE_DIR=/home/li/.ssh-tunnel
SSH_CONFIG_PATH=/home/li/.ssh/config
LOCAL_TARGET_HOST=127.0.0.1
LOCAL_TARGET_PORT=22
# Optional control over SSH config alias mode:
# OVER_SSH=GWServer
# API_URL=http://127.0.0.1:12000

Enable and start:

sudo systemctl daemon-reload
sudo systemctl enable --now ssh-tunnel-server
sudo systemctl enable --now ssh-tunnel-agent

Note:

  • In systemd, prefer absolute paths in env files (for example /home/li/.ssh-tunnel), instead of ~.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

ssh_tunnel_gateway-0.4.2.tar.gz (20.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ssh_tunnel_gateway-0.4.2-py3-none-any.whl (22.4 kB view details)

Uploaded Python 3

File details

Details for the file ssh_tunnel_gateway-0.4.2.tar.gz.

File metadata

  • Download URL: ssh_tunnel_gateway-0.4.2.tar.gz
  • Upload date:
  • Size: 20.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.11.14

File hashes

Hashes for ssh_tunnel_gateway-0.4.2.tar.gz
Algorithm Hash digest
SHA256 875b82eda20aa53f89e69d4076b1d24cc2d4baf647d04e6cd28506a572ff28a0
MD5 ed269ab4ac62934c5be1a2c45d1c78d0
BLAKE2b-256 3e90a1492138074d7fc33380b93d0ab99100940d6274df1d48102dc915cdcc8b

See more details on using hashes here.

File details

Details for the file ssh_tunnel_gateway-0.4.2-py3-none-any.whl.

File metadata

File hashes

Hashes for ssh_tunnel_gateway-0.4.2-py3-none-any.whl
Algorithm Hash digest
SHA256 c4f498727c8035df53a2524c60dfb86ae35b1da2b6afbd1a194583e03be93ec5
MD5 0939d12d5d01fdd3ce87f500260a20a0
BLAKE2b-256 e6dc7d1de56831bf151a9b23186152f37d9e586e703ce392a8f5c440be577b1a

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page