- -l/-r/-d now show their actual grammar as the clap value name (<[BIND:]PORT:HOST:PORT>, <[BIND:]PORT>) instead of a generic <SPEC>, matching vmic's convention of showing real syntax in --help. The grammar is no longer also repeated in the flag's help text, since the value name already carries it. - --via changes from a single comma-separated flag to a repeatable one (Vec<String> + value_delimiter = ','), so both `--via a --via b` and `--via a,b` work and can be mixed. commands/mod.rs's edits_from_mapping no longer needs to split the string itself - clap does it. - Trimmed CLI help text that restated grammar/rationale already covered by the value name or by --via being required. - Normalized code comments for tone/format consistency: dropped cross-references to vmic's own internals as justification (this codebase should read as self-contained), removed markdown-style *emphasis* asterisks that don't render in plain comments, tightened several run-on sentences into plain declarative ones, and fixed one comment in wipe.rs that inaccurately described close_instance's force path (it sends SIGKILL, not SIGTERM). - spec/porthole-spec.md's --via row updated to document the repeatable form alongside comma-separated. Cargo.toml/Cargo.lock/atomic.rs also carry an external toml crate bump and formatting pass picked up from the working tree.
23 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(advisoryflock, held for the supervisor's entire lifetime — see §3) - Logs:
~/.local/state/porthole/<name>.log, rotated to a single<name>.log.1backup 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:
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 viastd::env::current_exe()with a hidden subcommand:porthole __supervise <name>.- The spawned process detaches before doing anything else:
stdinfrom/dev/null,stdout/stderrappended to<name>.log, andlibc::setsid()called viaCommandExt::pre_execso it leaves the parent's process group/session — it survives the terminal closing and doesn't receive the shell's Ctrl-C/SIGHUP. - The foregrounding
opencall blocks briefly (bounded, a few seconds) waiting for the supervisor to write its pid + initialstateinto the instance file, so callers get an accurate exit code for immediate failures (bad auth, port conflict) instead of racing a background process.-f/--foregroundskips the detach step entirely and runs the supervisor loop inline, attached to the current session. - The supervisor's loop: spawn
sshwith 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 onsshitself, which is unreliable) is what distinguishes an intentionalclosefrom a dropped connection:closesends SIGTERM to the supervisor pid, whose handler kills itssshchild, waits briefly, deletes its lock and instance file, and exits — any other way thesshchild ends (any exit status) is treated as a failure to reconnect from, per §4. Becausestep 2'ssetsid()makes the supervisor its own process group leader andsshinherits that group,--forcesends SIGKILL to the whole group (kill(-pid, SIGKILL)) rather than just the supervisor pid — a plain single-pid SIGKILL would leavesshrunning, 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 withstate: errorrather than deleting it, so the failure stays visible tostatus/listuntil 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 <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.
--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
reconnectisfalse, there is no retry loop at all: a single failed attempt goes straight tostate: errorand 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:
namemust not already exist (useeditto modify); 1-64 chars,[a-z0-9_-]+.mappingport syntax validated against the same grammarsshaccepts; stored verbatim as whichever-l/-r/-dpayload was given (without the flag itself) —kindrecords which one it was, so re-deriving the right-L/-R/-Dflag atopentime is a lookup, not a re-parse.--viarequires at least one hop (its last entry is the mandatory connection target, see §3.1); hosts are resolved/checked against~/.ssh/configif 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 iflsof/ssisn't onPATH), don't silently retry. openblocks 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 viastatus/list, notopen'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, orclosedif no instance file exists at all — §2.2) - Session uptime: elapsed time since
opened_at(the originalopencall), regardless of intervening reconnects - Connection uptime: elapsed time since
connected_at(the current unbroken connection) — resets on every reconnect, absent whilereconnecting/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 --allexists as the hook. - Multi-hop
--via: confirmed as a straight passthrough tossh -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 postgresimplies port 5432) — worth adding as sugar overadd, or scope creep? - Config export/import for moving profiles between machines.