added initial spec
This commit is contained in:
275
spec/porthole-spec.md
Normal file
275
spec/porthole-spec.md
Normal 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.
|
||||||
Reference in New Issue
Block a user