Files
porthole/spec/porthole-spec.md
Overlord b18ee7405e Address implementation gaps in spec: supervisor lifecycle, reconnect policy, instance state
Bumps to v0.2. Resolves the open questions and design gaps flagged during
review before implementation starts:

- Spells out the supervisor detach/re-exec mechanism (§3), mirroring how
  vmic solves the same one-shot-CLI-can't-host-a-daemon problem.
- Forces BatchMode/ExitOnForwardFailure/ConnectTimeout on every ssh
  invocation so headless failures surface instead of hanging on a TTY
  prompt (§3.1).
- Adds backoff + stderr-based fatal/transient failure classification so
  reconnect doesn't retry forever against a permanently broken profile
  (§4).
- Collapses the Instance `state` enum to up/reconnecting/error, with
  "no instance file" as the sole meaning of closed, removing the prior
  ambiguity around close vs. crash vs. down (§2.2, §3).
- Fixes --via's conflicting comma-list-vs-repeated-flag examples by
  committing to a straight ssh -J passthrough grammar (§3.1/§5.1).
- Resolves the edit --restart open question: no --restart, stays explicit.
- Simplifies exit codes to 0/1/2 (was a 6-code table with its own TODO).
- Adds a lock file for concurrent-open safety and a log rotation policy.
- Calls out reboot/logout survival as an explicit v0.1 non-goal, with
  `open --all` as the only hook left for external autostart wiring (§8).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 15:26:43 +02:00

22 KiB

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/<name>.toml
  • Runtime state: ~/.local/state/porthole/<name>.json (written by the supervisor, not hand-edited; absence means the profile is closed)
  • Lock: ~/.local/state/porthole/<name>.lock (advisory flock, held for the supervisor's entire lifetime — see §3)
  • Logs: ~/.local/state/porthole/<name>.log, rotated to a single <name>.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 <name> validates the profile, tries to acquire <name>.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 <name>.
  2. The spawned process detaches before doing anything else: stdin from /dev/null, stdout/stderr appended to <name>.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 <via> 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 <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 [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); 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: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 <name> [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 <name>; 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 <name>.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 <name> [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 <name> [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 <name> 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 <name>

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 <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

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 <shell>

Generates a shell completion script. <shell>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.


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?
  • Config export/import for moving profiles between machines.