Compare commits

...

1 Commits

Author SHA1 Message Date
11302572e0 added initial spec 2026-08-13 15:21:13 +02:00

275
spec/porthole-spec.md Normal file
View File

@@ -0,0 +1,275 @@
# `porthole` — Spec v0.1
Named, managed SSH port forwards. Wraps `ssh -L/-R/-D` so forwards are
addressable by name instead of by PID, terminal tab, or shell history.
---
## 1. Overview
**Problem:** SSH forwards are anonymous and ephemeral. They die when a
terminal closes, when a laptop sleeps, or when a network blips — silently,
with no notification. There's no built-in way to list what's currently
forwarded, and multi-hop / reverse forwards have enough flag surface that
people end up hand-rolling shell aliases per-tunnel.
**Solution:** Persist forward definitions as named profiles. Run forwards as
a supervised background process (not tied to a shell session), with
auto-reconnect, health status, and a single command to see everything that's
open.
**Non-goals:** Not a replacement for a VPN or a full SOCKS/proxy manager.
Not a secrets manager — SSH auth still comes from your existing SSH config,
agent, or identity files. No GUI.
---
## 2. Data model
### 2.1 Profile
A saved definition. Does not imply anything is running.
| Field | Type | Notes |
|------------------|-----------|------------------------------------------|
| `name` | string | Unique key. `[a-z0-9_-]+`. |
| `kind` | enum | `local` \| `remote` \| `dynamic` |
| `mapping` | string | Raw `ssh -L/-R/-D`-style spec, see §3.1 |
| `via` | string[] | Ordered list of hops for multi-hop jumps |
| `user` | string? | Defaults to current user / `ssh_config` |
| `identity` | path? | Identity file override |
| `ssh_port` | int | Default `22` |
| `reconnect` | bool | Default `true` |
| `retry_interval` | int (sec) | Default `5` |
| `keepalive` | int (sec) | `ServerAliveInterval`, default `15` |
| `created_at` | timestamp | |
| `updated_at` | timestamp | |
### 2.2 Instance (runtime state)
Exists only while a profile is open. Tracked separately from the profile so
`list`/`status` can report live data without touching the saved definition.
| Field | Type | Notes |
|---------------------|------------|---------------------------------------------|
| `name` | string | FK to profile |
| `pid` | int | Supervisor process PID, not raw `ssh` PID |
| `state` | enum | `up` \| `down` \| `reconnecting` \| `error` |
| `opened_at` | timestamp | |
| `last_error` | string? | Most recent failure message, if any |
| `reconnect_count` | int | Since last manual `open` |
| `last_reconnect_at` | timestamp? | |
### 2.3 Storage
- Profiles: `~/.config/porthole/profiles/<name>.toml`
- Runtime state: `~/.local/state/porthole/<name>.json` (written by supervisor, not hand-edited)
- Logs: `~/.local/state/porthole/<name>.log`
---
## 3. Commands
### 3.1 `porthole add <name> [flags]`
Aliases: `create`, `new`
Saves a new profile. Does **not** open it.
| Flag | Arg | Required | Default | Description |
|--------------------|-----------------------------|-------------------|---------------------------|---------------------------------------|
| `-l, --local` | `[bind:]port:host:hostport` | one of `-l/-r/-d` | — | Local forward: your machine → remote |
| `-r, --remote` | `[bind:]port:host:hostport` | one of `-l/-r/-d` | — | Remote forward: remote → your machine |
| `-d, --dynamic` | `[bind:]port` | one of `-l/-r/-d` | — | Dynamic forward (SOCKS proxy) |
| `--via` | `host` | yes | — | SSH target; multiple for multi-hop |
| `-u, --user` | `user` | no | current user / ssh_config | |
| `-i, --identity` | `path` | no | ssh_config default | |
| `-p, --port` | `port` | no | `22` | SSH port on final target |
| `--reconnect` | `bool` | no | `true` | Auto-reconnect on drop |
| `--retry-interval` | `seconds` | no | `5` | Delay between reconnect attempts |
| `--keepalive` | `seconds` | no | `15` | `ServerAliveInterval` |
Exactly one of `-l`/`-r`/`-d` is required. Providing more than one is an
error.
**Validation:**
- `name` must not already exist (use `edit` to modify).
- `mapping` port syntax validated against the same grammar `ssh` accepts.
- `--via` hosts resolved/checked against `~/.ssh/config` if present, but not
required to exist there.
**Examples:**
```
porthole add db --local 5432:db.internal:5432 --via jumpbox
porthole add admin-ui --local 8080:localhost:8080 --via bastion1,bastion2 --user ops
porthole add webhook --remote 9000:localhost:3000 --via public-vps
porthole add proxy --dynamic 1080 --via edge-host
```
---
### 3.2 `porthole open <name> [flags]`
Alias: `start`
Starts a saved forward as a background-supervised process.
| Flag | Description |
|--------------------|-------------------------------------------------------------------------|
| `-f, --foreground` | Run attached in current shell instead of daemonizing. Ctrl-C closes it. |
| `--once` | Open without auto-reconnect, regardless of profile setting. |
**Behavior:**
- If already open: no-op, print current status, exit 0.
- If port bind fails (already in use): exit non-zero with the conflicting
process info if discoverable (`lsof`-style lookup), don't silently retry.
- Spawns a supervisor process that owns the underlying `ssh` subprocess,
watches for exit, and reconnects per profile settings.
**Examples:**
```
porthole open db
porthole open db --foreground
porthole open proxy --once
```
---
### 3.3 `porthole close <name> [flags]`
Alias: `stop`
Stops a running forward. Profile definition is untouched.
| Flag | Description |
|-----------|--------------------------------------------------------|
| `--force` | SIGKILL immediately instead of graceful SIGTERM + wait |
**Examples:**
```
porthole close db
porthole close db --force
```
---
### 3.4 `porthole edit <name> [flags]`
Updates a saved profile. Accepts the same flags as `add` (all optional —
only provided flags are changed). Does not restart a running instance
automatically; changes apply on next `open`.
**Examples:**
```
porthole edit db --retry-interval 10
porthole edit db --local 5433:db.internal:5432
```
If `<name>` is currently running, print a warning that changes won't take
effect until the next `open` (or offer `--restart` — see §6 open questions).
---
### 3.5 `porthole status <name>`
Deep-dive health for one forward.
**Output includes:**
- Profile summary (kind, mapping, via, user)
- Current state (`up` / `down` / `reconnecting` / `error`)
- Uptime since last successful connect
- Reconnect count and timestamp of last reconnect
- Last error message, if any
- Path to log file
**Example:**
```
porthole status db
```
---
### 3.6 `porthole list`
Alias: `ls`
All saved profiles with live status. Fast, scannable — no deep diagnostics
(use `status` for that).
**Columns:** `NAME KIND MAPPING VIA STATE UPTIME`
**Flags:**
| Flag | Description |
|-------------|-----------------------------------|
| `--running` | Show only currently-open forwards |
| `--json` | Machine-readable output |
**Example:**
```
porthole list
porthole list --running
```
---
### 3.7 `porthole remove <name>`
Aliases: `rm`, `delete`
Deletes a saved profile. Closes it first if running.
| Flag | Description |
|------------------|-------------------------------------------------------------------|
| `--keep-running` | Delete the profile but leave an active instance running untracked |
---
### 3.8 `porthole wipe`
Alias: `reset`
Closes and deletes **every** forward, including any `ssh` processes matching
porthole's supervisor signature that aren't in the current profile store
(e.g. orphaned after a crash). Confirmation prompt unless `--yes`.
| Flag | Description |
|-------------|--------------------------|
| `-y, --yes` | Skip confirmation prompt |
---
### 3.9 `porthole completions <shell>`
Generates a shell completion script. `<shell>``bash`, `zsh`, `fish`.
---
## 4. Global options
| Flag | Description |
|-----------------|---------------|
| `-h, --help` | Print help |
| `-V, --version` | Print version |
---
## 5. Exit codes
| Code | Meaning |
|------|-----------------------------------------------|
| `0` | Success |
| `1` | Generic error |
| `2` | Profile not found |
| `3` | Profile already exists (`add` without `edit`) |
| `4` | Port bind conflict on `open` |
| `5` | SSH auth/connection failure |
// TODO: fix these up, just use 0/1/2 and output a good error code
// ERR_IDENT (ERR_NUM): Err_Msg
---
## 6. Open questions for v0.2
- Should `edit` on a running profile support `--restart` to apply
immediately, or stay explicit (`edit` then `close`/`open`)?
- Multi-hop `--via` — do we shell out to `ssh -J`, or manage a chain of
supervised hops ourselves for finer-grained per-hop status?
- Templating (`dbtun`-style): saved "kind" templates (e.g. `--template
postgres` implies port 5432) — worth adding as sugar over `add`, or scope
creep?
- Config export/import for moving profiles between machines.