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.
462 lines
23 KiB
Markdown
462 lines
23 KiB
Markdown
# `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 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 `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** | — | 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:**
|
|
- `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.
|
|
|
|
---
|
|
|
|
## 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.
|