Fix --via/-J grammar and --force process-group kill in spec

Found both while translating the spec into code:

- `ssh -J a,b,c` alone isn't valid - ssh -J only carries jump hosts
  before the final hop; it still needs a positional destination. --via's
  last entry has to double as that target, which also makes --via
  mandatory (there's no other field naming "the host ssh connects to"),
  reversing the earlier "no" in the required column.
- A plain single-pid SIGKILL on `close --force` would leave the `ssh`
  child orphaned, since a killed process can't forward anything to it.
  Documents sending SIGKILL to the whole process group instead, relying
  on the supervisor's own setsid() call making it the group leader.
This commit is contained in:
2026-08-13 15:43:36 +02:00
parent b18ee7405e
commit bc8f1cc5d2
2 changed files with 31 additions and 8 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@
target/
*.lock
!Cargo.lock

View File

@@ -105,10 +105,18 @@ re-exec of its own binary:
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.
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.
@@ -124,11 +132,24 @@ regardless of the user's own `~/.ssh/config`:
| `-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). |
| `-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
@@ -183,7 +204,7 @@ Saves a new profile. Does **not** open it.
| `-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][,...]` | no | — | SSH jump-host chain, comma-separated; passed to `ssh -J` verbatim (§3.1) |
| `--via` | `[user@]host[:port][,...]` | **yes** | — | Comma-separated hop chain; the *last* hop is the actual `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 |
@@ -202,8 +223,9 @@ error.
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.
- `--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:**
```