commit 84514afe57a4866e91722a7251a524e7db694fd3 Author: tech Date: Wed May 20 21:19:25 2026 +0800 Add autossh-mgr design spec Co-Authored-By: Claude Sonnet 4.6 diff --git a/docs/superpowers/specs/2026-05-20-autossh-mgr-design.md b/docs/superpowers/specs/2026-05-20-autossh-mgr-design.md new file mode 100644 index 0000000..fec23b0 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-autossh-mgr-design.md @@ -0,0 +1,167 @@ +# 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.