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

455 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# `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.
**`--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** | - | 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.