# 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/ │ └── .pid # one per tunnel: "\n" └── logs/ └── .log # autossh stdout/stderr ``` ## Data Model ```python @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 — `\n`. The timestamp enables uptime calculation with no extra dependencies. ## autossh Command ``` autossh -M 0 -N \ -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \ -i -p \ -R ::: \ @ [ssh_options] ``` - `-M 0` disables autossh monitor port; SSH ServerAlive handles keepalive detection. - `-N` skips remote command execution; port forwarding only. - `AUTOSSH_GATETIME=0` is 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` — raises `ClickException` if not found - `ensure_dirs(config_dir)` — creates directory structure; idempotent ### `process.py` - `start_tunnel(config_dir, tunnel)` — launches autossh via `subprocess.Popen` with log file as stdout/stderr; writes PID file (fsync'd); waits 1s; confirms process still alive via `os.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 with `state` (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)` — runs `ssh -o BatchMode=yes -o ConnectTimeout=5 -i -p @ true`; reports success or SSH error. ### `display.py` - `print_tunnel_table(tunnels, statuses)` — Name | Server | Ports | Status - `print_tunnel_detail(tunnel, status)` — all fields + runtime info - `print_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 ` | Interactive prompts or `--host/--user/--local-port/--remote-port` flags; validates name uniqueness | | `remove ` | Confirm y/N; error if tunnel is running | | `list` | Table: Name, Server, Ports, Status | | `show ` | All fields, one per line | | `config ` | 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 ` | SSH connectivity test without starting the tunnel | ## Error Handling All user-facing errors raise `click.ClickException` (prints `Error: `, exits code 1). | Scenario | Message | |---|---| | `autossh` not in PATH | `autossh not found. Install it first.` | | Tunnel name not found | `No tunnel named ''` | | Duplicate name on `add` | `Tunnel '' already exists` | | `remove` while running | `Stop '' before removing it` | | `config` with unknown key | `Unknown field ''` | | `config name ` | `Cannot rename a tunnel via config. Remove and re-add.` | | Local port already in use | `Port is already in use` | | Corrupt PID file | `Corrupt PID file for '', try: autossh-mgr stop ` | **Idempotency:** `start` on a running tunnel and `stop` on a stopped tunnel produce informational messages, not errors. ## Testing ### Unit tests (`tests/unit/`) - `tmp_path` pytest fixture for isolated filesystem. - `unittest.mock.patch` for `subprocess.Popen` and `os.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 `autossh` with 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/.log`. - **Atomic config writes:** write to `.tmp` then rename to avoid corruption on crash.