Found both while translating the spec into code: - `ssh -J a,b,c` alone isn't valid - ssh -J only carries jump hosts before the final hop; it still needs a positional destination. --via's last entry has to double as that target, which also makes --via mandatory (there's no other field naming "the host ssh connects to"), reversing the earlier "no" in the required column. - A plain single-pid SIGKILL on `close --force` would leave the `ssh` child orphaned, since a killed process can't forward anything to it. Documents sending SIGKILL to the whole process group instead, relying on the supervisor's own setsid() call making it the group leader.
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 | — | Comma-separated hop chain; the last hop is the actual 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.