The custom --help renderer showed every positional as <name> regardless
of whether it was actually required, which was misleading for open and
transfer (both take an optional profile name). positional_args() now
checks Arg::is_required_set() and renders [name] for an optional one.
transfer's name positional previously only worked with --export and was
rejected outright when combined with --import. It now filters --import
the same way: 'porthole transfer <name> -i file.toml' imports just that
one profile out of the file instead of everything in it, erroring with
a new TransferProfileNotFound if the file doesn't contain it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- --help now shows <PATH.toml> instead of the format-agnostic <PATH>.
- Export warns (naming the affected profiles) that identity files
aren't included, only their local paths; import warns per-profile
when an identity path doesn't resolve on the importing machine,
expanding a leading ~/ the same way ssh.rs does so that check isn't
a false positive for tilde paths.
- transfer now takes an optional profile-name positional (same shape
as OpenArgs.name) so -e/--export can target a single profile instead
of always dumping every saved one; combining it with -i/--import is
rejected with a new TransferNameWithImport error.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
-e/--export writes every saved profile to a single TOML file as a
[[profile]] array; -i/--import reads one back and saves each entry,
failing on the first name collision rather than silently overwriting.
Reuses Profile's existing Serialize/Deserialize impl directly, so the
file format is just the same shape already written to
~/.config/porthole/profiles/<name>.toml, aggregated.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
BatchMode=yes already blocked TTY prompts, but a brand-new host with no
known_hosts entry failed outright on first connect since ssh had no way
to ask for acceptance. Add StrictHostKeyChecking=accept-new (TOFU, still
hard-fails on a changed known host) plus LogLevel=ERROR to keep the
resulting "permanently added" notice out of profile logs.
Also force ControlMaster=no/ControlPath=none and ClearAllForwardings=yes
so a user's own ~/.ssh/config can't make porthole's spawned ssh share a
multiplexed connection or apply extra forwards - the supervisor's
process-based tracking assumes one spawned ssh exclusively owns one
tunnel. ServerAliveCountMax=3 makes dead-connection detection time
deterministic against the profile's keepalive, and -T is explicit
no-pty insurance alongside the existing -N. IdentitiesOnly=yes is added
whenever a profile sets an identity file, avoiding auth-failure lockouts
from also offering agent/default keys.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 22:42:02 +02:00
7 changed files with 257 additions and 58 deletions
| `-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 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. |
| `-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. |
| `-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
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
@@ -146,7 +153,7 @@ entry: everything before it becomes `-J`'s value (omitted entirely if
| `-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) |
| `-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 |
| `-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. |
| `--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,
@@ -254,7 +261,7 @@ Starts a saved forward as a background-supervised process (§3).
- 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
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
@@ -284,7 +291,7 @@ is untouched.
| `--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
0 - it still clears a stale instance file left over from a crash, same as
the crash-recovery path in `open`.
**Examples:**
@@ -297,13 +304,13 @@ porthole close db --force
### 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).
**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
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.
@@ -327,11 +334,11 @@ Deep-dive health for one forward.
**Output includes:**
- Profile summary (kind, mapping, via, user)
- Current state (`up` / `reconnecting` / `error`, or `closed` if no
instance file exists at all — §2.2)
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
unbroken connection) - resets on every reconnect, absent while
`reconnecting`/`error`
- Reconnect count and timestamp of last reconnect
- Last error message, if any
@@ -347,7 +354,7 @@ porthole status db
### 5.6 `porthole list`
Alias: `ls`
All saved profiles with live status. Fast, scannable — no deep diagnostics
All saved profiles with live status. Fast, scannable - no deep diagnostics
(use `status` for that).
**Columns:**`NAME KIND MAPPING VIA STATE UPTIME`
@@ -382,7 +389,7 @@ Deletes a saved profile. Closes it first if running.
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`.
state - same as vmic's `wipe`.
---
@@ -391,7 +398,7 @@ 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`—
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
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.