From 4d4a83f1e9a0e5aa015ad3b7afec615069abec4c Mon Sep 17 00:00:00 2001 From: Overlord Date: Fri, 14 Aug 2026 19:33:15 +0200 Subject: [PATCH] Remove `porthole-spec.md` (migrated to internal docs). --- spec/porthole-spec.md | 454 ------------------------------------------ 1 file changed, 454 deletions(-) delete mode 100644 spec/porthole-spec.md diff --git a/spec/porthole-spec.md b/spec/porthole-spec.md deleted file mode 100644 index 76b3361..0000000 --- a/spec/porthole-spec.md +++ /dev/null @@ -1,454 +0,0 @@ -# `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. - ---- - -## 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. Surviving a full reboot/logout is also -out of scope for v0.1 - see §8. - ---- - -## 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_-]+`, 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` \| `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 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. Supervisor architecture - -`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. A signal handler - installed on the supervisor (not exit-code inference on `ssh` itself, - which is unreliable) is what distinguishes an intentional `close` from - a dropped connection: `close` sends SIGTERM to the *supervisor* pid, - whose handler kills its `ssh` child, waits briefly, deletes its lock and - instance file, and exits - any *other* way the `ssh` child ends (any - exit status) is treated as a failure to reconnect from, per §4. Because - `step 2`'s `setsid()` makes the supervisor its own process group leader - and `ssh` inherits that group, `--force` sends SIGKILL to the whole - group (`kill(-pid, SIGKILL)`) rather than just the supervisor pid - a - plain single-pid SIGKILL would leave `ssh` running, orphaned and - untracked, since a killed process can't forward anything to its child. - 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 StrictHostKeyChecking=accept-new` | Trust-on-first-use for a host with no `known_hosts` entry yet; still hard-fails if a *known* host's key later changes. Without this, `BatchMode=yes` turns a brand-new host into an immediate fatal failure, since `ssh` has no way to prompt for acceptance. | -| `-o LogLevel=ERROR` | Suppresses the routine "Permanently added ... to the list of known hosts" line `accept-new` produces on first connect, keeping profile logs free of non-error noise. | -| `-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. | -| `-o ServerAliveCountMax=3` | Paired with `ServerAliveInterval` (below), makes dead-connection detection time deterministic (`keepalive × 3`) instead of depending on `ssh`'s compiled-in default. | -| `-o ControlMaster=no`, `-o ControlPath=none` | Blocks `ssh` connection multiplexing, so a `ControlMaster`/`ControlPersist` setting in the user's own `~/.ssh/config` can't make porthole's process silently share a control socket with another session. Supervision (§3) assumes one spawned process owns one tunnel exclusively. | -| `-o ClearAllForwardings=yes` | Ignores any `LocalForward`/`RemoteForward`/`DynamicForward` the user's `~/.ssh/config` declares for the matched host, so the profile's own forward is the only one that ever applies. | -| `-o IdentitiesOnly=yes` | Added only when the profile sets `identity` (alongside `-i`, below) - stops `ssh` from also offering agent/default keys, which avoids authentication-failure lockouts on servers with a low `MaxAuthTries`. | -| `-N` | No remote command - porthole only ever wants the forward, never a shell. | -| `-T` | No pseudo-tty. Redundant with `-N` (no command runs), kept as insurance against a server-side `sshd_config` forcing one anyway. | -| `-J ` + positional target | See below - `--via`'s *last* hop is the actual connection target, not another jump. | - -`ServerAliveInterval` comes from the profile's `keepalive` field (not -hardcoded), so it stays user-tunable. - -**`--via` → `ssh` argument translation:** `ssh -J a,b,c` is not itself a -valid invocation - `-J` only ever carries jump hosts *before* the final -hop; `ssh` still needs a positional `destination` to actually connect (and -run the forward from). So porthole splits `--via`'s comma list at the last -entry: everything before it becomes `-J`'s value (omitted entirely if -`--via` has only one hop), and the last entry becomes `ssh`'s positional -target argument. `--via jumpbox` → `ssh ... jumpbox` (no `-J`). `--via -bastion1,bastion2` → `ssh -J bastion1 ... bastion2` (connect through -bastion1, forward runs from bastion2). This is also why `--via` is -**required**, not optional (see §5.1) - there is no other field -representing "the host `ssh` actually connects to"; `--via`'s last hop -*is* that field. - ---- - -## 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` | `[user@]host[:port]` | **yes** | - | One hop chain entry; repeatable (`--via a --via b`) and/or comma-separated (`--via a,b`) - the last hop is the `ssh` connection target, any before it are `-J` jumps (§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); 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` requires at least one hop (its last entry is the mandatory - connection target, see §3.1); hosts are 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:2222 --user ops -porthole add webhook --remote 9000:localhost:3000 --via public-vps -porthole add proxy --dynamic 1080 --via edge-host -``` - ---- - -### 5.2 `porthole open [flags]` -Alias: `start` - -Starts a saved forward as a background-supervised process (§3). - -| 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 (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 - 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 -``` - ---- - -### 5.3 `porthole close [flags]` -Alias: `stop` - -Stops a running forward and removes its instance file. Profile definition -is untouched. - -| 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:** -``` -porthole close db -porthole close db --force -``` - ---- - -### 5.4 `porthole edit [flags]` - -Updates a saved profile. Accepts the same flags as `add` (all optional - -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:** -``` -porthole edit db --retry-interval 10 -porthole edit db --local 5433:db.internal:5432 -``` - ---- - -### 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` / `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 - -**Example:** -``` -porthole status db -``` - ---- - -### 5.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` - -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 (`STATE` in `up`/`reconnecting`) | -| `--json` | Machine-readable output | - -**Example:** -``` -porthole list -porthole list --running -``` - ---- - -### 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`. - ---- - -### 5.8 `porthole wipe` -Alias: `reset` - -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 | - ---- - -### 5.9 `porthole completions ` - -Generates a shell completion script. `` ∈ `bash`, `zsh`, `fish`. - ---- - -## 6. Global options - -| Flag | Description | -|-----------------|----------------| -| `-h, --help` | Print help | -| `-V, --version` | Print version | - ---- - -## 7. Exit codes - -| 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. - ---- - -## 8. Surviving reboots - -**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.