Files
porthole/spec/porthole-spec.md
Overlord 1ee8b122f3 Squashed (migration)
(initial) Align formatting and code style to project standards.
Add live_test.sh: full automated integration suite against a real SSH server
Show [name] for optional positionals, allow filtering import by name too
Fix transfer rough edges: value-name hint, identity warnings, single-profile export
Add transfer subcommand for bulk profile export/import
Harden forced ssh flags: accept-new host keys, block multiplexing, tighten identity auth
release build opts
Standardize error messages, formatting, and code style. Refine --help text for consistency. Update comments for clarity and tone alignment.
Normalize user-facing --help text for tone/format consistency
Show real mapping grammar in --help, make --via repeatable, trim comments
Match vmic's custom top-level --help renderer
Implement porthole v0.1: profiles, supervised open/close, reconnect
Fix --via/-J grammar and --force process-group kill
added .gitignore
2026-08-14 12:48:28 +02:00

24 KiB
Raw Blame History

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. 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 <hops> + 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.

--viassh 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 jumpboxssh ... jumpbox (no -J). --via bastion1,bastion2ssh -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 <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] 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 <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.