ssh-tunnel-gateway 0.4.3
SSH tunnel server + agent with reverse port forwarding
Requires Python: >=3.10

# ssh-tunnel-gateway

Single Python package that ships both CLIs:
- `ssh-tunnel-server` (control plane + port allocation)
- `ssh-tunnel-agent` (reverse SSH tunnel on private host)

This project uses a public gateway as a bastion to reach private/internal servers over standard SSH.

## Install

Install on both gateway and agent hosts:

```bash
pip install ssh-tunnel-gateway
```

## SKILLS.md

Session skill guide for coding/release agents is in `SKILLS.md`.

## Quick Start (Recommended)

1. Start gateway server:

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

2. Start agent:

```bash
ssh-tunnel-agent --api-key change-me --endpoint http://<gateway_host>:12000
```

3. Read allocated `port_b` from agent session file:

```bash
cat ~/.ssh-tunnel/session.json
```

```bash
python3 -c 'import json, os; print(json.load(open(os.path.expanduser("~/.ssh-tunnel/session.json")))["port_b"])'
```

4. Add client SSH config (`~/.ssh/config`):

```sshconfig
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>
    ProxyJump GWServer
```

5. Connect:

```bash
ssh AgentServer
```

## Control Plane Modes

### Standard mode

Agent calls HTTP API directly:

```bash
ssh-tunnel-agent --api-key change-me --endpoint http://<gateway_host>:12000
```

Gateway requirements:
- `22/tcp` for SSH data plane
- `12000/tcp` for HTTP control plane

### `--over-ssh` mode

Agent reaches control plane through SSH local forwarding (no public `12000` required):

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

SSH alias example:

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

Important behavior:
- Alias match is strict against SSH config (`~/.ssh/config` by default, or `SSH_CONFIG_PATH`).
- If alias is missing, agent falls back to standard endpoint mode.
- In `--over-ssh` mode, `API_URL` host/IP is ignored; only the API port is used.
- Agent prefers binding the same local API port first; if unavailable, it picks a free local port.

## Systemd Deployment

Use `-d` for systemd (run once and let systemd restart on failure).

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

```ini
[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
```

Server env (`/etc/ssh-tunnel/server.env`):

```bash
# Required:
API_KEY=change-me

# Optional (use instead of API_KEY for multiple keys):
# API_KEYS=key1,key2,key3

# Optional overrides:
# STATE_DIR=/home/gw-tunnel/.ssh-tunnel/server
# SERVER_HOST=0.0.0.0
# 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`):

```ini
[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
```

Agent env (`/etc/ssh-tunnel/agent.env`):

```bash
# Required:
API_KEY=change-me
API_URL=http://<gateway_host>:12000

# Optional overrides:
# STATE_DIR=/home/li/.ssh-tunnel
# SSH_CONFIG_PATH=/home/li/.ssh/config
# SSH_HOST=<gateway_host>
# SSH_PORT=22
# LOCAL_TARGET_HOST=127.0.0.1
# LOCAL_TARGET_PORT=22
# REVERSE_BIND_HOST=0.0.0.0

# Optional control-plane over SSH (if enabled, API_URL host is ignored and port is used):
# OVER_SSH=GWServer
# API_URL=http://127.0.0.1:12000
```

Enable services:

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

Systemd note:
- Use absolute paths in env files (`/home/<user>/...`) instead of `~`.

## Operational Behavior

- Server API authentication supports `API_KEY` or `API_KEYS` (comma-separated).
- API endpoint is `POST /` with JSON `action`:
- `register`: allocate or reuse `port_b`, return encrypted key material.
- `heartbeat`: refresh lease.
- Agent keeps a stable local `agent_id` (UUID) unless explicitly overridden.
- Agent writes session state to `${STATE_DIR}/session.json` (default `~/.ssh-tunnel/session.json`).
- Server keeps `agent_id -> port_b` mapping and reuses the same port when possible.
- If reused port is busy, server tries reclaiming it; if reclaim fails, server allocates a new one.
- Lease timeout is `7` days by default (`LEASE_TTL_DAYS`); expired agents are cleaned from state and `authorized_keys`.
- Restarting `ssh-tunnel-server` does not restart existing tunnel processes in `sshd`; active tunnels stay up while their SSH sessions stay up.

## SSHD Requirements (Gateway)

For default public reverse bind (`AGENT_REVERSE_BIND_HOST=0.0.0.0`):

```sshconfig
AllowTcpForwarding remote
GatewayPorts clientspecified
```

For loopback-only reverse bind (`AGENT_REVERSE_BIND_HOST=127.0.0.1`):
- `AllowTcpForwarding remote` is still required.

## Security Guidance

- Use a dedicated SSH tunnel user on gateway (for example `gw-tunnel`).
- Block shell/TTY for that user and allow only remote port forwarding.
- Keep client jump access (`SSH_JUMP_USER`) separate from tunnel login (`SSH_USER`) when needed.

Example `sshd_config` hardening:

```sshconfig
Match User gw-tunnel
    PasswordAuthentication no
    PubkeyAuthentication yes
    PermitTTY no
    X11Forwarding no
    AllowAgentForwarding no
    PermitUserRC no
    AllowTcpForwarding remote
```

---

## Agent Skills (From `SKILLS.md`)

### Scope

- Single package: `ssh-tunnel-gateway`
- Two CLIs: `ssh-tunnel-server`, `ssh-tunnel-agent`
- Transport model: SSH data plane (`22`) + HTTP control plane (`12000`) or `--over-ssh`

### Design Intent

- Keep SSH native (`ProxyJump`, standard ssh config, standard sshd settings).
- Keep control plane minimal (`POST /` with `action=register|heartbeat`).
- Keep agent identity stable (cached `agent_id`) and reuse `port_b` when possible.
- Prefer explicit and operationally safe defaults.

### Runtime Defaults

- Agent state directory default: `~/.ssh-tunnel`
- Agent session file default: `~/.ssh-tunnel/session.json`
- Agent key file default: `~/.ssh-tunnel/agent.pem`
- Agent id file default: `~/.ssh-tunnel/agent_id`
- Lease cleanup default: `7` days (`LEASE_TTL_DAYS`)

### `--over-ssh` Rules

- `--over-ssh <alias>` must match an alias in SSH config (`~/.ssh/config` or `SSH_CONFIG_PATH`).
- If alias is missing, fallback to standard endpoint mode.
- In `--over-ssh` mode, ignore `API_URL` host and use only its port.
- Use SSH alias directly as destination (do not force `user@alias`).

### Systemd Rules

- Use `-d` for both CLIs under systemd.
- Prefer absolute paths in env files.
- Set `User=` explicitly so home expansion resolves as expected.
- Keep optional env vars commented in example env files.

### Logging Expectations

- Agent logs register result with `agent_id`, `port_b`, `ssh_user`, `jump_user`, `jump_host`, and `connect_hint`.
- Server logs register/heartbeat with client IP and key operational fields.
- Foreground mode should provide enough info to debug without inspecting code.

### Release Checklist

1. Update `VERSION`.
2. Verify README reflects current behavior and flags.
3. Build: `make build`.
4. Verify CLI versions: `ssh-tunnel-server --version`, `ssh-tunnel-agent --version`.
5. Upload: `make upload`.
6. If PyPI says file exists, bump version and rebuild.

### Documentation Style

- Put usage and copy/paste commands before deep details.
- Keep server and agent examples separate and explicit.
- Document required env vars as active lines and optional ones as comments.