Add autossh-mgr design spec

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 21:19:25 +08:00
commit 84514afe57

View 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.