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) orAPI_KEYS(comma-separated multiple keys). - Restarting
ssh-tunnel-serverdoes not restart active SSH tunnels; existing tunnels stay up as long as the underlying SSH session to gatewaysshdremains alive. --over-ssh <ssh_config_host>makes agent reach API through SSH local forwarding over port22, so gateway12000does not need to be publicly exposed.--over-ssh <ssh_config_host>is used only when that host alias exists in SSH config (~/.ssh/configby default, orSSH_CONFIG_PATH).--over-sshalias matching is intentionally strict by design; if alias check does not pass, agent falls back to standard endpoint mode.- In
--over-sshmode,API_URLhost/IP is ignored for compatibility; agent uses only theAPI_URLport and forwards to gateway127.0.0.1:<port>. - If the alias is not found,
--over-sshis ignored for both control-plane API and default tunnel host selection. - In
--over-sshmode, agent prefers the same local endpoint port asAPI_URL; if that local port is unavailable, it falls back to a free local port. - If
--agent-idis 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.jsonby default (~/.ssh-tunnel/session.json). - Server reuses the same
port_bfor the sameagent_idwhen 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.1if you want loopback-only bind for strict bastion usage. - If not set, agent follows the server-provided reverse bind host.
- In
-dmode, 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)
- 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 remoteGatewayPorts 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
- 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
- Get
port_bfrom 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$USERon 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"])'
- 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
- 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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
875b82eda20aa53f89e69d4076b1d24cc2d4baf647d04e6cd28506a572ff28a0
|
|
| MD5 |
ed269ab4ac62934c5be1a2c45d1c78d0
|
|
| BLAKE2b-256 |
3e90a1492138074d7fc33380b93d0ab99100940d6274df1d48102dc915cdcc8b
|
File details
Details for the file ssh_tunnel_gateway-0.4.2-py3-none-any.whl.
File metadata
- Download URL: ssh_tunnel_gateway-0.4.2-py3-none-any.whl
- Upload date:
- Size: 22.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c4f498727c8035df53a2524c60dfb86ae35b1da2b6afbd1a194583e03be93ec5
|
|
| MD5 |
0939d12d5d01fdd3ce87f500260a20a0
|
|
| BLAKE2b-256 |
e6dc7d1de56831bf151a9b23186152f37d9e586e703ce392a8f5c440be577b1a
|