Compare commits
6 Commits
6e6b5c1393
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 06b940b730 | |||
| 7fdcccf864 | |||
| 6660717177 | |||
| 4d4a83f1e9 | |||
| 94d0c35e6b | |||
| 1ee8b122f3 |
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -220,7 +220,7 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "porthole"
|
name = "porthole"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"clap_complete",
|
"clap_complete",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "porthole"
|
name = "porthole"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Create and manage named SSH port forwards."
|
description = "Create and manage named SSH port forwards."
|
||||||
license = "AGPL-3+"
|
license = "AGPL-3+"
|
||||||
|
|||||||
165
README.md
165
README.md
@@ -1,3 +1,166 @@
|
|||||||
# porthole
|
# porthole
|
||||||
|
|
||||||
Create and manage named SSH port fowards easily.
|
Create and manage named SSH port forwards easily.
|
||||||
|
|
||||||
|
Porthole wraps `ssh` to turn tunnel commands into named profiles: define a
|
||||||
|
forward once, then open, close, and inspect it by name. Each open forward
|
||||||
|
runs under a small supervisor process that keeps it alive and reconnects
|
||||||
|
automatically if the connection drops.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Named profiles** for local, remote, and dynamic/SOCKS forwards, saved
|
||||||
|
to disk instead of retyped each time.
|
||||||
|
- **Multi-hop jump chains**, built on `ssh -J`.
|
||||||
|
- **Auto-reconnect** with exponential backoff, unless a failure looks
|
||||||
|
permanent (bad auth, host key mismatch, port already bound).
|
||||||
|
- **Status and listing** with live state, uptime, and reconnect counts, as
|
||||||
|
text or JSON.
|
||||||
|
- **Import/export** of profiles as a single TOML file, for moving a setup
|
||||||
|
to another machine.
|
||||||
|
- **Shell completions** for bash, zsh, fish, and others.
|
||||||
|
- **Hardened by default**: every spawned `ssh` call ignores the caller's
|
||||||
|
own config, runs in batch mode with no interactive prompts, and applies
|
||||||
|
the same hardening to every hop in a jump chain, not just the final
|
||||||
|
target.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Requires a Rust toolchain and an `ssh` binary on `PATH`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
The binary is written to `target/release/porthole`. Place it on `PATH`,
|
||||||
|
e.g.:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
install -Dm755 target/release/porthole ~/.local/bin/porthole
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Aliases | Description |
|
||||||
|
|---------------|----------------|-----------------------------------------------------------|
|
||||||
|
| `add` | `mk`, `create` | Save a new forward profile. |
|
||||||
|
| `open` | `start` | Start a saved forward as a supervised background process. |
|
||||||
|
| `close` | `stop` | Stop a running forward. |
|
||||||
|
| `edit` | `change` | Update a saved profile. |
|
||||||
|
| `status` | | Show detailed status for one forward. |
|
||||||
|
| `list` | `ls` | List all saved profiles with live status. |
|
||||||
|
| `remove` | `rm`, `delete` | Delete a saved profile. |
|
||||||
|
| `wipe` | `reset` | Close and delete every forward, tracked or not. |
|
||||||
|
| `transfer` | `data` | Export saved profiles to a file, or import them from one. |
|
||||||
|
| `completions` | | Generate a shell completion script. |
|
||||||
|
|
||||||
|
Run `porthole` or `porthole --help` for the full flag reference.
|
||||||
|
|
||||||
|
### `add` / `edit` flags
|
||||||
|
|
||||||
|
One of `-l/--local`, `-r/--remote`, or `-d/--dynamic` selects the forward
|
||||||
|
kind (required for `add`, optional for `edit`):
|
||||||
|
|
||||||
|
| Flag | Value | Meaning | Default |
|
||||||
|
|--------------------|-------------------------|-------------------------------------------------------------|:-------:|
|
||||||
|
| `-l, --local` | `[BIND:]PORT:HOST:PORT` | Local forward (this machine -> remote machine). | - |
|
||||||
|
| `-r, --remote` | `[BIND:]PORT:HOST:PORT` | Remote forward (remote machine -> this machine). | - |
|
||||||
|
| `-d, --dynamic` | `[BIND:]PORT` | Dynamic forward (SOCKS proxy). | - |
|
||||||
|
| `--via` | `[USER@]HOST[:PORT]` | Jump-host chain, ending at the connection target. Required. | - |
|
||||||
|
| `-u, --user` | `USER` | Default user for the target and any hop without one. | - |
|
||||||
|
| `-i, --identity` | `PATH` | Identity file override. | - |
|
||||||
|
| `-p, --port` | `PORT` | SSH port of the final target. | `22` |
|
||||||
|
| `--reconnect` | `BOOL` | Auto-reconnect on connection drop. | `true` |
|
||||||
|
| `--retry-interval` | `SECONDS` | Base delay between reconnection attempts. | `5` |
|
||||||
|
| `--backoff-max` | `SECONDS` | Cap on the doubling reconnection delay. | `60` |
|
||||||
|
| `--keepalive` | `SECONDS` | SSH `ServerAliveInterval`. | `15` |
|
||||||
|
|
||||||
|
`edit` only touches the fields given on the command line; everything else
|
||||||
|
stays as-is.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Remote forward, exposing this machine's port 3000 to the remote host.
|
||||||
|
porthole add expose-app -r 3000:localhost:3000 --via ops@server.example.com
|
||||||
|
|
||||||
|
# Dynamic SOCKS proxy.
|
||||||
|
porthole add socks -d 1080 --via user@gateway
|
||||||
|
|
||||||
|
# Two-hop jump chain: bastion1, then bastion2, ending at db-host.
|
||||||
|
porthole add mydb -l 5432:internal-db:5432 --via bastion1 --via bastion2 -u ops
|
||||||
|
|
||||||
|
# Open every profile with reconnect enabled that isn't already running.
|
||||||
|
porthole open --all
|
||||||
|
|
||||||
|
# Run attached in the current shell instead of detaching.
|
||||||
|
porthole open mydb --foreground
|
||||||
|
|
||||||
|
# Force-kill instead of a graceful SIGTERM-then-wait.
|
||||||
|
porthole close mydb --force
|
||||||
|
|
||||||
|
# Machine-readable status/listing.
|
||||||
|
porthole status mydb --json
|
||||||
|
porthole list --json
|
||||||
|
|
||||||
|
# Back up all profiles, then restore them elsewhere.
|
||||||
|
porthole transfer --export backup.toml
|
||||||
|
porthole transfer --import backup.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
> **ⓘ** Note:<br>
|
||||||
|
> `transfer --export` does not include identity file contents, only
|
||||||
|
> their configured paths; key files need to be copied to the target machine
|
||||||
|
> separately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How a forward stays open
|
||||||
|
|
||||||
|
`open` spawns a detached copy of the `porthole` binary running an internal
|
||||||
|
supervisor loop for that one profile. The supervisor:
|
||||||
|
|
||||||
|
- Builds and runs the `ssh` command for the profile (`-N -T` plus the
|
||||||
|
right `-L`/`-R`/`-D` flag), holding an advisory file lock for its whole
|
||||||
|
lifetime so `status`/`list` can reliably tell if it's still alive.
|
||||||
|
- Watches the connection; once it survives a short grace period it's
|
||||||
|
reported as `up`.
|
||||||
|
- On a dropped or failed connection, classifies the failure:
|
||||||
|
- **Fatal** (bad auth, host key mismatch, port already in use): gives up
|
||||||
|
immediately, state becomes `error`.
|
||||||
|
- **Known transient** (connection refused, DNS failure, timeout):
|
||||||
|
reconnects with exponential backoff.
|
||||||
|
- **Unrecognized**: also retries, but gives up after too many
|
||||||
|
consecutive unrecognized failures in a row.
|
||||||
|
- Resets the backoff delay once a connection has stayed up long enough to
|
||||||
|
be considered stable again.
|
||||||
|
- Logs `ssh` output to a per-profile log file, rotating it once it grows
|
||||||
|
past 10 MB.
|
||||||
|
- Exits cleanly and removes its own state on `close` (SIGTERM) or Ctrl-C
|
||||||
|
in foreground mode (SIGINT).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State on disk
|
||||||
|
|
||||||
|
- Profiles: `$XDG_CONFIG_HOME/porthole/profiles/<name>.toml`
|
||||||
|
- Runtime state, lock, and log for each open forward:
|
||||||
|
`$XDG_STATE_HOME/porthole/<name>.{json,lock,log}`.
|
||||||
|
|
||||||
|
State locations can be overridden with the `PORTHOLE_STATE_DIR_OVERRIDE` environment variable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shell completions
|
||||||
|
|
||||||
|
```sh
|
||||||
|
porthole completions bash > /etc/bash_completion.d/porthole
|
||||||
|
porthole completions zsh > "${fpath[1]}/_porthole"
|
||||||
|
porthole completions fish > ~/.config/fish/completions/porthole.fish
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,454 +0,0 @@
|
|||||||
# `porthole` - Spec v0.2
|
|
||||||
|
|
||||||
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. Surviving a full reboot/logout is also
|
|
||||||
out of scope for v0.1 - see §8.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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_-]+`, 1-64 chars (matches vmic's naming rule). |
|
|
||||||
| `kind` | enum | `local` \| `remote` \| `dynamic` |
|
|
||||||
| `mapping` | string | Raw `-L/-R/-D` payload, see §5.1 |
|
|
||||||
| `via` | string[] | Ordered hop list; each entry `[user@]host[:port]` (see §3.1) |
|
|
||||||
| `user` | string? | Defaults to current user / `ssh_config` |
|
|
||||||
| `identity` | path? | Identity file override |
|
|
||||||
| `ssh_port` | int | Default `22`; final target only, see §5.1 |
|
|
||||||
| `reconnect` | bool | Default `true` |
|
|
||||||
| `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` |
|
|
||||||
| `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.
|
|
||||||
**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 |
|
|
||||||
|---------------------|------------|----------------------------------------------------------------|
|
|
||||||
| `name` | string | FK to profile |
|
|
||||||
| `pid` | int | Supervisor process PID, not raw `ssh` PID |
|
|
||||||
| `state` | enum | `up` \| `reconnecting` \| `error` |
|
|
||||||
| `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 |
|
|
||||||
| `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 the
|
|
||||||
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. Supervisor architecture
|
|
||||||
|
|
||||||
`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. 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.
|
|
||||||
|
|
||||||
### 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 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. |
|
|
||||||
| `-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
|
|
||||||
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
|
|
||||||
|
|
||||||
### 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`
|
|
||||||
|
|
||||||
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` | `[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 |
|
|
||||||
| `-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 |
|
|
||||||
| `--reconnect` | `bool` | no | `true` | Auto-reconnect on drop (§4) |
|
|
||||||
| `--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` |
|
|
||||||
|
|
||||||
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); 1-64 chars,
|
|
||||||
`[a-z0-9_-]+`.
|
|
||||||
- `mapping` port syntax validated against the same grammar `ssh` accepts;
|
|
||||||
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` 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:**
|
|
||||||
```
|
|
||||||
porthole add db --local 5432:db.internal:5432 --via jumpbox
|
|
||||||
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 proxy --dynamic 1080 --via edge-host
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.2 `porthole open <name> [flags]`
|
|
||||||
Alias: `start`
|
|
||||||
|
|
||||||
Starts a saved forward as a background-supervised process (§3).
|
|
||||||
|
|
||||||
| Flag | Description |
|
|
||||||
|--------------------|--------------------------------------------------------------------------------------------------------------------|
|
|
||||||
| `-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. |
|
|
||||||
|
|
||||||
**Behavior:**
|
|
||||||
- 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
|
|
||||||
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
|
|
||||||
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:**
|
|
||||||
```
|
|
||||||
porthole open db
|
|
||||||
porthole open db --foreground
|
|
||||||
porthole open proxy --once
|
|
||||||
porthole open --all
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.3 `porthole close <name> [flags]`
|
|
||||||
Alias: `stop`
|
|
||||||
|
|
||||||
Stops a running forward and removes its instance file. Profile definition
|
|
||||||
is untouched.
|
|
||||||
|
|
||||||
| Flag | Description |
|
|
||||||
|-----------|----------------------------------------------------------------------------------|
|
|
||||||
| `--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:**
|
|
||||||
```
|
|
||||||
porthole close db
|
|
||||||
porthole close db --force
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.4 `porthole edit <name> [flags]`
|
|
||||||
|
|
||||||
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
|
|
||||||
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:**
|
|
||||||
```
|
|
||||||
porthole edit db --retry-interval 10
|
|
||||||
porthole edit db --local 5433:db.internal:5432
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.5 `porthole status <name>`
|
|
||||||
|
|
||||||
Deep-dive health for one forward.
|
|
||||||
|
|
||||||
| Flag | Description |
|
|
||||||
|----------|-----------------------------------------------------|
|
|
||||||
| `--json` | Machine-readable output (matches `list --json`) |
|
|
||||||
|
|
||||||
**Output includes:**
|
|
||||||
- Profile summary (kind, mapping, via, user)
|
|
||||||
- Current state (`up` / `reconnecting` / `error`, or `closed` if no
|
|
||||||
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
|
|
||||||
- Last error message, if any
|
|
||||||
- Path to log file
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
```
|
|
||||||
porthole status db
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.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`
|
|
||||||
|
|
||||||
A profile with no instance file shows `STATE: closed` and an empty
|
|
||||||
`UPTIME`. `UPTIME` otherwise shows connection uptime (§5.5).
|
|
||||||
|
|
||||||
**Flags:**
|
|
||||||
|
|
||||||
| Flag | Description |
|
|
||||||
|-------------|-------------------------------------------------------------------|
|
|
||||||
| `--running` | Show only currently-open forwards (`STATE` in `up`/`reconnecting`) |
|
|
||||||
| `--json` | Machine-readable output |
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
```
|
|
||||||
porthole list
|
|
||||||
porthole list --running
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.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 |
|
|
||||||
|
|
||||||
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`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.8 `porthole wipe`
|
|
||||||
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` -
|
|
||||||
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 |
|
|
||||||
|-------------|----------------------------|
|
|
||||||
| `-y, --yes` | Skip confirmation prompt |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.9 `porthole completions <shell>`
|
|
||||||
|
|
||||||
Generates a shell completion script. `<shell>` ∈ `bash`, `zsh`, `fish`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Global options
|
|
||||||
|
|
||||||
| Flag | Description |
|
|
||||||
|-----------------|----------------|
|
|
||||||
| `-h, --help` | Print help |
|
|
||||||
| `-V, --version` | Print version |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Exit codes
|
|
||||||
|
|
||||||
| Code | Meaning |
|
|
||||||
|------|--------------------------------------------------------------------|
|
|
||||||
| `0` | Success |
|
|
||||||
| `1` | Error - see the printed message |
|
|
||||||
| `2` | CLI usage error (bad/missing arguments - clap's own exit code) |
|
|
||||||
|
|
||||||
Kept deliberately flat, matching vmic: granular per-failure codes (not
|
|
||||||
found vs. already-exists vs. bind conflict, etc.) only pay for themselves
|
|
||||||
once something is actually scripting against them, and nobody's asked for
|
|
||||||
that yet. Every error still gets a specific, greppable message on stderr.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Surviving reboots
|
|
||||||
|
|
||||||
**Explicitly out of scope for v0.1**, and worth calling out since it's
|
|
||||||
adjacent to the overview's own motivating problem: the supervisor is a
|
|
||||||
plain process, not a system service, so a full reboot or logout kills it
|
|
||||||
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.
|
|
||||||
@@ -12,7 +12,7 @@ pub struct Cli {
|
|||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
pub enum Commands {
|
pub enum Commands {
|
||||||
/// Save a new forward profile.
|
/// Save a new forward profile.
|
||||||
#[command(visible_alias = "create", visible_alias = "new")]
|
#[command(visible_alias = "create", visible_alias = "mk")]
|
||||||
Add(AddArgs),
|
Add(AddArgs),
|
||||||
|
|
||||||
/// Start a saved forward as a supervised background process.
|
/// Start a saved forward as a supervised background process.
|
||||||
@@ -42,6 +42,7 @@ pub enum Commands {
|
|||||||
Wipe(WipeArgs),
|
Wipe(WipeArgs),
|
||||||
|
|
||||||
/// Export saved profiles to a file, or import them from one.
|
/// Export saved profiles to a file, or import them from one.
|
||||||
|
#[command(visible_alias = "data")]
|
||||||
Transfer(TransferArgs),
|
Transfer(TransferArgs),
|
||||||
|
|
||||||
/// Generate a shell completion script.
|
/// Generate a shell completion script.
|
||||||
|
|||||||
162
src/ssh.rs
162
src/ssh.rs
@@ -1,62 +1,107 @@
|
|||||||
//! Builds the `ssh` invocation for a profile.
|
//! Builds the `ssh` invocation for a profile.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::fmt::Write as _;
|
||||||
use std::process::{ Stdio, Command };
|
use std::process::{ Stdio, Command };
|
||||||
|
|
||||||
use crate::profile::Profile;
|
use crate::profile::Profile;
|
||||||
|
|
||||||
|
/// A throwaway `ssh_config` applying the same hardening (and identity, if
|
||||||
|
/// any) to every host, so `-J`'s inner proxy connection picks it up too
|
||||||
|
/// instead of falling back to default identities and prompting for
|
||||||
|
/// host-key confirmation on a `/dev/null`-less stdin. Deleted on drop, so
|
||||||
|
/// callers just need to keep this alive for as long as the `ssh` process
|
||||||
|
/// that reads it runs.
|
||||||
|
pub struct JumpConfig(PathBuf);
|
||||||
|
|
||||||
|
impl JumpConfig
|
||||||
|
{
|
||||||
|
/// Named after the profile and this process's pid rather than
|
||||||
|
/// something unique per call, so a long-lived supervisor's repeated
|
||||||
|
/// reconnect attempts overwrite the same file instead of littering a
|
||||||
|
/// new one on every retry.
|
||||||
|
fn write(profile: &Profile) -> Option<Self>
|
||||||
|
{
|
||||||
|
let mut body = String::from("Host *\n");
|
||||||
|
for (key, value) in forced_options(profile) { writeln!(body, "\t{key} {value}").ok()?; }
|
||||||
|
if let Some(identity) = &profile.identity
|
||||||
|
{
|
||||||
|
writeln!(body, "\tIdentityFile {identity}").ok()?;
|
||||||
|
writeln!(body, "\tIdentitiesOnly yes").ok()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = std::env::temp_dir().join(format!("porthole-{}-{}.sshconfig", profile.name, std::process::id()));
|
||||||
|
std::fs::write(&path, body).ok()?;
|
||||||
|
Some(Self(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arg(&self) -> &str { self.0.to_str().unwrap_or("/dev/null") }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for JumpConfig
|
||||||
|
{
|
||||||
|
fn drop(&mut self) { let _ = std::fs::remove_file(&self.0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
/// The `-o` options forced on every invocation, not user-configurable.
|
||||||
|
/// Shared by `build()` (as command-line `-o key=value` args) and
|
||||||
|
/// `JumpConfig::write` (as `ssh_config` lines), so the two representations
|
||||||
|
/// of "what's forced" can't drift apart.
|
||||||
|
fn forced_options(profile: &Profile) -> [(&'static str, String); 9]
|
||||||
|
{
|
||||||
|
[
|
||||||
|
("BatchMode", "yes".into()),
|
||||||
|
("StrictHostKeyChecking", "accept-new".into()),
|
||||||
|
("LogLevel", "ERROR".into()),
|
||||||
|
("ExitOnForwardFailure", "yes".into()),
|
||||||
|
("ConnectTimeout", "10".into()),
|
||||||
|
("ServerAliveCountMax", "3".into()),
|
||||||
|
("ControlMaster", "no".into()),
|
||||||
|
("ControlPath", "none".into()),
|
||||||
|
("ServerAliveInterval", profile.keepalive.to_string()),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
/// Builds the `ssh` command for `profile`, stdio wired for the supervisor
|
/// Builds the `ssh` command for `profile`, stdio wired for the supervisor
|
||||||
/// to capture (stdout/stderr piped so failure text can be classified;
|
/// to capture (stdout/stderr piped so failure text can be classified;
|
||||||
/// stdin from `/dev/null` since porthole never wants a shell).
|
/// stdin from `/dev/null` since porthole never wants a shell). The second
|
||||||
pub fn build(profile: &Profile) -> Command {
|
/// return value, when present, must outlive the spawned process: it owns
|
||||||
|
/// the config file `-F` points at and deletes it on drop.
|
||||||
|
pub fn build(profile: &Profile) -> (Command, Option<JumpConfig>)
|
||||||
|
{
|
||||||
let mut cmd = Command::new("ssh");
|
let mut cmd = Command::new("ssh");
|
||||||
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||||
|
|
||||||
// Flags forced on every invocation, not user-configurable.
|
|
||||||
cmd.args([
|
|
||||||
"-o",
|
|
||||||
"BatchMode=yes",
|
|
||||||
"-o",
|
|
||||||
"StrictHostKeyChecking=accept-new",
|
|
||||||
"-o",
|
|
||||||
"LogLevel=ERROR",
|
|
||||||
"-o",
|
|
||||||
"ExitOnForwardFailure=yes",
|
|
||||||
"-o",
|
|
||||||
"ConnectTimeout=10",
|
|
||||||
"-o",
|
|
||||||
"ServerAliveCountMax=3",
|
|
||||||
"-o",
|
|
||||||
"ControlMaster=no",
|
|
||||||
"-o",
|
|
||||||
"ControlPath=none",
|
|
||||||
"-o",
|
|
||||||
"ClearAllForwardings=yes",
|
|
||||||
"-o",
|
|
||||||
&format!("ServerAliveInterval={}", profile.keepalive),
|
|
||||||
"-N",
|
|
||||||
"-T",
|
|
||||||
]);
|
|
||||||
|
|
||||||
let (jumps, target) = profile.ssh_target();
|
let (jumps, target) = profile.ssh_target();
|
||||||
if let Some(jumps) = jumps {
|
|
||||||
cmd.args(["-J", &jumps]);
|
let jump_config = jumps.is_some().then(|| JumpConfig::write(profile)).flatten();
|
||||||
}
|
let config_arg = jump_config.as_ref().map_or("/dev/null", |c| c.arg());
|
||||||
|
|
||||||
|
cmd.args(["-F", config_arg]);
|
||||||
|
for (key, value) in forced_options(profile) { cmd.args(["-o", &format!("{key}={value}")]); }
|
||||||
|
cmd.args(["-N", "-T"]);
|
||||||
|
|
||||||
|
if let Some(jumps) = jumps { cmd.args(["-J", &jumps]); }
|
||||||
|
|
||||||
cmd.arg("-p").arg(profile.ssh_port.to_string());
|
cmd.arg("-p").arg(profile.ssh_port.to_string());
|
||||||
if let Some(identity) = &profile.identity {
|
if let Some(identity) = &profile.identity
|
||||||
|
{
|
||||||
cmd.arg("-i").arg(identity);
|
cmd.arg("-i").arg(identity);
|
||||||
cmd.args(["-o", "IdentitiesOnly=yes"]);
|
cmd.args(["-o", "IdentitiesOnly=yes"]);
|
||||||
}
|
}
|
||||||
if let Some(user) = &profile.user {
|
if let Some(user) = &profile.user { cmd.arg("-l").arg(user); }
|
||||||
cmd.arg("-l").arg(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.arg(profile.kind.ssh_flag()).arg(&profile.mapping);
|
cmd.arg(profile.kind.ssh_flag()).arg(&profile.mapping);
|
||||||
cmd.arg(target);
|
cmd.arg(target);
|
||||||
|
|
||||||
cmd
|
(cmd, jump_config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -75,17 +120,50 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_the_callers_own_ssh_config() {
|
||||||
|
let (cmd, _guard) = build(&profile_with(vec!["jumpbox"]));
|
||||||
|
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||||
|
assert!(args.windows(2).any(|w| w == ["-F", "/dev/null"]));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn single_hop_has_no_dash_j() {
|
fn single_hop_has_no_dash_j() {
|
||||||
let cmd = build(&profile_with(vec!["jumpbox"]));
|
let (cmd, _guard) = build(&profile_with(vec!["jumpbox"]));
|
||||||
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||||
assert!(!args.contains(&"-J".to_string()));
|
assert!(!args.contains(&"-J".to_string()));
|
||||||
assert_eq!(args.last(), Some(&"jumpbox".to_string()));
|
assert_eq!(args.last(), Some(&"jumpbox".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_hop_writes_a_config_carrying_hardening_and_identity_to_every_hop() {
|
||||||
|
let p = Profile::new(
|
||||||
|
"t2".into(),
|
||||||
|
&ProfileEdits {
|
||||||
|
local: Some("5432:db.internal:5432".into()),
|
||||||
|
via: Some(vec!["bastion1".into(), "bastion2".into()]),
|
||||||
|
identity: Some("/home/me/.ssh/id_ed25519".into()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let (cmd, guard) = build(&p);
|
||||||
|
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||||
|
let f_idx = args.iter().position(|a| a == "-F").expect("-F present");
|
||||||
|
let config_path = &args[f_idx + 1];
|
||||||
|
assert_ne!(config_path, "/dev/null");
|
||||||
|
let contents = std::fs::read_to_string(config_path).expect("config file should exist");
|
||||||
|
assert!(contents.contains("BatchMode yes"));
|
||||||
|
assert!(contents.contains("StrictHostKeyChecking accept-new"));
|
||||||
|
assert!(contents.contains("IdentityFile /home/me/.ssh/id_ed25519"));
|
||||||
|
assert!(contents.contains("IdentitiesOnly yes"));
|
||||||
|
drop(guard);
|
||||||
|
assert!(!std::path::Path::new(config_path).exists());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multi_hop_splits_jumps_from_target() {
|
fn multi_hop_splits_jumps_from_target() {
|
||||||
let cmd = build(&profile_with(vec!["bastion1", "bastion2:2222"]));
|
let (cmd, _guard) = build(&profile_with(vec!["bastion1", "bastion2:2222"]));
|
||||||
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||||
let j_idx = args.iter().position(|a| a == "-J").expect("-J present");
|
let j_idx = args.iter().position(|a| a == "-J").expect("-J present");
|
||||||
assert_eq!(args[j_idx + 1], "bastion1");
|
assert_eq!(args[j_idx + 1], "bastion1");
|
||||||
@@ -96,7 +174,7 @@ mod tests {
|
|||||||
fn includes_forward_flag_and_mapping() {
|
fn includes_forward_flag_and_mapping() {
|
||||||
let p = profile_with(vec!["jumpbox"]);
|
let p = profile_with(vec!["jumpbox"]);
|
||||||
assert_eq!(p.kind, Kind::Local);
|
assert_eq!(p.kind, Kind::Local);
|
||||||
let cmd = build(&p);
|
let (cmd, _guard) = build(&p);
|
||||||
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||||
let l_idx = args.iter().position(|a| a == "-L").expect("-L present");
|
let l_idx = args.iter().position(|a| a == "-L").expect("-L present");
|
||||||
assert_eq!(args[l_idx + 1], "5432:db.internal:5432");
|
assert_eq!(args[l_idx + 1], "5432:db.internal:5432");
|
||||||
@@ -114,7 +192,8 @@ mod tests {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let args: Vec<String> = build(&p).get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
let (cmd, _guard) = build(&p);
|
||||||
|
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||||
let i_idx = args.iter().position(|a| a == "-i").expect("-i present");
|
let i_idx = args.iter().position(|a| a == "-i").expect("-i present");
|
||||||
assert_eq!(args[i_idx + 1], "/home/me/.ssh/id_ed25519");
|
assert_eq!(args[i_idx + 1], "/home/me/.ssh/id_ed25519");
|
||||||
assert!(args.windows(2).any(|w| w == ["-o", "IdentitiesOnly=yes"]));
|
assert!(args.windows(2).any(|w| w == ["-o", "IdentitiesOnly=yes"]));
|
||||||
@@ -124,7 +203,8 @@ mod tests {
|
|||||||
fn identities_only_absent_without_identity() {
|
fn identities_only_absent_without_identity() {
|
||||||
let p = profile_with(vec!["jumpbox"]);
|
let p = profile_with(vec!["jumpbox"]);
|
||||||
assert!(p.identity.is_none());
|
assert!(p.identity.is_none());
|
||||||
let args: Vec<String> = build(&p).get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
let (cmd, _guard) = build(&p);
|
||||||
|
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||||
assert!(!args.windows(2).any(|w| w == ["-o", "IdentitiesOnly=yes"]));
|
assert!(!args.windows(2).any(|w| w == ["-o", "IdentitiesOnly=yes"]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,8 +57,9 @@ enum Outcome {
|
|||||||
|
|
||||||
/// Entry point for `porthole __supervise <name>`. This function is the
|
/// Entry point for `porthole __supervise <name>`. This function is the
|
||||||
/// supervisor process: it runs until told to stop (SIGTERM/SIGINT) or
|
/// supervisor process: it runs until told to stop (SIGTERM/SIGINT) or
|
||||||
/// gives up per §4.
|
/// gives up.
|
||||||
pub fn run(name: &str) -> Result<()> {
|
pub fn run(name: &str) -> Result<()>
|
||||||
|
{
|
||||||
install_signal_handler();
|
install_signal_handler();
|
||||||
|
|
||||||
let profile = profile::load(name)?;
|
let profile = profile::load(name)?;
|
||||||
@@ -154,13 +155,16 @@ fn sleep_or_shutdown(dur: Duration) -> bool
|
|||||||
/// requested. Marks `inst` as `State::Up` once the process has survived
|
/// requested. Marks `inst` as `State::Up` once the process has survived
|
||||||
/// `CONNECT_GRACE`. `ssh` does not report "the forward is bound" directly
|
/// `CONNECT_GRACE`. `ssh` does not report "the forward is bound" directly
|
||||||
/// without parsing `-v` debug output; a real failure exits near-instantly
|
/// without parsing `-v` debug output; a real failure exits near-instantly
|
||||||
/// under `ExitOnForwardFailure=yes` (§3.1), so staying alive past the grace
|
/// under `ExitOnForwardFailure=yes`, so staying alive past the grace
|
||||||
/// window is used as a proxy for connected.
|
/// window is used as a proxy for connected.
|
||||||
fn run_ssh_once(name: &str, profile: &Profile, inst: &mut Instance) -> Outcome
|
fn run_ssh_once(name: &str, profile: &Profile, inst: &mut Instance) -> Outcome
|
||||||
{
|
{
|
||||||
rotate_log_if_large(name);
|
rotate_log_if_large(name);
|
||||||
|
|
||||||
let mut cmd = ssh::build(profile);
|
// `_jump_config`, when present, must stay alive for this whole
|
||||||
|
// function: it owns the config file `-F` points ssh at, and every
|
||||||
|
// return path below runs the ssh process to completion first.
|
||||||
|
let (mut cmd, _jump_config) = ssh::build(profile);
|
||||||
let mut child = match cmd.spawn() {
|
let mut child = match cmd.spawn() {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => return Outcome::Failed { class: Class::Unrecognized, message: format!("failed to spawn ssh: {e}") },
|
Err(e) => return Outcome::Failed { class: Class::Unrecognized, message: format!("failed to spawn ssh: {e}") },
|
||||||
|
|||||||
@@ -120,7 +120,16 @@ p_run() { LAST_OUT="$(PORTHOLE_STATE_DIR_OVERRIDE="$1" "$BIN" "${@:2}" 2>&1)"; L
|
|||||||
# status.rs); polling that key is far more robust than scraping the padded
|
# status.rs); polling that key is far more robust than scraping the padded
|
||||||
# human-readable field.
|
# human-readable field.
|
||||||
json_field() { # json_field <state-dir> <name> <field> -> value, empty if absent/no instance
|
json_field() { # json_field <state-dir> <name> <field> -> value, empty if absent/no instance
|
||||||
p "$1" status "$2" --json 2>/dev/null | sed -n "s/.*\"$3\": \"\\{0,1\\}\\([^\",]*\\)\"\\{0,1\\},\\{0,1\\}\$/\\1/p" | head -1
|
local out v
|
||||||
|
out="$(p "$1" status "$2" --json 2>/dev/null)"
|
||||||
|
# Quoted string value first: the closing quote is mandatory here (unlike
|
||||||
|
# a bare [^",]* class) so a value with an embedded comma - a real ssh
|
||||||
|
# "Permission denied (publickey,password)." error has one - isn't
|
||||||
|
# truncated at the first comma instead of its actual end.
|
||||||
|
v="$(sed -n "s/.*\"$3\": \"\\(.*\\)\",\\{0,1\\}\$/\\1/p" <<<"$out" | head -1)"
|
||||||
|
if [[ -n "$v" ]]; then echo "$v"; return; fi
|
||||||
|
# Bare (unquoted) value: number, bool, or null.
|
||||||
|
sed -n "s/.*\"$3\": \\([^\",]*\\),\\{0,1\\}\$/\\1/p" <<<"$out" | head -1
|
||||||
}
|
}
|
||||||
wait_for_state() { # wait_for_state <state-dir> <name> <want-state> [tries, x0.5s]
|
wait_for_state() { # wait_for_state <state-dir> <name> <want-state> [tries, x0.5s]
|
||||||
local dir="$1" name="$2" want="$3" tries="${4:-20}"
|
local dir="$1" name="$2" want="$3" tries="${4:-20}"
|
||||||
@@ -238,7 +247,7 @@ p_run "$STATE_DIR" status "${NAME}_local"; expect_contains "fresh profile is clo
|
|||||||
p_run "$STATE_DIR" open "${NAME}_local"; expect_exit "'open ${NAME}_local'" 0
|
p_run "$STATE_DIR" open "${NAME}_local"; expect_exit "'open ${NAME}_local'" 0
|
||||||
assert_true "'${NAME}_local' reaches state: up" wait_for_state "$STATE_DIR" "${NAME}_local" up 10
|
assert_true "'${NAME}_local' reaches state: up" wait_for_state "$STATE_DIR" "${NAME}_local" up 10
|
||||||
|
|
||||||
if ssh "${SSH_PROBE_OPTS[@]}" -p "$LOCAL_PORT" localhost true 2>/tmp/porthole_test_probe.$$; then
|
if ssh "${SSH_PROBE_OPTS[@]}" -i "$IDENTITY" -o IdentitiesOnly=yes -l "$USER_" -p "$LOCAL_PORT" localhost true 2>/tmp/porthole_test_probe.$$; then
|
||||||
pass "forwarded port $LOCAL_PORT actually round-trips to the real sshd"
|
pass "forwarded port $LOCAL_PORT actually round-trips to the real sshd"
|
||||||
else
|
else
|
||||||
fail "forwarded port $LOCAL_PORT actually round-trips to the real sshd" "$(cat /tmp/porthole_test_probe.$$ 2>/dev/null)"
|
fail "forwarded port $LOCAL_PORT actually round-trips to the real sshd" "$(cat /tmp/porthole_test_probe.$$ 2>/dev/null)"
|
||||||
@@ -259,9 +268,15 @@ expect_exit "'add ${NAME}_remote'" 0
|
|||||||
p_run "$STATE_DIR" open "${NAME}_remote"; expect_exit "'open ${NAME}_remote'" 0
|
p_run "$STATE_DIR" open "${NAME}_remote"; expect_exit "'open ${NAME}_remote'" 0
|
||||||
assert_true "'${NAME}_remote' reaches state: up" wait_for_state "$STATE_DIR" "${NAME}_remote" up 10
|
assert_true "'${NAME}_remote' reaches state: up" wait_for_state "$STATE_DIR" "${NAME}_remote" up 10
|
||||||
|
|
||||||
remote_check="$(ssh "${SSH_PROBE_OPTS[@]}" -i "$IDENTITY" "$USER_@$HOST" \
|
# The inner ssh below runs on the remote box itself (reached through the
|
||||||
"ssh -p $REMOTE_PORT -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null localhost true && echo REMOTE_FORWARD_OK" 2>&1)"
|
# outer session), then loops back out through the -R forward to a real
|
||||||
if [[ "$remote_check" == *REMOTE_FORWARD_OK* ]]; then
|
# sshd on this same server - $IDENTITY is a local path and won't exist
|
||||||
|
# there, so it has no credentials for that final hop. Reaching the real
|
||||||
|
# sshd and being rejected already proves the forward round-trips; a
|
||||||
|
# refused/timed-out connection is what would indicate it's actually broken.
|
||||||
|
remote_check="$(ssh "${SSH_PROBE_OPTS[@]}" -i "$IDENTITY" -o IdentitiesOnly=yes "$USER_@$HOST" \
|
||||||
|
"ssh -p $REMOTE_PORT -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null localhost true; echo EXIT:\$?" 2>&1)"
|
||||||
|
if [[ "$remote_check" == *"EXIT:0"* || "$remote_check" == *"Permission denied"* ]]; then
|
||||||
pass "remote-bound port $REMOTE_PORT round-trips back out through the tunnel"
|
pass "remote-bound port $REMOTE_PORT round-trips back out through the tunnel"
|
||||||
else
|
else
|
||||||
fail "remote-bound port $REMOTE_PORT round-trips back out through the tunnel" "$remote_check"
|
fail "remote-bound port $REMOTE_PORT round-trips back out through the tunnel" "$remote_check"
|
||||||
@@ -298,19 +313,30 @@ fi
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
section "Phase 6: multi-hop --via (self-jump - see header comment for why)"
|
section "Phase 6: multi-hop --via (self-jump - see header comment for why)"
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
# A real ssh refuses a ProxyJump hop that's textually identical to the
|
||||||
|
# final target ("jumphost loop via ..."), so USER@HOST,USER@HOST can never
|
||||||
|
# work no matter how porthole builds the command - it's rejected before a
|
||||||
|
# connection is even attempted. Using the server's IP for the first hop and
|
||||||
|
# its hostname for the final target is the same physical box (still a real
|
||||||
|
# 2-hop handshake) but sidesteps that string-identity check.
|
||||||
HOP_PORT=28223
|
HOP_PORT=28223
|
||||||
p_run "$STATE_DIR" add "${NAME}_multihop" -l "$HOP_PORT:localhost:22" -i "$IDENTITY" --via "$USER_@$HOST,$USER_@$HOST"
|
HOST_IP="$(getent ahostsv4 "$HOST" 2>/dev/null | awk '{print $1; exit}')"
|
||||||
expect_exit "'add ${NAME}_multihop' with a 2-hop --via" 0
|
if [[ -z "$HOST_IP" ]]; then
|
||||||
p_run "$STATE_DIR" open "${NAME}_multihop"; expect_exit "'open ${NAME}_multihop'" 0
|
skip "multi-hop phase (could not resolve $HOST to an IP for the loop workaround)"
|
||||||
assert_true "'${NAME}_multihop' reaches state: up (real -J handshake, twice)" wait_for_state "$STATE_DIR" "${NAME}_multihop" up 30
|
|
||||||
|
|
||||||
if ssh "${SSH_PROBE_OPTS[@]}" -p "$HOP_PORT" localhost true 2>/tmp/porthole_test_probe.$$; then
|
|
||||||
pass "forwarded port round-trips through the 2-hop chain"
|
|
||||||
else
|
else
|
||||||
fail "forwarded port round-trips through the 2-hop chain" "$(cat /tmp/porthole_test_probe.$$ 2>/dev/null)"
|
p_run "$STATE_DIR" add "${NAME}_multihop" -l "$HOP_PORT:localhost:22" -i "$IDENTITY" --via "$USER_@$HOST_IP,$USER_@$HOST"
|
||||||
|
expect_exit "'add ${NAME}_multihop' with a 2-hop --via" 0
|
||||||
|
p_run "$STATE_DIR" open "${NAME}_multihop"; expect_exit "'open ${NAME}_multihop'" 0
|
||||||
|
assert_true "'${NAME}_multihop' reaches state: up (real -J handshake, twice)" wait_for_state "$STATE_DIR" "${NAME}_multihop" up 30
|
||||||
|
|
||||||
|
if ssh "${SSH_PROBE_OPTS[@]}" -i "$IDENTITY" -o IdentitiesOnly=yes -l "$USER_" -p "$HOP_PORT" localhost true 2>/tmp/porthole_test_probe.$$; then
|
||||||
|
pass "forwarded port round-trips through the 2-hop chain"
|
||||||
|
else
|
||||||
|
fail "forwarded port round-trips through the 2-hop chain" "$(cat /tmp/porthole_test_probe.$$ 2>/dev/null)"
|
||||||
|
fi
|
||||||
|
rm -f "/tmp/porthole_test_probe.$$" 2>/dev/null
|
||||||
|
p_run "$STATE_DIR" close "${NAME}_multihop"; expect_exit "'close ${NAME}_multihop'" 0
|
||||||
fi
|
fi
|
||||||
rm -f "/tmp/porthole_test_probe.$$" 2>/dev/null
|
|
||||||
p_run "$STATE_DIR" close "${NAME}_multihop"; expect_exit "'close ${NAME}_multihop'" 0
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
section "Phase 7: -u/--user without an embedded via user - real -l flag auth"
|
section "Phase 7: -u/--user without an embedded via user - real -l flag auth"
|
||||||
@@ -349,8 +375,15 @@ expect_exit "'add ${NAME}_deadport' targeting a closed port on the real host" 0
|
|||||||
p_run "$STATE_DIR" open "${NAME}_deadport"; expect_exit "'open ${NAME}_deadport' (backgrounds even though the first attempt fails)" 0
|
p_run "$STATE_DIR" open "${NAME}_deadport"; expect_exit "'open ${NAME}_deadport' (backgrounds even though the first attempt fails)" 0
|
||||||
assert_true "'${NAME}_deadport' keeps reconnecting rather than giving up" wait_for_state "$STATE_DIR" "${NAME}_deadport" reconnecting 20
|
assert_true "'${NAME}_deadport' keeps reconnecting rather than giving up" wait_for_state "$STATE_DIR" "${NAME}_deadport" reconnecting 20
|
||||||
rc1="$(json_field "$STATE_DIR" "${NAME}_deadport" reconnect_count)"
|
rc1="$(json_field "$STATE_DIR" "${NAME}_deadport" reconnect_count)"
|
||||||
sleep 6
|
# Port 9 may be silently dropped rather than actively refused, so a single
|
||||||
rc2="$(json_field "$STATE_DIR" "${NAME}_deadport" reconnect_count)"
|
# attempt can burn the full ConnectTimeout=10s; poll well past worst-case
|
||||||
|
# instead of a fixed sleep that assumes an instant refusal.
|
||||||
|
rc2="$rc1"
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
rc2="$(json_field "$STATE_DIR" "${NAME}_deadport" reconnect_count)"
|
||||||
|
[[ -n "$rc2" && "$rc2" -gt "${rc1:-0}" ]] && break
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
if [[ -n "$rc2" && "$rc2" -gt "${rc1:-0}" ]]; then
|
if [[ -n "$rc2" && "$rc2" -gt "${rc1:-0}" ]]; then
|
||||||
pass "reconnect_count keeps increasing on a real refused connection ($rc1 -> $rc2)"
|
pass "reconnect_count keeps increasing on a real refused connection ($rc1 -> $rc2)"
|
||||||
else
|
else
|
||||||
@@ -455,8 +488,17 @@ else
|
|||||||
expect_contains "wipe reports what it did" "Wiped all forwards"
|
expect_contains "wipe reports what it did" "Wiped all forwards"
|
||||||
|
|
||||||
p_run "$STATE_DIR" list; expect_contains "'list' is empty after wipe" "No profiles saved."
|
p_run "$STATE_DIR" list; expect_contains "'list' is empty after wipe" "No profiles saved."
|
||||||
assert_true "the phase-13 orphan is gone too (kill_orphaned_supervisors)" bash -c \
|
# kill_orphaned_supervisors only sends SIGTERM and returns; the orphan's
|
||||||
"! pgrep -f '__supervise ${NAME}_keep\$' >/dev/null"
|
# own signal handler needs a moment to shut down, so give it a few
|
||||||
|
# retries rather than checking the instant `wipe` returns.
|
||||||
|
wait_for_orphan_gone() {
|
||||||
|
for _ in $(seq 1 10); do
|
||||||
|
pgrep -f "__supervise ${NAME}_keep\$" >/dev/null || return 0
|
||||||
|
sleep 0.3
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
assert_true "the phase-13 orphan is gone too (kill_orphaned_supervisors)" wait_for_orphan_gone
|
||||||
assert_true "port $KEEP_PORT is no longer listening" wait_port_closed "$KEEP_PORT" 6
|
assert_true "port $KEEP_PORT is no longer listening" wait_port_closed "$KEEP_PORT" 6
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user