Add autossh-mgr design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
167
docs/superpowers/specs/2026-05-20-autossh-mgr-design.md
Normal file
167
docs/superpowers/specs/2026-05-20-autossh-mgr-design.md
Normal file
@@ -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/
|
||||||
|
│ └── <name>.pid # one per tunnel: "<pid>\n<iso_timestamp>"
|
||||||
|
└── logs/
|
||||||
|
└── <name>.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 — `<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.
|
||||||
Reference in New Issue
Block a user