diff --git a/spec/porthole-spec.md b/spec/porthole-spec.md index 0115869..02a85bc 100644 --- a/spec/porthole-spec.md +++ b/spec/porthole-spec.md @@ -1,4 +1,4 @@ -# `porthole` — Spec v0.1 +# `porthole` — Spec v0.2 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. @@ -20,7 +20,8 @@ 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. +agent, or identity files. No GUI. Surviving a full reboot/logout is also +out of scope for v0.1 — see §8. --- @@ -30,117 +31,239 @@ agent, or identity files. No GUI. 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 | | +| Field | Type | Notes | +|------------------|-----------|-----------------------------------------------------------------------| +| `name` | string | Unique key. `[a-z0-9_-]+`, 1-64 chars (matches vmic's naming rule). | +| `kind` | enum | `local` \| `remote` \| `dynamic` | +| `mapping` | string | Raw `-L/-R/-D` payload, see §5.1 | +| `via` | string[] | Ordered hop list; each entry `[user@]host[:port]` (see §3.1) | +| `user` | string? | Defaults to current user / `ssh_config` | +| `identity` | path? | Identity file override | +| `ssh_port` | int | Default `22`; final target only, see §5.1 | +| `reconnect` | bool | Default `true` | +| `retry_interval` | int (sec) | Default `5`; base reconnect delay (§4.1) | +| `backoff_max` | int (sec) | Default `60`; cap on the doubling reconnect delay (§4.1) | +| `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. +**The absence of an instance file is what "closed" means** — there is no +separate `down` state; see §3 for exactly when the file is created/removed. -| 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? | | +| Field | Type | Notes | +|---------------------|------------|----------------------------------------------------------------| +| `name` | string | FK to profile | +| `pid` | int | Supervisor process PID, not raw `ssh` PID | +| `state` | enum | `up` \| `reconnecting` \| `error` | +| `opened_at` | timestamp | Anchor for "session uptime" (§5.5) — set once, at `open` | +| `connected_at` | timestamp? | Start of the *current* unbroken connection; resets each reconnect (§5.5) | +| `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` +- Runtime state: `~/.local/state/porthole/.json` (written by the + supervisor, not hand-edited; absence means the profile is closed) +- Lock: `~/.local/state/porthole/.lock` (advisory `flock`, held for + the supervisor's entire lifetime — see §3) +- Logs: `~/.local/state/porthole/.log`, rotated to a single + `.log.1` backup once it exceeds 10 MiB (checked on each reconnect + attempt, not per line — these are meant to run for months, unlike vmic's + short-lived CLI invocations) --- -## 3. Commands +## 3. Supervisor architecture -### 3.1 `porthole add [flags]` +`open` has to hand off to a process that keeps running after the invoking +shell/terminal exits — the same one-shot-CLI-can't-host-a-daemon problem +`vmic` solves by spawning detached `pw-loopback` subprocesses tracked by +pid. porthole has no external long-running helper to shell out to (there's +no `ssh-loopback` equivalent), so it supervises `ssh` itself via a hidden +re-exec of its own binary: + +1. `porthole open ` validates the profile, tries to acquire + `.lock` (if already held by a live pid: no-op, print status, exit + 0 — see §5.2), then spawns *itself* via `std::env::current_exe()` with a + hidden subcommand: `porthole __supervise `. +2. The spawned process detaches before doing anything else: `stdin` from + `/dev/null`, `stdout`/`stderr` appended to `.log`, and + `libc::setsid()` called via `CommandExt::pre_exec` so it leaves the + parent's process group/session — it survives the terminal closing and + doesn't receive the shell's Ctrl-C/SIGHUP. +3. The foregrounding `open` call blocks briefly (bounded, a few seconds) + waiting for the supervisor to write its pid + initial `state` into the + instance file, so callers get an accurate exit code for immediate + failures (bad auth, port conflict) instead of racing a background + process. `-f/--foreground` skips the detach step entirely and runs the + supervisor loop inline, attached to the current session. +4. The supervisor's loop: spawn `ssh` with the flags in §3.1, wait on it, + classify the exit per §4, sleep/backoff or give up accordingly, and + rewrite the instance file after every state change. `close` sends + SIGTERM to the *supervisor* pid (not raw `ssh`), which forwards it to + its `ssh` child, waits briefly, then exits and removes its lock and + instance file. `--force` skips the wait and SIGKILLs both immediately. + A supervisor that exits on its own due to a fatal failure (§4.2) leaves + the instance file in place with `state: error` rather than deleting it, + so the failure stays visible to `status`/`list` until the user acts. + +### 3.1 Forced `ssh` flags + +Every `ssh` invocation porthole spawns gets these, non-configurable, +regardless of the user's own `~/.ssh/config`: + +| Flag | Why | +|--------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `-o BatchMode=yes` | A headless supervised process must never block on a password/passphrase/host-key TTY prompt — without this, a first connection to an unknown host or a locked key just hangs forever, indistinguishable from "reconnecting." | +| `-o ExitOnForwardFailure=yes` | Makes `ssh` exit non-zero immediately if the requested forward can't be bound, instead of staying up as a plain (forward-less) session that *looks* healthy. | +| `-o ConnectTimeout=10` | Bounds how long one connection attempt can hang before porthole's own backoff logic (§4.1) gets a turn. | +| `-N` | No remote command — porthole only ever wants the forward, never a shell. | +| `-J ` | Only when `--via` is set. The comma-joined hop list is passed to `ssh -J` verbatim — porthole does not re-implement jump-host chaining itself (see §5.1). | + +`ServerAliveInterval` comes from the profile's `keepalive` field (not +hardcoded), so it stays user-tunable. + +--- + +## 4. Reconnect & failure handling + +### 4.1 Backoff + +- Base delay is the profile's `retry_interval` (default 5s). +- Each consecutive failed attempt doubles the delay, capped at + `backoff_max` (default 60s). +- The backoff counter resets to the base delay once a connection has + stayed up continuously for 60s — an in-memory supervisor detail, not + persisted to the instance file — so one good connection after a flaky + patch doesn't leave the *next* reconnect waiting a full capped delay. +- If `reconnect` is `false`, there is no retry loop at all: a single + failed attempt goes straight to `state: error` and the supervisor exits. + +### 4.2 Failure classification + +`ssh`'s own exit code doesn't reliably distinguish "retry me" from "stop +retrying," so porthole classifies by matching `ssh`'s stderr: + +| Pattern (stderr substring) | Class | Behavior | +|-----------------------------------------------------------------|------------|--------------------------------------------------| +| `Permission denied` | fatal | `state: error`, stop, supervisor exits | +| `Host key verification failed` | fatal | `state: error`, stop, supervisor exits | +| `bind: Address already in use` | fatal | `state: error`, stop, supervisor exits | +| `Connection refused` / `No route to host` / connect timeout | transient | backoff + retry | +| `Could not resolve hostname` | transient | backoff + retry (bounded by `backoff_max`) | +| anything else / unrecognized | transient* | backoff + retry, but see escalation below | + +\* After 10 consecutive unrecognized failures in a row, porthole treats the +profile as effectively broken (`state: error`, stop) rather than retrying +under an unrecognized failure forever. `last_error` always holds the raw +`ssh` message either way, for `status` to show. + +A profile that lands in `state: error` stays that way — including its +instance file — until the user runs `open` again (fresh attempt, fresh +backoff) or `close` (clears it). It is deliberately *not* self-healing past +a fatal classification. + +--- + +## 5. Commands + +### 5.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` | +| 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` | `[user@]host[:port][,...]` | no | — | SSH jump-host chain, comma-separated; passed to `ssh -J` verbatim (§3.1) | +| `-u, --user` | `user` | no | current user / ssh_config | Default user for the final target and any `--via` hop that doesn't specify its own | +| `-i, --identity` | `path` | no | ssh_config default | | +| `-p, --port` | `port` | no | `22` | SSH port on the final target only — a `--via` hop needs its own inline `:port` if it isn't 22 | +| `--reconnect` | `bool` | no | `true` | Auto-reconnect on drop (§4) | +| `--retry-interval` | `seconds` | no | `5` | Base reconnect delay (§4.1) | +| `--backoff-max` | `seconds` | no | `60` | Cap on the doubling reconnect delay (§4.1) | +| `--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. +- `name` must not already exist (use `edit` to modify); 1-64 chars, + `[a-z0-9_-]+`. +- `mapping` port syntax validated against the same grammar `ssh` accepts; + stored verbatim as whichever `-l/-r/-d` payload was given (without the + flag itself) — `kind` records which one it was, so re-deriving the right + `-L`/`-R`/`-D` flag at `open` time is a lookup, not a re-parse. +- `--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 admin-ui --local 8080:localhost:8080 --via bastion1,bastion2:2222 --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]` +### 5.2 `porthole open [flags]` Alias: `start` -Starts a saved forward as a background-supervised process. +Starts a saved forward as a background-supervised process (§3). -| 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. | +| Flag | Description | +|--------------------|--------------------------------------------------------------------------------------------------------------------| +| `-f, --foreground` | Run attached in current shell instead of detaching. Ctrl-C closes it cleanly (removes the instance file, same as `close`). | +| `--once` | Open without auto-reconnect, regardless of profile setting (§4.1). | +| `--all` | Ignore ``; open every profile with `reconnect: true` that isn't already running. Per-profile failures are warnings, not a whole-batch failure — this exists specifically as the hook for external autostart mechanisms, see §8. | **Behavior:** -- If already open: no-op, print current status, exit 0. +- If already open (a live supervisor pid holds `.lock`): no-op, + print current status, exit 0. +- If the instance file exists but its pid is dead (crash, or the machine + rebooted): treated as not-running, proceeds to spawn a fresh supervisor. - 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. + process info if discoverable (`lsof`-style lookup — best-effort, degrades + to a plain "port in use" message if `lsof`/`ss` isn't on `PATH`), don't + silently retry. +- `open` blocks briefly (bounded, a few seconds) waiting for the detached + supervisor to confirm it's alive, so an immediate failure (bad auth, + bind conflict) reports a non-zero exit rather than appearing to succeed. + Anything that fails *after* that point (a later reconnect) only shows up + via `status`/`list`, not `open`'s own exit code. **Examples:** ``` porthole open db porthole open db --foreground porthole open proxy --once +porthole open --all ``` --- -### 3.3 `porthole close [flags]` +### 5.3 `porthole close [flags]` Alias: `stop` -Stops a running forward. Profile definition is untouched. +Stops a running forward and removes its instance file. Profile definition +is untouched. -| Flag | Description | -|-----------|--------------------------------------------------------| -| `--force` | SIGKILL immediately instead of graceful SIGTERM + wait | +| Flag | Description | +|-----------|----------------------------------------------------------------------------------| +| `--force` | SIGKILL the supervisor (and its `ssh` child) immediately instead of graceful SIGTERM + wait | + +`close` on a profile that's already stopped (no live pid) is a no-op, exit +0 — it still clears a stale instance file left over from a crash, same as +the crash-recovery path in `open`. **Examples:** ``` @@ -150,11 +273,18 @@ porthole close db --force --- -### 3.4 `porthole edit [flags]` +### 5.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`. +only provided flags are changed). + +**Decision:** `edit` never restarts a running instance, and there is no +`--restart` flag. If `` is currently running, `edit` prints a warning +that the change won't take effect until the next `open`/`close` cycle and +exits 0 — consistent with vmic's `edit`, which never auto-migrates a live +topology without telling the user exactly what to run instead. Keeping +this explicit avoids a footgun where editing a profile silently bounces a +tunnel someone else might be relying on. **Examples:** ``` @@ -162,19 +292,25 @@ 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 ` +### 5.5 `porthole status ` Deep-dive health for one forward. +| Flag | Description | +|----------|-----------------------------------------------------| +| `--json` | Machine-readable output (matches `list --json`) | + **Output includes:** - Profile summary (kind, mapping, via, user) -- Current state (`up` / `down` / `reconnecting` / `error`) -- Uptime since last successful connect +- Current state (`up` / `reconnecting` / `error`, or `closed` if no + instance file exists at all — §2.2) +- **Session uptime**: elapsed time since `opened_at` (the original `open` + call), regardless of intervening reconnects +- **Connection uptime**: elapsed time since `connected_at` (the current + unbroken connection) — resets on every reconnect, absent while + `reconnecting`/`error` - Reconnect count and timestamp of last reconnect - Last error message, if any - Path to log file @@ -186,7 +322,7 @@ porthole status db --- -### 3.6 `porthole list` +### 5.6 `porthole list` Alias: `ls` All saved profiles with live status. Fast, scannable — no deep diagnostics @@ -194,12 +330,15 @@ All saved profiles with live status. Fast, scannable — no deep diagnostics **Columns:** `NAME KIND MAPPING VIA STATE UPTIME` +A profile with no instance file shows `STATE: closed` and an empty +`UPTIME`. `UPTIME` otherwise shows connection uptime (§5.5). + **Flags:** -| Flag | Description | -|-------------|-----------------------------------| -| `--running` | Show only currently-open forwards | -| `--json` | Machine-readable output | +| Flag | Description | +|-------------|-------------------------------------------------------------------| +| `--running` | Show only currently-open forwards (`STATE` in `up`/`reconnecting`) | +| `--json` | Machine-readable output | **Example:** ``` @@ -209,67 +348,92 @@ porthole list --running --- -### 3.7 `porthole remove ` +### 5.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 | +An instance left running via `--keep-running` is no longer visible to +`list`/`status` (its profile is gone), but is still caught by `wipe` +(§5.8), which matches by supervisor process signature rather than tracked +state — same as vmic's `wipe`. + --- -### 3.8 `porthole wipe` +### 5.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`. +Closes and deletes **every** forward, including any supervisor/`ssh` +processes matching porthole's signature that aren't in the current profile +store (e.g. orphaned after a crash). Confirmation prompt unless `--yes` — +unlike vmic's `wipe` (no prompt), porthole's tears down active network +tunnels rather than just audio routing, so the extra confirmation is a +deliberate, not accidental, difference. | Flag | Description | -|-------------|--------------------------| -| `-y, --yes` | Skip confirmation prompt | +|-------------|----------------------------| +| `-y, --yes` | Skip confirmation prompt | --- -### 3.9 `porthole completions ` +### 5.9 `porthole completions ` Generates a shell completion script. `` ∈ `bash`, `zsh`, `fish`. --- -## 4. Global options +## 6. Global options | Flag | Description | -|-----------------|---------------| -| `-h, --help` | Print help | -| `-V, --version` | Print version | +|-----------------|----------------| +| `-h, --help` | Print help | +| `-V, --version` | Print version | --- -## 5. Exit codes +## 7. 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 +| Code | Meaning | +|------|--------------------------------------------------------------------| +| `0` | Success | +| `1` | Error — see the printed message | +| `2` | CLI usage error (bad/missing arguments — clap's own exit code) | + +Kept deliberately flat, matching vmic: granular per-failure codes (not +found vs. already-exists vs. bind conflict, etc.) only pay for themselves +once something is actually scripting against them, and nobody's asked for +that yet. Every error still gets a specific, greppable message on stderr. --- -## 6. Open questions for v0.2 +## 8. Surviving reboots -- 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? +**Explicitly out of scope for v0.1**, and worth calling out since it's +adjacent to the overview's own motivating problem: the supervisor is a +plain process, not a system service, so a full reboot or logout kills it +along with everything else — reconnect (§4) covers network blips and +sleep/wake, not "the machine came back up." + +The only piece porthole commits to now is `open --all` (§5.2), which exists +specifically so an external mechanism can drive it — a systemd `--user` +unit, a login item, a cron `@reboot` line. porthole does not register, +manage, or template any of those itself; that's a v0.2+ decision (§9) once +it's clear which one people actually want. + +--- + +## 9. Open questions for v0.2 + +- Autostart: if/when to commit to a specific mechanism (systemd user unit + template? login item?) now that `open --all` exists as the hook. +- Multi-hop `--via`: confirmed as a straight passthrough to `ssh -J` + (§3.1/§5.1) for v0.1; revisit only if per-hop supervised status ever + becomes a real ask. - Templating (`dbtun`-style): saved "kind" templates (e.g. `--template - postgres` implies port 5432) — worth adding as sugar over `add`, or scope - creep? + postgres` implies port 5432) — worth adding as sugar over `add`, or + scope creep? - Config export/import for moving profiles between machines.