Files
autossh-mgr/docs/superpowers/specs/2026-05-20-autossh-mgr-design.md
tech 84514afe57 Add autossh-mgr design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 21:19:25 +08:00

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 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 <key> -p <port> <user>@<host> 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 <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_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/<name>.log.
  • Atomic config writes: write to .tmp then rename to avoid corruption on crash.