Address implementation gaps in spec: supervisor lifecycle, reconnect policy, instance state
Bumps to v0.2. Resolves the open questions and design gaps flagged during review before implementation starts: - Spells out the supervisor detach/re-exec mechanism (§3), mirroring how vmic solves the same one-shot-CLI-can't-host-a-daemon problem. - Forces BatchMode/ExitOnForwardFailure/ConnectTimeout on every ssh invocation so headless failures surface instead of hanging on a TTY prompt (§3.1). - Adds backoff + stderr-based fatal/transient failure classification so reconnect doesn't retry forever against a permanently broken profile (§4). - Collapses the Instance `state` enum to up/reconnecting/error, with "no instance file" as the sole meaning of closed, removing the prior ambiguity around close vs. crash vs. down (§2.2, §3). - Fixes --via's conflicting comma-list-vs-repeated-flag examples by committing to a straight ssh -J passthrough grammar (§3.1/§5.1). - Resolves the edit --restart open question: no --restart, stays explicit. - Simplifies exit codes to 0/1/2 (was a 6-code table with its own TODO). - Adds a lock file for concurrent-open safety and a log rotation policy. - Calls out reboot/logout survival as an explicit v0.1 non-goal, with `open --all` as the only hook left for external autostart wiring (§8). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
# `porthole` — Spec v0.1
|
# `porthole` — Spec v0.2
|
||||||
|
|
||||||
Named, managed SSH port forwards. Wraps `ssh -L/-R/-D` so forwards are
|
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.
|
addressable by name instead of by PID, terminal tab, or shell history.
|
||||||
@@ -20,7 +20,8 @@ open.
|
|||||||
|
|
||||||
**Non-goals:** Not a replacement for a VPN or a full SOCKS/proxy manager.
|
**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,
|
Not a secrets manager — SSH auth still comes from your existing SSH config,
|
||||||
agent, or identity files. No GUI.
|
agent, or identity files. No GUI. Surviving a full reboot/logout is also
|
||||||
|
out of scope for v0.1 — see §8.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -31,16 +32,17 @@ agent, or identity files. No GUI.
|
|||||||
A saved definition. Does not imply anything is running.
|
A saved definition. Does not imply anything is running.
|
||||||
|
|
||||||
| Field | Type | Notes |
|
| Field | Type | Notes |
|
||||||
|------------------|-----------|------------------------------------------|
|
|------------------|-----------|-----------------------------------------------------------------------|
|
||||||
| `name` | string | Unique key. `[a-z0-9_-]+`. |
|
| `name` | string | Unique key. `[a-z0-9_-]+`, 1-64 chars (matches vmic's naming rule). |
|
||||||
| `kind` | enum | `local` \| `remote` \| `dynamic` |
|
| `kind` | enum | `local` \| `remote` \| `dynamic` |
|
||||||
| `mapping` | string | Raw `ssh -L/-R/-D`-style spec, see §3.1 |
|
| `mapping` | string | Raw `-L/-R/-D` payload, see §5.1 |
|
||||||
| `via` | string[] | Ordered list of hops for multi-hop jumps |
|
| `via` | string[] | Ordered hop list; each entry `[user@]host[:port]` (see §3.1) |
|
||||||
| `user` | string? | Defaults to current user / `ssh_config` |
|
| `user` | string? | Defaults to current user / `ssh_config` |
|
||||||
| `identity` | path? | Identity file override |
|
| `identity` | path? | Identity file override |
|
||||||
| `ssh_port` | int | Default `22` |
|
| `ssh_port` | int | Default `22`; final target only, see §5.1 |
|
||||||
| `reconnect` | bool | Default `true` |
|
| `reconnect` | bool | Default `true` |
|
||||||
| `retry_interval` | int (sec) | Default `5` |
|
| `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` |
|
| `keepalive` | int (sec) | `ServerAliveInterval`, default `15` |
|
||||||
| `created_at` | timestamp | |
|
| `created_at` | timestamp | |
|
||||||
| `updated_at` | timestamp | |
|
| `updated_at` | timestamp | |
|
||||||
@@ -49,13 +51,16 @@ A saved definition. Does not imply anything is running.
|
|||||||
|
|
||||||
Exists only while a profile is open. Tracked separately from the profile so
|
Exists only while a profile is open. Tracked separately from the profile so
|
||||||
`list`/`status` can report live data without touching the saved definition.
|
`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 |
|
| Field | Type | Notes |
|
||||||
|---------------------|------------|---------------------------------------------|
|
|---------------------|------------|----------------------------------------------------------------|
|
||||||
| `name` | string | FK to profile |
|
| `name` | string | FK to profile |
|
||||||
| `pid` | int | Supervisor process PID, not raw `ssh` PID |
|
| `pid` | int | Supervisor process PID, not raw `ssh` PID |
|
||||||
| `state` | enum | `up` \| `down` \| `reconnecting` \| `error` |
|
| `state` | enum | `up` \| `reconnecting` \| `error` |
|
||||||
| `opened_at` | timestamp | |
|
| `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 |
|
| `last_error` | string? | Most recent failure message, if any |
|
||||||
| `reconnect_count` | int | Since last manual `open` |
|
| `reconnect_count` | int | Since last manual `open` |
|
||||||
| `last_reconnect_at` | timestamp? | |
|
| `last_reconnect_at` | timestamp? | |
|
||||||
@@ -63,84 +68,202 @@ Exists only while a profile is open. Tracked separately from the profile so
|
|||||||
### 2.3 Storage
|
### 2.3 Storage
|
||||||
|
|
||||||
- Profiles: `~/.config/porthole/profiles/<name>.toml`
|
- Profiles: `~/.config/porthole/profiles/<name>.toml`
|
||||||
- Runtime state: `~/.local/state/porthole/<name>.json` (written by supervisor, not hand-edited)
|
- Runtime state: `~/.local/state/porthole/<name>.json` (written by the
|
||||||
- Logs: `~/.local/state/porthole/<name>.log`
|
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. Commands
|
## 3. Supervisor architecture
|
||||||
|
|
||||||
### 3.1 `porthole add <name> [flags]`
|
`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. `close` sends
|
||||||
|
SIGTERM to the *supervisor* pid (not raw `ssh`), which forwards it to
|
||||||
|
its `ssh` child, waits briefly, then exits and removes its lock and
|
||||||
|
instance file. `--force` skips the wait and SIGKILLs both immediately.
|
||||||
|
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 <via>` | Only when `--via` is set. The comma-joined hop list is passed to `ssh -J` verbatim — porthole does not re-implement jump-host chaining itself (see §5.1). |
|
||||||
|
|
||||||
|
`ServerAliveInterval` comes from the profile's `keepalive` field (not
|
||||||
|
hardcoded), so it stays user-tunable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`
|
Aliases: `create`, `new`
|
||||||
|
|
||||||
Saves a new profile. Does **not** open it.
|
Saves a new profile. Does **not** open it.
|
||||||
|
|
||||||
| Flag | Arg | Required | Default | Description |
|
| Flag | Arg | Required | Default | Description |
|
||||||
|--------------------|-----------------------------|-------------------|---------------------------|---------------------------------------|
|
|--------------------|-------------------------------|-------------------|---------------------------|----------------------------------------|
|
||||||
| `-l, --local` | `[bind:]port:host:hostport` | one of `-l/-r/-d` | — | Local forward: your machine → remote |
|
| `-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 |
|
| `-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) |
|
| `-d, --dynamic` | `[bind:]port` | one of `-l/-r/-d` | — | Dynamic forward (SOCKS proxy) |
|
||||||
| `--via` | `host` | yes | — | SSH target; multiple for multi-hop |
|
| `--via` | `[user@]host[:port][,...]` | no | — | SSH jump-host chain, comma-separated; passed to `ssh -J` verbatim (§3.1) |
|
||||||
| `-u, --user` | `user` | no | current user / ssh_config | |
|
| `-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 | |
|
| `-i, --identity` | `path` | no | ssh_config default | |
|
||||||
| `-p, --port` | `port` | no | `22` | SSH port on final target |
|
| `-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 |
|
| `--reconnect` | `bool` | no | `true` | Auto-reconnect on drop (§4) |
|
||||||
| `--retry-interval` | `seconds` | no | `5` | Delay between reconnect attempts |
|
| `--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` |
|
| `--keepalive` | `seconds` | no | `15` | `ServerAliveInterval` |
|
||||||
|
|
||||||
Exactly one of `-l`/`-r`/`-d` is required. Providing more than one is an
|
Exactly one of `-l`/`-r`/`-d` is required. Providing more than one is an
|
||||||
error.
|
error.
|
||||||
|
|
||||||
**Validation:**
|
**Validation:**
|
||||||
- `name` must not already exist (use `edit` to modify).
|
- `name` must not already exist (use `edit` to modify); 1-64 chars,
|
||||||
- `mapping` port syntax validated against the same grammar `ssh` accepts.
|
`[a-z0-9_-]+`.
|
||||||
- `--via` hosts resolved/checked against `~/.ssh/config` if present, but not
|
- `mapping` port syntax validated against the same grammar `ssh` accepts;
|
||||||
required to exist there.
|
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` hosts resolved/checked against `~/.ssh/config` if present, but
|
||||||
|
not required to exist there.
|
||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
```
|
```
|
||||||
porthole add db --local 5432:db.internal:5432 --via jumpbox
|
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 admin-ui --local 8080:localhost:8080 --via bastion1,bastion2:2222 --user ops
|
||||||
porthole add webhook --remote 9000:localhost:3000 --via public-vps
|
porthole add webhook --remote 9000:localhost:3000 --via public-vps
|
||||||
porthole add proxy --dynamic 1080 --via edge-host
|
porthole add proxy --dynamic 1080 --via edge-host
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3.2 `porthole open <name> [flags]`
|
### 5.2 `porthole open <name> [flags]`
|
||||||
Alias: `start`
|
Alias: `start`
|
||||||
|
|
||||||
Starts a saved forward as a background-supervised process.
|
Starts a saved forward as a background-supervised process (§3).
|
||||||
|
|
||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
|--------------------|-------------------------------------------------------------------------|
|
|--------------------|--------------------------------------------------------------------------------------------------------------------|
|
||||||
| `-f, --foreground` | Run attached in current shell instead of daemonizing. Ctrl-C closes it. |
|
| `-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. |
|
| `--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:**
|
**Behavior:**
|
||||||
- If already open: no-op, print current status, exit 0.
|
- 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
|
- If port bind fails (already in use): exit non-zero with the conflicting
|
||||||
process info if discoverable (`lsof`-style lookup), don't silently retry.
|
process info if discoverable (`lsof`-style lookup — best-effort, degrades
|
||||||
- Spawns a supervisor process that owns the underlying `ssh` subprocess,
|
to a plain "port in use" message if `lsof`/`ss` isn't on `PATH`), don't
|
||||||
watches for exit, and reconnects per profile settings.
|
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:**
|
**Examples:**
|
||||||
```
|
```
|
||||||
porthole open db
|
porthole open db
|
||||||
porthole open db --foreground
|
porthole open db --foreground
|
||||||
porthole open proxy --once
|
porthole open proxy --once
|
||||||
|
porthole open --all
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3.3 `porthole close <name> [flags]`
|
### 5.3 `porthole close <name> [flags]`
|
||||||
Alias: `stop`
|
Alias: `stop`
|
||||||
|
|
||||||
Stops a running forward. Profile definition is untouched.
|
Stops a running forward and removes its instance file. Profile definition
|
||||||
|
is untouched.
|
||||||
|
|
||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
|-----------|--------------------------------------------------------|
|
|-----------|----------------------------------------------------------------------------------|
|
||||||
| `--force` | SIGKILL immediately instead of graceful SIGTERM + wait |
|
| `--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:**
|
**Examples:**
|
||||||
```
|
```
|
||||||
@@ -150,11 +273,18 @@ porthole close db --force
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3.4 `porthole edit <name> [flags]`
|
### 5.4 `porthole edit <name> [flags]`
|
||||||
|
|
||||||
Updates a saved profile. Accepts the same flags as `add` (all optional —
|
Updates a saved profile. Accepts the same flags as `add` (all optional —
|
||||||
only provided flags are changed). Does not restart a running instance
|
only provided flags are changed).
|
||||||
automatically; changes apply on next `open`.
|
|
||||||
|
**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:**
|
**Examples:**
|
||||||
```
|
```
|
||||||
@@ -162,19 +292,25 @@ porthole edit db --retry-interval 10
|
|||||||
porthole edit db --local 5433:db.internal:5432
|
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>`
|
### 5.5 `porthole status <name>`
|
||||||
|
|
||||||
Deep-dive health for one forward.
|
Deep-dive health for one forward.
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
|----------|-----------------------------------------------------|
|
||||||
|
| `--json` | Machine-readable output (matches `list --json`) |
|
||||||
|
|
||||||
**Output includes:**
|
**Output includes:**
|
||||||
- Profile summary (kind, mapping, via, user)
|
- Profile summary (kind, mapping, via, user)
|
||||||
- Current state (`up` / `down` / `reconnecting` / `error`)
|
- Current state (`up` / `reconnecting` / `error`, or `closed` if no
|
||||||
- Uptime since last successful connect
|
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
|
- Reconnect count and timestamp of last reconnect
|
||||||
- Last error message, if any
|
- Last error message, if any
|
||||||
- Path to log file
|
- Path to log file
|
||||||
@@ -186,7 +322,7 @@ porthole status db
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3.6 `porthole list`
|
### 5.6 `porthole list`
|
||||||
Alias: `ls`
|
Alias: `ls`
|
||||||
|
|
||||||
All saved profiles with live status. Fast, scannable — no deep diagnostics
|
All saved profiles with live status. Fast, scannable — no deep diagnostics
|
||||||
@@ -194,11 +330,14 @@ All saved profiles with live status. Fast, scannable — no deep diagnostics
|
|||||||
|
|
||||||
**Columns:** `NAME KIND MAPPING VIA STATE UPTIME`
|
**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:**
|
**Flags:**
|
||||||
|
|
||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
|-------------|-----------------------------------|
|
|-------------|-------------------------------------------------------------------|
|
||||||
| `--running` | Show only currently-open forwards |
|
| `--running` | Show only currently-open forwards (`STATE` in `up`/`reconnecting`) |
|
||||||
| `--json` | Machine-readable output |
|
| `--json` | Machine-readable output |
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
@@ -209,67 +348,92 @@ porthole list --running
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3.7 `porthole remove <name>`
|
### 5.7 `porthole remove <name>`
|
||||||
Aliases: `rm`, `delete`
|
Aliases: `rm`, `delete`
|
||||||
|
|
||||||
Deletes a saved profile. Closes it first if running.
|
Deletes a saved profile. Closes it first if running.
|
||||||
|
|
||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
|------------------|-------------------------------------------------------------------|
|
|------------------|--------------------------------------------------------------------|
|
||||||
| `--keep-running` | Delete the profile but leave an active instance running untracked |
|
| `--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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3.8 `porthole wipe`
|
### 5.8 `porthole wipe`
|
||||||
Alias: `reset`
|
Alias: `reset`
|
||||||
|
|
||||||
Closes and deletes **every** forward, including any `ssh` processes matching
|
Closes and deletes **every** forward, including any supervisor/`ssh`
|
||||||
porthole's supervisor signature that aren't in the current profile store
|
processes matching porthole's signature that aren't in the current profile
|
||||||
(e.g. orphaned after a crash). Confirmation prompt unless `--yes`.
|
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 |
|
| Flag | Description |
|
||||||
|-------------|--------------------------|
|
|-------------|----------------------------|
|
||||||
| `-y, --yes` | Skip confirmation prompt |
|
| `-y, --yes` | Skip confirmation prompt |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3.9 `porthole completions <shell>`
|
### 5.9 `porthole completions <shell>`
|
||||||
|
|
||||||
Generates a shell completion script. `<shell>` ∈ `bash`, `zsh`, `fish`.
|
Generates a shell completion script. `<shell>` ∈ `bash`, `zsh`, `fish`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Global options
|
## 6. Global options
|
||||||
|
|
||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
|-----------------|---------------|
|
|-----------------|----------------|
|
||||||
| `-h, --help` | Print help |
|
| `-h, --help` | Print help |
|
||||||
| `-V, --version` | Print version |
|
| `-V, --version` | Print version |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Exit codes
|
## 7. Exit codes
|
||||||
|
|
||||||
| Code | Meaning |
|
| Code | Meaning |
|
||||||
|------|-----------------------------------------------|
|
|------|--------------------------------------------------------------------|
|
||||||
| `0` | Success |
|
| `0` | Success |
|
||||||
| `1` | Generic error |
|
| `1` | Error — see the printed message |
|
||||||
| `2` | Profile not found |
|
| `2` | CLI usage error (bad/missing arguments — clap's own exit code) |
|
||||||
| `3` | Profile already exists (`add` without `edit`) |
|
|
||||||
| `4` | Port bind conflict on `open` |
|
Kept deliberately flat, matching vmic: granular per-failure codes (not
|
||||||
| `5` | SSH auth/connection failure |
|
found vs. already-exists vs. bind conflict, etc.) only pay for themselves
|
||||||
// TODO: fix these up, just use 0/1/2 and output a good error code
|
once something is actually scripting against them, and nobody's asked for
|
||||||
// ERR_IDENT (ERR_NUM): Err_Msg
|
that yet. Every error still gets a specific, greppable message on stderr.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Open questions for v0.2
|
## 8. Surviving reboots
|
||||||
|
|
||||||
- Should `edit` on a running profile support `--restart` to apply
|
**Explicitly out of scope for v0.1**, and worth calling out since it's
|
||||||
immediately, or stay explicit (`edit` then `close`/`open`)?
|
adjacent to the overview's own motivating problem: the supervisor is a
|
||||||
- Multi-hop `--via` — do we shell out to `ssh -J`, or manage a chain of
|
plain process, not a system service, so a full reboot or logout kills it
|
||||||
supervised hops ourselves for finer-grained per-hop status?
|
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
|
- Templating (`dbtun`-style): saved "kind" templates (e.g. `--template
|
||||||
postgres` implies port 5432) — worth adding as sugar over `add`, or scope
|
postgres` implies port 5432) — worth adding as sugar over `add`, or
|
||||||
creep?
|
scope creep?
|
||||||
- Config export/import for moving profiles between machines.
|
- Config export/import for moving profiles between machines.
|
||||||
|
|||||||
Reference in New Issue
Block a user