# `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/.toml` - Runtime state: `~/.local/state/porthole/.json` (written by supervisor, not hand-edited) - Logs: `~/.local/state/porthole/.log` --- ## 3. Commands ### 3.1 `porthole add [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 [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 [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 [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 `` 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 ` 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 ` 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 ` Generates a shell completion script. `` ∈ `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.