7.2 KiB
autossh-mgr Design Spec
Date: 2026-05-20 Status: Approved
Overview
autossh-mgr is a Python + Click CLI tool for managing multiple autossh reverse SSH tunnels. Each tunnel forwards a local service port to a port on a public server via a persistent SSH reverse tunnel (-R flag). The CLI is a one-shot command tool — not a daemon. Each tunnel runs as an independent autossh background process tracked via PID files.
Project Structure
autossh-mgr/
├── pyproject.toml
├── src/
│ └── autossh_mgr/
│ ├── __init__.py
│ ├── __main__.py # calls cli() — enables python -m autossh_mgr
│ ├── cli.py # Click group + all 11 commands (thin wrappers)
│ ├── config.py # TunnelConfig dataclass + YAML read/write
│ ├── process.py # start/stop/status/PID file logic
│ ├── check.py # SSH connectivity verification
│ └── display.py # table/status output formatting
└── tests/
├── unit/
│ ├── test_config.py
│ ├── test_process.py
│ └── test_check.py
└── integration/
└── test_lifecycle.py
Packaging: pyproject.toml with src/ layout. Script entry point: autossh-mgr = "autossh_mgr.cli:cli". Runtime dependencies: click, pyyaml. Installed via uv tool install.
Config Directory
~/.config/autossh-mgr/
autossh-mgr/
├── tunnels.yaml # all tunnel configurations
├── pids/
│ └── <name>.pid # one per tunnel: "<pid>\n<iso_timestamp>"
└── logs/
└── <name>.log # autossh stdout/stderr
Data Model
@dataclass
class TunnelConfig:
name: str # unique kebab-case identifier
host: str # public server hostname/IP
user: str # SSH login user
local_port: int # local service port
remote_port: int # public server listening port
port: int = 22 # public server SSH port
identity_file: str = "~/.ssh/id_ed25519"
local_host: str = "127.0.0.1"
remote_host: str = "0.0.0.0"
ssh_options: str = ""
tunnels.yaml top-level structure: tunnels: [...]. Optional fields at their defaults are omitted when writing to keep the file readable.
PID file format: two lines — <pid>\n<start_timestamp_iso>. The timestamp enables uptime calculation with no extra dependencies.
autossh Command
autossh -M 0 -N \
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
-i <identity_file> -p <port> \
-R <remote_host>:<remote_port>:<local_host>:<local_port> \
<user>@<host>
[ssh_options]
-M 0disables autossh monitor port; SSH ServerAlive handles keepalive detection.-Nskips remote command execution; port forwarding only.AUTOSSH_GATETIME=0is set in the subprocess environment so autossh backgrounds immediately without waiting for the initial connection to succeed.
Module Responsibilities
config.py
load_tunnels(config_dir) -> list[TunnelConfig]save_tunnels(config_dir, tunnels)— atomic write (write to.tmp, rename)get_tunnel(config_dir, name) -> TunnelConfig— raisesClickExceptionif not foundensure_dirs(config_dir)— creates directory structure; idempotent
process.py
start_tunnel(config_dir, tunnel)— launches autossh viasubprocess.Popenwith log file as stdout/stderr; writes PID file (fsync'd); waits 1s; confirms process still alive viaos.kill(pid, 0).stop_tunnel(config_dir, name)— reads PID file; SIGTERM; polls up to 5s; SIGKILL if needed; removes PID file.get_status(config_dir, name) -> TunnelStatus— returns dataclass withstate(running / stopped / stale),pid,uptime.check_stale(config_dir, name) -> bool— True if PID file exists but process is dead.
check.py
check_connectivity(tunnel)— runsssh -o BatchMode=yes -o ConnectTimeout=5 -i <key> -p <port> <user>@<host> true; reports success or SSH error.
display.py
print_tunnel_table(tunnels, statuses)— Name | Server | Ports | Statusprint_tunnel_detail(tunnel, status)— all fields + runtime infoprint_status_table(tunnels, statuses)— Name | State | PID | Uptime
cli.py
Thin Click command wrappers. Calls domain functions, passes results to display.py. Checks shutil.which("autossh") at startup and aborts with a clear error if not found.
Commands
| Command | Description |
|---|---|
init |
Create ~/.config/autossh-mgr/ structure; idempotent |
add <name> |
Interactive prompts or --host/--user/--local-port/--remote-port flags; validates name uniqueness |
remove <name> |
Confirm y/N; error if tunnel is running |
list |
Table: Name, Server, Ports, Status |
show <name> |
All fields, one per line |
config <name> <key> <value> |
Update one field; validates key and value type |
start [name] |
Single or all (alphabetical); stale PID cleanup; already-running skip; 1s PID alive check |
stop [name] |
Single or all running; stale PID cleaned silently |
restart [name] |
stop then start; single or all (alphabetical order) |
status [name] |
Table (all) or detail (single): state, PID, uptime (1d 2h 3m), mapping |
check <name> |
SSH connectivity test without starting the tunnel |
Error Handling
All user-facing errors raise click.ClickException (prints Error: <msg>, exits code 1).
| Scenario | Message |
|---|---|
autossh not in PATH |
autossh not found. Install it first. |
| Tunnel name not found | No tunnel named '<name>' |
Duplicate name on add |
Tunnel '<name>' already exists |
remove while running |
Stop '<name>' before removing it |
config with unknown key |
Unknown field '<key>' |
config <name> name <value> |
Cannot rename a tunnel via config. Remove and re-add. |
| Local port already in use | Port <port> is already in use |
| Corrupt PID file | Corrupt PID file for '<name>', try: autossh-mgr stop <name> |
Idempotency: start on a running tunnel and stop on a stopped tunnel produce informational messages, not errors.
Testing
Unit tests (tests/unit/)
tmp_pathpytest fixture for isolated filesystem.unittest.mock.patchforsubprocess.Popenandos.kill.test_config.py— YAML round-trips, missing file handling, field validation.test_process.py— start/stop/stale PID paths, SIGTERM→SIGKILL escalation.test_check.py— SSH command construction, success/failure parsing.
Integration tests (tests/integration/)
- Substitute
autosshwith a dummy shell script (#!/bin/sh\nexec sleep 60) to exercise the full PID file lifecycle without a real SSH connection. - Tests: start writes correct PID file, status reads running state and uptime, stop kills process and removes PID file, stale PID detected and cleaned on next start.
Non-Functional Requirements
- Cross-platform: Linux and macOS.
- No passwords: SSH key auth only; no secrets in config files.
- Logs: autossh output redirected to
~/.config/autossh-mgr/logs/<name>.log. - Atomic config writes: write to
.tmpthen rename to avoid corruption on crash.