10 KiB
porthole — Spec v0.1
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.
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_-]+. |
kind |
enum | local | remote | dynamic |
mapping |
string | Raw ssh -L/-R/-D-style spec, see §3.1 |
via |
string[] | Ordered list of hops for multi-hop jumps |
user |
string? | Defaults to current user / ssh_config |
identity |
path? | Identity file override |
ssh_port |
int | Default 22 |
reconnect |
bool | Default true |
retry_interval |
int (sec) | Default 5 |
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.
| Field | Type | Notes |
|---|---|---|
name |
string | FK to profile |
pid |
int | Supervisor process PID, not raw ssh PID |
state |
enum | up | down | reconnecting | error |
opened_at |
timestamp | |
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 supervisor, not hand-edited) - Logs:
~/.local/state/porthole/<name>.log
3. Commands
3.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 |
host |
yes | — | SSH target; multiple for multi-hop |
-u, --user |
user |
no | current user / ssh_config | |
-i, --identity |
path |
no | ssh_config default | |
-p, --port |
port |
no | 22 |
SSH port on final target |
--reconnect |
bool |
no | true |
Auto-reconnect on drop |
--retry-interval |
seconds |
no | 5 |
Delay between reconnect attempts |
--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).mappingport syntax validated against the same grammarsshaccepts.--viahosts 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 --user ops
porthole add webhook --remote 9000:localhost:3000 --via public-vps
porthole add proxy --dynamic 1080 --via edge-host
3.2 porthole open <name> [flags]
Alias: start
Starts a saved forward as a background-supervised process.
| Flag | Description |
|---|---|
-f, --foreground |
Run attached in current shell instead of daemonizing. Ctrl-C closes it. |
--once |
Open without auto-reconnect, regardless of profile setting. |
Behavior:
- If already open: no-op, print current status, exit 0.
- If port bind fails (already in use): exit non-zero with the conflicting
process info if discoverable (
lsof-style lookup), don't silently retry. - Spawns a supervisor process that owns the underlying
sshsubprocess, watches for exit, and reconnects per profile settings.
Examples:
porthole open db
porthole open db --foreground
porthole open proxy --once
3.3 porthole close <name> [flags]
Alias: stop
Stops a running forward. Profile definition is untouched.
| Flag | Description |
|---|---|
--force |
SIGKILL immediately instead of graceful SIGTERM + wait |
Examples:
porthole close db
porthole close db --force
3.4 porthole edit <name> [flags]
Updates a saved profile. Accepts the same flags as add (all optional —
only provided flags are changed). Does not restart a running instance
automatically; changes apply on next open.
Examples:
porthole edit db --retry-interval 10
porthole edit db --local 5433:db.internal:5432
If <name> is currently running, print a warning that changes won't take
effect until the next open (or offer --restart — see §6 open questions).
3.5 porthole status <name>
Deep-dive health for one forward.
Output includes:
- Profile summary (kind, mapping, via, user)
- Current state (
up/down/reconnecting/error) - Uptime since last successful connect
- Reconnect count and timestamp of last reconnect
- Last error message, if any
- Path to log file
Example:
porthole status db
3.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
Flags:
| Flag | Description |
|---|---|
--running |
Show only currently-open forwards |
--json |
Machine-readable output |
Example:
porthole list
porthole list --running
3.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 |
3.8 porthole wipe
Alias: reset
Closes and deletes every forward, including any ssh processes matching
porthole's supervisor signature that aren't in the current profile store
(e.g. orphaned after a crash). Confirmation prompt unless --yes.
| Flag | Description |
|---|---|
-y, --yes |
Skip confirmation prompt |
3.9 porthole completions <shell>
Generates a shell completion script. <shell> ∈ bash, zsh, fish.
4. Global options
| Flag | Description |
|---|---|
-h, --help |
Print help |
-V, --version |
Print version |
5. Exit codes
| Code | Meaning |
|---|---|
0 |
Success |
1 |
Generic error |
2 |
Profile not found |
3 |
Profile already exists (add without edit) |
4 |
Port bind conflict on open |
5 |
SSH auth/connection failure |
| // TODO: fix these up, just use 0/1/2 and output a good error code | |
| // ERR_IDENT (ERR_NUM): Err_Msg |
6. Open questions for v0.2
- Should
editon a running profile support--restartto apply immediately, or stay explicit (editthenclose/open)? - Multi-hop
--via— do we shell out tossh -J, or manage a chain of supervised hops ourselves for finer-grained per-hop status? - 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.