Files
porthole/spec/porthole-spec.md
2026-08-13 15:21:13 +02:00

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:

  • name must not already exist (use edit to modify).
  • mapping port syntax validated against the same grammar ssh accepts.
  • --via hosts 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 --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 ssh subprocess, 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 edit on a running profile support --restart to apply immediately, or stay explicit (edit then close/open)?
  • Multi-hop --via — do we shell out to ssh -J, or manage a chain of supervised hops ourselves for finer-grained per-hop status?
  • 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.