Compare commits
11 Commits
develop
...
0aab042859
| Author | SHA1 | Date | |
|---|---|---|---|
| 0aab042859 | |||
| 07518f1190 | |||
| 1b947fec24 | |||
| e36360e18c | |||
| 4170d51cf5 | |||
| 4a8faf1131 | |||
| 0c9f0585fa | |||
| bc8f1cc5d2 | |||
| b18ee7405e | |||
| 5675200a3c | |||
| 11302572e0 |
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -220,7 +220,7 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "porthole"
|
||||
version = "1.0.0"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "porthole"
|
||||
version = "1.0.0"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Create and manage named SSH port forwards."
|
||||
license = "AGPL-3+"
|
||||
|
||||
165
README.md
165
README.md
@@ -1,166 +1,3 @@
|
||||
# porthole
|
||||
|
||||
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
|
||||
```
|
||||
Create and manage named SSH port fowards easily.
|
||||
461
spec/porthole-spec.md
Normal file
461
spec/porthole-spec.md
Normal file
@@ -0,0 +1,461 @@
|
||||
# `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 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. |
|
||||
|
||||
`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.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
postgres` implies port 5432) — worth adding as sugar over `add`, or
|
||||
scope creep?
|
||||
- Config export/import for moving profiles between machines.
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Atomic file writes: write to a sibling temp file, then `rename` over the
|
||||
//! target. Matters here specifically for the instance JSON file, which the
|
||||
//! supervisor rewrites on every state change while it may be alive for
|
||||
//! months; a reader (`status`/`list`) must never observe a half-written
|
||||
//! months - a reader (`status`/`list`) must never observe a half-written
|
||||
//! file, and a crash mid-write must never corrupt the last-known-good state.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
26
src/cli.rs
26
src/cli.rs
@@ -1,5 +1,5 @@
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
use clap_complete::Shell;
|
||||
use clap::{ Args, Parser, Subcommand };
|
||||
|
||||
/// Create and manage named SSH port forwards.
|
||||
#[derive(Parser)]
|
||||
@@ -12,7 +12,7 @@ pub struct Cli {
|
||||
#[derive(Subcommand)]
|
||||
pub enum Commands {
|
||||
/// Save a new forward profile.
|
||||
#[command(visible_alias = "create", visible_alias = "mk")]
|
||||
#[command(visible_alias = "create", visible_alias = "new")]
|
||||
Add(AddArgs),
|
||||
|
||||
/// Start a saved forward as a supervised background process.
|
||||
@@ -41,20 +41,16 @@ pub enum Commands {
|
||||
#[command(visible_alias = "reset")]
|
||||
Wipe(WipeArgs),
|
||||
|
||||
/// Export saved profiles to a file, or import them from one.
|
||||
#[command(visible_alias = "data")]
|
||||
Transfer(TransferArgs),
|
||||
|
||||
/// Generate a shell completion script.
|
||||
Completions { shell: Shell },
|
||||
|
||||
/// Internal: runs the supervisor loop for one profile. Not for direct
|
||||
/// use; `open` spawns this itself.
|
||||
/// use - `open` spawns this itself (spec §3).
|
||||
#[command(hide = true, name = "__supervise")]
|
||||
Supervise { name: String },
|
||||
}
|
||||
|
||||
/// Shared mapping/connection flags for `add` and `edit`, kept as one
|
||||
/// Shared mapping/connection flags for `add` and `edit` - kept as one
|
||||
/// struct (`#[command(flatten)]`ed into both) so the two can never drift.
|
||||
#[derive(Args, Default)]
|
||||
pub struct MappingArgs {
|
||||
@@ -186,17 +182,3 @@ pub struct WipeArgs {
|
||||
#[arg(short, long)]
|
||||
pub yes: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct TransferArgs {
|
||||
/// Restrict export/import to just this profile; every profile in scope if omitted.
|
||||
pub name: Option<String>,
|
||||
|
||||
/// Export saved profiles to a file.
|
||||
#[arg(short, long, value_name = "PATH.toml")]
|
||||
pub export: Option<String>,
|
||||
|
||||
/// Import profiles from a file.
|
||||
#[arg(short, long, value_name = "PATH.toml")]
|
||||
pub import: Option<String>,
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ pub fn run(args: CloseArgs) -> Result<()> {
|
||||
}
|
||||
|
||||
/// Stops `name`'s supervisor if one is actually running, and clears any
|
||||
/// stale instance file either way, shared with `remove` and `wipe`.
|
||||
/// Returns whether anything was actually running.
|
||||
/// stale instance file either way (spec §5.3) - shared with `remove` and
|
||||
/// `wipe`. Returns whether anything was actually running.
|
||||
pub fn close_instance(name: &str, force: bool) -> Result<bool> {
|
||||
let Some(pid) = instance::running_pid(name)? else {
|
||||
instance::delete(name)?; // clears a stale file left by a crash
|
||||
@@ -27,9 +27,9 @@ pub fn close_instance(name: &str, force: bool) -> Result<bool> {
|
||||
};
|
||||
|
||||
if force {
|
||||
// SIGKILL the whole process group (the supervisor is its own
|
||||
// group leader via setsid), not just the supervisor pid; a plain
|
||||
// single-pid SIGKILL would leave `ssh` orphaned.
|
||||
// spec §3 step 4: SIGKILL the whole process group (the supervisor
|
||||
// is its own group leader via setsid), not just the supervisor pid
|
||||
// - a plain single-pid SIGKILL would leave `ssh` orphaned.
|
||||
unsafe { libc::kill(-pid, libc::SIGKILL) };
|
||||
} else {
|
||||
unsafe { libc::kill(pid, libc::SIGTERM) };
|
||||
|
||||
@@ -19,7 +19,7 @@ pub fn run(args: EditArgs) -> Result<()> {
|
||||
p.apply_edits(&edits)?;
|
||||
profile::save(&p)?;
|
||||
|
||||
// edit never restarts a running instance, just warn.
|
||||
// Spec §5.4: edit never restarts a running instance - just warn.
|
||||
if instance::running_pid(&name)?.is_some() {
|
||||
ui::warn(&format!(
|
||||
"'{name}' is currently open; this change won't take effect until the next open/close cycle."
|
||||
|
||||
@@ -6,13 +6,12 @@ pub mod list;
|
||||
pub mod open;
|
||||
pub mod remove;
|
||||
pub mod status;
|
||||
pub mod transfer;
|
||||
pub mod wipe;
|
||||
|
||||
use crate::cli::MappingArgs;
|
||||
use crate::profile::ProfileEdits;
|
||||
|
||||
/// Turns clap's `MappingArgs` into a `ProfileEdits`. `--via` is
|
||||
/// Turns clap's `MappingArgs` into a `ProfileEdits` (spec §5.1). `--via` is
|
||||
/// collected by clap itself: `value_delimiter = ','` splits each
|
||||
/// occurrence on commas, and the field being a `Vec` allows repeated
|
||||
/// `--via` flags, so both `--via a,b` and `--via a --via b` reach here as
|
||||
@@ -34,7 +33,7 @@ pub fn edits_from_mapping(m: &MappingArgs) -> ProfileEdits {
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` if `MappingArgs` carries no edits at all, used by `edit` to
|
||||
/// `true` if `MappingArgs` carries no edits at all - used by `edit` to
|
||||
/// reject a no-op invocation.
|
||||
pub fn mapping_is_empty(m: &MappingArgs) -> bool {
|
||||
m.local.is_none()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! `open` validates, then either runs the supervisor loop inline
|
||||
//! (`--foreground`) or spawns a detached copy of this binary
|
||||
//! `open` - spec §3/§5.2. Validates, then either runs the supervisor loop
|
||||
//! inline (`--foreground`) or spawns a detached copy of this binary
|
||||
//! (`porthole __supervise <name>`) and waits briefly for it to confirm.
|
||||
|
||||
use crate::cli::OpenArgs;
|
||||
@@ -24,9 +24,9 @@ pub fn run(args: OpenArgs) -> Result<()> {
|
||||
open_one(&name, args.foreground, args.once)
|
||||
}
|
||||
|
||||
/// Opens every `reconnect: true` profile that isn't already running, the
|
||||
/// hook external autostart mechanisms are meant to call. Per-profile
|
||||
/// failures are warnings, not a whole-batch failure.
|
||||
/// Opens every `reconnect: true` profile that isn't already running - the
|
||||
/// hook external autostart mechanisms are meant to call (spec §5.2/§8).
|
||||
/// Per-profile failures are warnings, not a whole-batch failure.
|
||||
fn open_all(once: bool) -> Result<()> {
|
||||
let profiles = profile::list_all()?;
|
||||
let mut opened = 0;
|
||||
@@ -57,8 +57,8 @@ fn open_one(name: &str, foreground: bool, once: bool) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
// Clear a stale instance file left by a crash before spawning. The
|
||||
// lock, not this file, is the authority on "already open"; this just
|
||||
// keeps `status` from reading stale state mid-spawn.
|
||||
// lock, not this file, is the authority on "already open" (spec
|
||||
// §5.2) - this just keeps `status` from reading stale state mid-spawn.
|
||||
instance::delete(name)?;
|
||||
|
||||
if foreground {
|
||||
@@ -73,10 +73,10 @@ fn open_one(name: &str, foreground: bool, once: bool) -> Result<()> {
|
||||
wait_for_confirmation(name)
|
||||
}
|
||||
|
||||
/// Spawns `porthole __supervise <name>` fully detached: stdin from
|
||||
/// `/dev/null`, stdout/stderr appended to the profile's log, and
|
||||
/// `setsid()` in the child so it leaves this process's session and
|
||||
/// survives the terminal closing.
|
||||
/// Spawns `porthole __supervise <name>` fully detached (spec §3 steps 1-2):
|
||||
/// stdin from `/dev/null`, stdout/stderr appended to the profile's log, and
|
||||
/// `setsid()` in the child so it leaves this process's session and survives
|
||||
/// the terminal closing.
|
||||
fn spawn_detached(name: &str, once: bool) -> Result<()> {
|
||||
let exe = std::env::current_exe()?;
|
||||
let log_path = instance::log_path(name);
|
||||
@@ -99,8 +99,8 @@ fn spawn_detached(name: &str, once: bool) -> Result<()> {
|
||||
|
||||
/// Blocks briefly for the detached supervisor to reach a conclusive state,
|
||||
/// so an immediate failure (bad auth, bind conflict, unresolvable host) is
|
||||
/// reported with a non-zero exit instead of `open` appearing to succeed.
|
||||
/// The instance file's initial write is always
|
||||
/// reported with a non-zero exit instead of `open` appearing to succeed
|
||||
/// (spec §5.2). The instance file's initial write is always
|
||||
/// `State::Reconnecting`, since the first attempt has not concluded yet;
|
||||
/// that value is indistinguishable from "already failed once, backing
|
||||
/// off". This function waits specifically for `Up` or `Error`, not merely
|
||||
|
||||
@@ -10,8 +10,8 @@ pub fn run(args: StatusArgs) -> Result<()> {
|
||||
let p = profile::load(&name)?;
|
||||
let inst = instance::load(&name)?;
|
||||
// An instance file whose pid isn't actually alive means the supervisor
|
||||
// crashed without cleaning up; report that, rather than trusting a
|
||||
// state the process table disagrees with.
|
||||
// crashed without cleaning up - report that, rather than trusting a
|
||||
// state the process table disagrees with (spec §2.2).
|
||||
let live = inst.as_ref().is_some_and(|i| instance::supervisor_alive(i.pid, &name));
|
||||
|
||||
if args.json {
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
//! Bulk profile backup/restore, a flat TOML array of the same `Profile`
|
||||
//! records `profile::save`/`load` already read and write, so it round-trips
|
||||
//! through the exact same serialization with nothing profile-specific here.
|
||||
|
||||
use crate::cli::TransferArgs;
|
||||
use crate::error::{PortholeError, Result};
|
||||
use crate::profile::{self, Profile};
|
||||
use crate::{atomic, ui};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct TransferFile {
|
||||
#[serde(rename = "profile", default)]
|
||||
profiles: Vec<Profile>,
|
||||
}
|
||||
|
||||
pub fn run(args: TransferArgs) -> Result<()> {
|
||||
match (&args.export, &args.import) {
|
||||
(Some(_), Some(_)) => Err(PortholeError::TransferConflictingMode),
|
||||
(None, None) => Err(PortholeError::TransferNoMode),
|
||||
(Some(path), None) => export(path, args.name.as_deref()),
|
||||
(None, Some(path)) => import(path, args.name.as_deref()),
|
||||
}
|
||||
}
|
||||
|
||||
fn export(path: &str, name: Option<&str>) -> Result<()> {
|
||||
let profiles = match name {
|
||||
Some(n) => {
|
||||
let n = profile::normalize(n);
|
||||
vec![profile::load(&n)?]
|
||||
}
|
||||
None => profile::list_all()?,
|
||||
};
|
||||
|
||||
warn_about_identities(&profiles);
|
||||
|
||||
let count = profiles.len();
|
||||
let file = TransferFile { profiles };
|
||||
let text = toml::to_string_pretty(&file)?;
|
||||
atomic::write(Path::new(path), text.as_bytes())?;
|
||||
|
||||
match name {
|
||||
Some(n) => ui::ok(&format!("Exported profile '{n}' to '{path}'.")),
|
||||
None => ui::ok(&format!("Exported {count} profile(s) to '{path}'.")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn import(path: &str, name: Option<&str>) -> Result<()> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
let file: TransferFile = toml::from_str(&text)?;
|
||||
|
||||
let selected = match name {
|
||||
Some(n) => {
|
||||
let n = profile::normalize(n);
|
||||
let found = file.profiles.into_iter().find(|p| p.name == n);
|
||||
vec![found.ok_or(PortholeError::TransferProfileNotFound(n))?]
|
||||
}
|
||||
None => file.profiles,
|
||||
};
|
||||
|
||||
for p in &selected {
|
||||
profile::require_valid_name(&p.name)?;
|
||||
if profile::exists(&p.name) {
|
||||
return Err(PortholeError::AlreadyExists(p.name.clone()));
|
||||
}
|
||||
}
|
||||
for p in &selected {
|
||||
profile::save(p)?;
|
||||
}
|
||||
|
||||
match name {
|
||||
Some(n) => ui::ok(&format!("Imported profile '{n}' from '{path}'.")),
|
||||
None => ui::ok(&format!("Imported {} profile(s) from '{path}'.", selected.len())),
|
||||
}
|
||||
warn_about_missing_identities(&selected);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Identity files are never included in the export, only the path; warn so
|
||||
/// that doesn't come as a surprise on the importing end.
|
||||
fn warn_about_identities(profiles: &[Profile]) {
|
||||
let names: Vec<&str> = profiles.iter().filter(|p| p.identity.is_some()).map(|p| p.name.as_str()).collect();
|
||||
if !names.is_empty() {
|
||||
ui::warn(&format!(
|
||||
"identity files are not included in the export ({}) - copy them to the importing machine yourself",
|
||||
names.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// After import, flag any profile whose identity path doesn't resolve on
|
||||
/// this machine, the most likely sign of a not-yet-copied key file.
|
||||
fn warn_about_missing_identities(profiles: &[Profile]) {
|
||||
for p in profiles {
|
||||
if let Some(identity) = &p.identity {
|
||||
if !expand_home(identity).is_file() {
|
||||
ui::warn(&format!(
|
||||
"'{}': identity file '{identity}' not found on this machine - fix it with \
|
||||
'porthole edit {} -i <path>' before opening",
|
||||
p.name, p.name
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Expands a leading `~/` the same way `ssh` itself does at spawn time
|
||||
/// (`ssh.rs`); without this, a valid `~/...` identity path would be
|
||||
/// misreported as missing since `Path::is_file` never expands `~` on its own.
|
||||
fn expand_home(path: &str) -> PathBuf {
|
||||
match path.strip_prefix("~/").zip(dirs::home_dir()) {
|
||||
Some((rest, home)) => home.join(rest),
|
||||
None => PathBuf::from(path),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn expands_leading_tilde() {
|
||||
let home = dirs::home_dir().unwrap();
|
||||
assert_eq!(expand_home("~/.ssh/id"), home.join(".ssh/id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_absolute_path_untouched() {
|
||||
assert_eq!(expand_home("/etc/ssh/id"), PathBuf::from("/etc/ssh/id"));
|
||||
}
|
||||
}
|
||||
@@ -42,9 +42,9 @@ pub fn run(args: WipeArgs) -> Result<()> {
|
||||
}
|
||||
|
||||
/// Kills any supervisor process not backed by a tracked profile (e.g. one
|
||||
/// orphaned after a crash), matched by cmdline rather than tracked state.
|
||||
/// Sends SIGTERM to each supervisor's process group so its `ssh` child is
|
||||
/// included.
|
||||
/// orphaned after a crash), matched by cmdline rather than tracked state
|
||||
/// (spec §5.8). Sends SIGTERM to each supervisor's process group so its
|
||||
/// `ssh` child is included.
|
||||
fn kill_orphaned_supervisors() -> u32 {
|
||||
let mut killed = 0;
|
||||
let Ok(entries) = std::fs::read_dir("/proc") else { return 0 };
|
||||
|
||||
@@ -29,15 +29,6 @@ pub enum PortholeError {
|
||||
#[error("--via is required: at least one hop (connection target)")]
|
||||
NoViaHosts,
|
||||
|
||||
#[error("exactly one of -i/--import, -e/--export is required")]
|
||||
TransferNoMode,
|
||||
|
||||
#[error("only one of -i/--import, -e/--export may be given")]
|
||||
TransferConflictingMode,
|
||||
|
||||
#[error("'{0}' not found in the transfer file")]
|
||||
TransferProfileNotFound(String),
|
||||
|
||||
#[error("nothing to do: {0}")]
|
||||
NothingToDo(String),
|
||||
|
||||
|
||||
151
src/instance.rs
151
src/instance.rs
@@ -1,14 +1,12 @@
|
||||
//! Runtime state for one open profile. Written only by the
|
||||
//! Runtime state for one open profile - spec §2.2/§2.3. Written only by the
|
||||
//! supervisor (`src/supervisor.rs`); everything else here just reads it.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
use std::fs::{ File, OpenOptions };
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::{ atomic, timefmt };
|
||||
|
||||
use serde::{ Serialize, Deserialize };
|
||||
use crate::{atomic, timefmt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::os::unix::io::AsRawFd;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -18,8 +16,7 @@ pub enum State {
|
||||
Error,
|
||||
}
|
||||
|
||||
impl State
|
||||
{
|
||||
impl State {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
State::Up => "up",
|
||||
@@ -29,14 +26,12 @@ impl State
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Instance {
|
||||
pub name: String,
|
||||
pub pid: i32,
|
||||
pub state: State,
|
||||
/// Anchor for "session uptime"; set once, when `open` starts.
|
||||
/// Anchor for "session uptime" (spec §5.5) - set once, when `open` starts.
|
||||
pub opened_at: i64,
|
||||
/// Start of the current unbroken connection; resets each reconnect.
|
||||
pub connected_at: Option<i64>,
|
||||
@@ -45,8 +40,7 @@ pub struct Instance {
|
||||
pub last_reconnect_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl Instance
|
||||
{
|
||||
impl Instance {
|
||||
pub fn new(name: String, pid: i32) -> Self {
|
||||
Self {
|
||||
name,
|
||||
@@ -61,60 +55,32 @@ impl Instance
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
|
||||
/// Advisory `flock` held for the supervisor's entire lifetime.
|
||||
/// The OS releases it the instant the holding process's file descriptors
|
||||
/// close, including on a crash or SIGKILL, so it needs no stale-lock
|
||||
/// cleanup and reliably answers "is a supervisor running for this profile."
|
||||
pub struct Lock {
|
||||
_file: File,
|
||||
}
|
||||
|
||||
impl Lock
|
||||
{
|
||||
/// Tries to take the lock non-blocking. `Ok(None)` means another live
|
||||
/// process already acquired it.
|
||||
pub fn try_acquire(name: &str) -> Result<Option<Self>>
|
||||
{
|
||||
let dir = state_dir();
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
|
||||
let file = OpenOptions::new().create(true).write(true).open(lock_path(name))?;
|
||||
let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
||||
|
||||
if ret == 0 { Ok(Some(Self { _file: file })) }
|
||||
else
|
||||
{
|
||||
let errno = std::io::Error::last_os_error();
|
||||
|
||||
if errno.raw_os_error() == Some(libc::EWOULDBLOCK) { Ok(None) }
|
||||
else { Err(errno.into()) }
|
||||
fn state_dir() -> PathBuf {
|
||||
if let Ok(dir) = std::env::var("PORTHOLE_STATE_DIR_OVERRIDE") {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
fn state_dir() -> PathBuf
|
||||
{
|
||||
if let Ok(dir) = std::env::var("PORTHOLE_STATE_DIR_OVERRIDE") { return PathBuf::from(dir); };
|
||||
|
||||
dirs::state_dir()
|
||||
.or_else(dirs::data_local_dir)
|
||||
.expect("could not resolve state dir")
|
||||
.join("porthole")
|
||||
}
|
||||
|
||||
pub fn instance_path(name: &str) -> PathBuf { state_dir().join(format!("{name}.json")) }
|
||||
pub fn lock_path(name: &str) -> PathBuf { state_dir().join(format!("{name}.lock")) }
|
||||
pub fn log_path(name: &str) -> PathBuf { state_dir().join(format!("{name}.log")) }
|
||||
pub fn instance_path(name: &str) -> PathBuf {
|
||||
state_dir().join(format!("{name}.json"))
|
||||
}
|
||||
|
||||
/// Loads the instance file for `name`, if any. `None` means closed; its
|
||||
/// absence *is* the closed state; there is no separate enum value for it.
|
||||
pub fn load(name: &str) -> Result<Option<Instance>>
|
||||
{
|
||||
pub fn lock_path(name: &str) -> PathBuf {
|
||||
state_dir().join(format!("{name}.lock"))
|
||||
}
|
||||
|
||||
pub fn log_path(name: &str) -> PathBuf {
|
||||
state_dir().join(format!("{name}.log"))
|
||||
}
|
||||
|
||||
/// Loads the instance file for `name`, if any. `None` means closed - the
|
||||
/// absence of this file is the closed state (spec §2.2); there is no
|
||||
/// separate enum value for it.
|
||||
pub fn load(name: &str) -> Result<Option<Instance>> {
|
||||
let path = instance_path(name);
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(text) => Ok(Some(serde_json::from_str(&text)?)),
|
||||
@@ -123,19 +89,15 @@ pub fn load(name: &str) -> Result<Option<Instance>>
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(instance: &Instance) -> Result<()>
|
||||
{
|
||||
pub fn save(instance: &Instance) -> Result<()> {
|
||||
let dir = state_dir();
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
|
||||
let text = serde_json::to_string_pretty(instance)?;
|
||||
atomic::write(&instance_path(&instance.name), text.as_bytes())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete(name: &str) -> Result<()>
|
||||
{
|
||||
pub fn delete(name: &str) -> Result<()> {
|
||||
match std::fs::remove_file(instance_path(name)) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
@@ -143,28 +105,59 @@ pub fn delete(name: &str) -> Result<()>
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process_alive(pid: i32) -> bool { unsafe { libc::kill(pid, 0) == 0 } }
|
||||
pub fn process_alive(pid: i32) -> bool {
|
||||
unsafe { libc::kill(pid, 0) == 0 }
|
||||
}
|
||||
|
||||
/// True only if `pid` is a live process whose cmdline identifies it as the
|
||||
/// supervisor for `name`, guards against a stale or reused pid.
|
||||
pub fn supervisor_alive(pid: i32, name: &str) -> bool
|
||||
{
|
||||
if !process_alive(pid) { return false };
|
||||
|
||||
let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else { return false };
|
||||
|
||||
/// supervisor for `name` - guards against a stale or reused pid.
|
||||
pub fn supervisor_alive(pid: i32, name: &str) -> bool {
|
||||
if !process_alive(pid) {
|
||||
return false;
|
||||
}
|
||||
let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else {
|
||||
return false;
|
||||
};
|
||||
let text = String::from_utf8_lossy(&cmdline);
|
||||
text.contains("__supervise") && text.contains(name)
|
||||
}
|
||||
|
||||
/// The live supervisor pid for `name`, if one is currently running
|
||||
/// The live supervisor pid for `name`, if one is actually running right now
|
||||
/// (checked against the process table, not just the instance file's
|
||||
/// last-known value, so a crash or reboot is detected as "not running"
|
||||
/// last-known value - so a crash or reboot is detected as "not running"
|
||||
/// rather than trusting stale on-disk state).
|
||||
pub fn running_pid(name: &str) -> Result<Option<i32>>
|
||||
{
|
||||
pub fn running_pid(name: &str) -> Result<Option<i32>> {
|
||||
match load(name)? {
|
||||
Some(inst) if supervisor_alive(inst.pid, name) => Ok(Some(inst.pid)),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Advisory `flock` held for the supervisor's entire lifetime (spec §2.3).
|
||||
/// The OS releases it the instant the holding process's file descriptors
|
||||
/// close, including on a crash or SIGKILL, so it needs no stale-lock
|
||||
/// cleanup and reliably answers "is a supervisor running for this profile."
|
||||
pub struct Lock {
|
||||
_file: File,
|
||||
}
|
||||
|
||||
impl Lock {
|
||||
/// Tries to take the lock non-blockingly. `Ok(None)` means another live
|
||||
/// process already holds it (i.e. this profile is already open).
|
||||
pub fn try_acquire(name: &str) -> Result<Option<Self>> {
|
||||
let dir = state_dir();
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let file = OpenOptions::new().create(true).write(true).open(lock_path(name))?;
|
||||
let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
||||
if ret == 0 {
|
||||
Ok(Some(Self { _file: file }))
|
||||
} else {
|
||||
let errno = std::io::Error::last_os_error();
|
||||
if errno.raw_os_error() == Some(libc::EWOULDBLOCK) {
|
||||
Ok(None)
|
||||
} else {
|
||||
Err(errno.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
22
src/main.rs
22
src/main.rs
@@ -12,8 +12,7 @@ mod ui;
|
||||
use clap::{CommandFactory, Parser};
|
||||
use cli::{Cli, Commands};
|
||||
|
||||
fn main()
|
||||
{
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
// Plain `porthole`, `-h`/`--help`, or `help` at the top level: show
|
||||
@@ -38,12 +37,14 @@ fn main()
|
||||
Commands::List(args) => commands::list::run(args),
|
||||
Commands::Remove(args) => commands::remove::run(args),
|
||||
Commands::Wipe(args) => commands::wipe::run(args),
|
||||
Commands::Transfer(args) => commands::transfer::run(args),
|
||||
Commands::Completions { shell } => {
|
||||
commands::completions::run(shell);
|
||||
Ok(())
|
||||
}
|
||||
// Internal
|
||||
// Internal: `open` re-execs into this. Never invoked by a user
|
||||
// directly (spec §3) - deliberately not wrapped in any of the
|
||||
// normal command ceremony (no "profile exists" re-check etc.),
|
||||
// since by the time we're here `open` has already done that.
|
||||
Commands::Supervise { name } => supervisor::run(&name),
|
||||
};
|
||||
|
||||
@@ -53,8 +54,8 @@ fn main()
|
||||
}
|
||||
}
|
||||
|
||||
/// True for a bare `porthole` invocation, or top-level `-h`/`--help`/`help`,
|
||||
/// i.e. anything that should show the expanded help rather than being
|
||||
/// True for a bare `porthole` invocation, or top-level `-h`/`--help`/`help`
|
||||
/// - i.e. anything that should show the expanded help rather than being
|
||||
/// handled (or rejected) by a specific subcommand.
|
||||
fn wants_top_level_help(args: &[String]) -> bool {
|
||||
match args.get(1..) {
|
||||
@@ -137,15 +138,10 @@ fn print_full_help() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `<name>` for each required positional arg of `cmd`, `[name]` for an
|
||||
/// optional one, space-joined and colored cyan.
|
||||
/// `<name>` for each positional arg of `cmd`, space-joined and colored cyan.
|
||||
fn positional_args(cmd: &clap::Command) -> String {
|
||||
cmd.get_positionals()
|
||||
.map(|a| {
|
||||
let id = a.get_id().as_str();
|
||||
let text = if a.is_required_set() { format!("<{id}>") } else { format!("[{id}]") };
|
||||
ui::cyan(&text)
|
||||
})
|
||||
.map(|a| ui::cyan(&format!("<{}>", a.get_id().as_str())))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
309
src/profile.rs
309
src/profile.rs
@@ -1,13 +1,11 @@
|
||||
//! Persisted forward definitions: One TOML file per profile at
|
||||
//! Persisted forward definitions - spec §2.1. One TOML file per profile at
|
||||
//! `~/.config/porthole/profiles/<name>.toml`.
|
||||
|
||||
use crate::error::{PortholeError, Result};
|
||||
use crate::{atomic, timefmt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::{ atomic, timefmt };
|
||||
use crate::error::{ Result, PortholeError };
|
||||
|
||||
use serde::{ Serialize, Deserialize };
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Kind {
|
||||
@@ -16,11 +14,9 @@ pub enum Kind {
|
||||
Dynamic,
|
||||
}
|
||||
|
||||
impl Kind
|
||||
{
|
||||
impl Kind {
|
||||
/// The `ssh` forward flag this kind maps to (`-L`/`-R`/`-D`).
|
||||
pub fn ssh_flag(self) -> &'static str
|
||||
{
|
||||
pub fn ssh_flag(self) -> &'static str {
|
||||
match self {
|
||||
Kind::Local => "-L",
|
||||
Kind::Remote => "-R",
|
||||
@@ -28,8 +24,7 @@ impl Kind
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str
|
||||
{
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Kind::Local => "local",
|
||||
Kind::Remote => "remote",
|
||||
@@ -42,9 +37,9 @@ impl Kind
|
||||
pub struct Profile {
|
||||
pub name: String,
|
||||
pub kind: Kind,
|
||||
/// Raw `-L/-R/-D` payload, without the flag itself.
|
||||
/// Raw `-L/-R/-D` payload, without the flag itself - see spec §5.1.
|
||||
pub mapping: String,
|
||||
/// Ordered hop list, each `[user@]host[:port]`.
|
||||
/// Ordered hop list, each `[user@]host[:port]` - see spec §3.1.
|
||||
#[serde(default)]
|
||||
pub via: Vec<String>,
|
||||
pub user: Option<String>,
|
||||
@@ -63,88 +58,21 @@ pub struct Profile {
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
impl Profile
|
||||
{
|
||||
/// Builds a brand-new profile from `add`'s flags.
|
||||
//noinspection RsFieldInitShorthand
|
||||
pub fn new(name: String, edits: &ProfileEdits) -> Result<Self>
|
||||
{
|
||||
let Some((kind, mapping)) = edits.mapping_kind()? else {
|
||||
return Err(PortholeError::NoMappingKind);
|
||||
};
|
||||
|
||||
validate_mapping(kind, mapping)?;
|
||||
let via = edits.via.clone().unwrap_or_default();
|
||||
if via.is_empty() { return Err(PortholeError::NoViaHosts); }
|
||||
|
||||
for hop in &via { validate_via_hop(hop)?; }
|
||||
|
||||
let now = timefmt::now();
|
||||
|
||||
Ok(Self {
|
||||
name: name,
|
||||
kind: kind,
|
||||
mapping: mapping.to_string(),
|
||||
via: via,
|
||||
user: edits.user.clone(),
|
||||
identity: edits.identity.clone(),
|
||||
ssh_port: edits.port.unwrap_or_else(default_ssh_port),
|
||||
reconnect: edits.reconnect.unwrap_or_else(default_true),
|
||||
retry_interval: edits.retry_interval.unwrap_or_else(default_retry_interval),
|
||||
backoff_max: edits.backoff_max.unwrap_or_else(default_backoff_max),
|
||||
keepalive: edits.keepalive.unwrap_or_else(default_keepalive),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
/// Applies `edits` on top of an existing profile (`edit`'s semantics:
|
||||
/// only provided fields change).
|
||||
pub fn apply_edits(&mut self, edits: &ProfileEdits) -> Result<()>
|
||||
{
|
||||
if let Some((kind, mapping)) = edits.mapping_kind()?
|
||||
{
|
||||
validate_mapping(kind, mapping)?;
|
||||
|
||||
self.kind = kind;
|
||||
self.mapping = mapping.to_string();
|
||||
}
|
||||
|
||||
if let Some(via) = &edits.via
|
||||
{
|
||||
if via.is_empty() { return Err(PortholeError::NoViaHosts); }
|
||||
|
||||
for hop in via { validate_via_hop(hop)?; }
|
||||
self.via = via.clone();
|
||||
}
|
||||
|
||||
if let Some(user) = &edits.user { self.user = Some(user.clone()); }
|
||||
if let Some(identity) = &edits.identity { self.identity = Some(identity.clone()); }
|
||||
if let Some(port) = edits.port { self.ssh_port = port; }
|
||||
if let Some(reconnect) = edits.reconnect { self.reconnect = reconnect; }
|
||||
if let Some(v) = edits.retry_interval { self.retry_interval = v; }
|
||||
if let Some(v) = edits.backoff_max { self.backoff_max = v; }
|
||||
if let Some(v) = edits.keepalive { self.keepalive = v; }
|
||||
self.updated_at = timefmt::now();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Splits `via` into the `-J` jump-chain value (comma-joined, all but
|
||||
/// the last hop; `None` for a single-hop `via`) and the final `ssh`
|
||||
/// connection target. Every path that constructs a `Profile` validates
|
||||
/// `via` as non-empty.
|
||||
pub fn ssh_target(&self) -> (Option<String>, &str)
|
||||
{
|
||||
match self.via.split_last() {
|
||||
Some((target, jumps)) if !jumps.is_empty() => (Some(jumps.join(",")), target.as_str()),
|
||||
Some((target, _)) => (None, target.as_str()),
|
||||
None => (None, ""),
|
||||
}
|
||||
}
|
||||
fn default_ssh_port() -> u16 {
|
||||
22
|
||||
}
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_retry_interval() -> u32 {
|
||||
5
|
||||
}
|
||||
fn default_backoff_max() -> u32 {
|
||||
60
|
||||
}
|
||||
fn default_keepalive() -> u32 {
|
||||
15
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
/// Flags shared by `add`/`edit` for building/patching a [`Profile`].
|
||||
#[derive(Debug, Default)]
|
||||
@@ -162,15 +90,16 @@ pub struct ProfileEdits {
|
||||
pub keepalive: Option<u32>,
|
||||
}
|
||||
|
||||
impl ProfileEdits
|
||||
{
|
||||
fn mapping_kind(&self) -> Result<Option<(Kind, &str)>>
|
||||
{
|
||||
impl ProfileEdits {
|
||||
fn mapping_kind(&self) -> Result<Option<(Kind, &str)>> {
|
||||
let given: Vec<(Kind, &str)> = [
|
||||
self.local.as_deref().map(|m| (Kind::Local, m)),
|
||||
self.remote.as_deref().map(|m| (Kind::Remote, m)),
|
||||
self.dynamic.as_deref().map(|m| (Kind::Dynamic, m)),
|
||||
].into_iter().flatten().collect();
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
|
||||
match given.len() {
|
||||
0 => Ok(None),
|
||||
@@ -180,47 +109,37 @@ impl ProfileEdits
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
fn default_ssh_port() -> u16 { 22 }
|
||||
fn default_true() -> bool { true }
|
||||
fn default_retry_interval() -> u32 { 5 }
|
||||
fn default_backoff_max() -> u32 { 60 }
|
||||
fn default_keepalive() -> u32 { 15 }
|
||||
|
||||
fn valid_name(name: &str) -> bool
|
||||
{
|
||||
fn valid_name(name: &str) -> bool {
|
||||
let mut chars = name.chars();
|
||||
let Some(first) = chars.next() else { return false };
|
||||
|
||||
if !first.is_ascii_alphanumeric() { return false; }
|
||||
if !first.is_ascii_alphanumeric() {
|
||||
return false;
|
||||
}
|
||||
name.len() <= 64 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
}
|
||||
|
||||
pub fn require_valid_name(name: &str) -> Result<()>
|
||||
{
|
||||
if valid_name(name) { Ok(()) }
|
||||
else { Err(PortholeError::InvalidName(name.to_string())) }
|
||||
pub fn require_valid_name(name: &str) -> Result<()> {
|
||||
if valid_name(name) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(PortholeError::InvalidName(name.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates a `-l/-r` payload (`[bind:]port:host:hostport`) or `-d` payload
|
||||
/// (`[bind:]port`) against the same shape `ssh` itself expects.
|
||||
fn validate_mapping(kind: Kind, mapping: &str) -> Result<()>
|
||||
{
|
||||
fn validate_mapping(kind: Kind, mapping: &str) -> Result<()> {
|
||||
let bad = || PortholeError::InvalidMapping(mapping.to_string());
|
||||
let parts: Vec<&str> = mapping.split(':').collect();
|
||||
let valid_port = |s: &str| s.parse::<u16>().is_ok() && !s.is_empty();
|
||||
|
||||
match kind
|
||||
{
|
||||
Kind::Dynamic => match parts.as_slice()
|
||||
{
|
||||
match kind {
|
||||
Kind::Dynamic => match parts.as_slice() {
|
||||
[port] if valid_port(port) => Ok(()),
|
||||
[_bind, port] if valid_port(port) => Ok(()),
|
||||
_ => Err(bad()),
|
||||
},
|
||||
Kind::Local | Kind::Remote => match parts.as_slice()
|
||||
{
|
||||
Kind::Local | Kind::Remote => match parts.as_slice() {
|
||||
[port, host, hostport] if valid_port(port) && !host.is_empty() && valid_port(hostport) => Ok(()),
|
||||
[_bind, port, host, hostport] if valid_port(port) && !host.is_empty() && valid_port(hostport) => Ok(()),
|
||||
_ => Err(bad()),
|
||||
@@ -229,71 +148,153 @@ fn validate_mapping(kind: Kind, mapping: &str) -> Result<()>
|
||||
}
|
||||
|
||||
/// Validates a `--via` hop (`[user@]host[:port]`).
|
||||
fn validate_via_hop(hop: &str) -> Result<()>
|
||||
{
|
||||
fn validate_via_hop(hop: &str) -> Result<()> {
|
||||
let bad = || PortholeError::InvalidVia(hop.to_string());
|
||||
let host_port = hop.rsplit_once('@').map(|(_, rest)| rest).unwrap_or(hop);
|
||||
if host_port.is_empty() { return Err(bad()); }
|
||||
|
||||
if let Some((host, port)) = host_port.rsplit_once(':')
|
||||
{
|
||||
if host_port.is_empty() {
|
||||
return Err(bad());
|
||||
}
|
||||
if let Some((host, port)) = host_port.rsplit_once(':') {
|
||||
if host.is_empty() || port.parse::<u16>().is_err() {
|
||||
return Err(bad());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn normalize(name: &str) -> String { name.to_lowercase() }
|
||||
impl Profile {
|
||||
/// Builds a brand-new profile from `add`'s flags.
|
||||
pub fn new(name: String, edits: &ProfileEdits) -> Result<Self> {
|
||||
let Some((kind, mapping)) = edits.mapping_kind()? else {
|
||||
return Err(PortholeError::NoMappingKind);
|
||||
};
|
||||
validate_mapping(kind, mapping)?;
|
||||
let via = edits.via.clone().unwrap_or_default();
|
||||
if via.is_empty() {
|
||||
return Err(PortholeError::NoViaHosts);
|
||||
}
|
||||
for hop in &via {
|
||||
validate_via_hop(hop)?;
|
||||
}
|
||||
let now = timefmt::now();
|
||||
Ok(Self {
|
||||
name,
|
||||
kind,
|
||||
mapping: mapping.to_string(),
|
||||
via,
|
||||
user: edits.user.clone(),
|
||||
identity: edits.identity.clone(),
|
||||
ssh_port: edits.port.unwrap_or_else(default_ssh_port),
|
||||
reconnect: edits.reconnect.unwrap_or_else(default_true),
|
||||
retry_interval: edits.retry_interval.unwrap_or_else(default_retry_interval),
|
||||
backoff_max: edits.backoff_max.unwrap_or_else(default_backoff_max),
|
||||
keepalive: edits.keepalive.unwrap_or_else(default_keepalive),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
fn profiles_dir() -> PathBuf
|
||||
{
|
||||
/// Applies `edits` on top of an existing profile (`edit`'s semantics:
|
||||
/// only provided fields change).
|
||||
pub fn apply_edits(&mut self, edits: &ProfileEdits) -> Result<()> {
|
||||
if let Some((kind, mapping)) = edits.mapping_kind()? {
|
||||
validate_mapping(kind, mapping)?;
|
||||
self.kind = kind;
|
||||
self.mapping = mapping.to_string();
|
||||
}
|
||||
if let Some(via) = &edits.via {
|
||||
if via.is_empty() {
|
||||
return Err(PortholeError::NoViaHosts);
|
||||
}
|
||||
for hop in via {
|
||||
validate_via_hop(hop)?;
|
||||
}
|
||||
self.via = via.clone();
|
||||
}
|
||||
if let Some(user) = &edits.user {
|
||||
self.user = Some(user.clone());
|
||||
}
|
||||
if let Some(identity) = &edits.identity {
|
||||
self.identity = Some(identity.clone());
|
||||
}
|
||||
if let Some(port) = edits.port {
|
||||
self.ssh_port = port;
|
||||
}
|
||||
if let Some(reconnect) = edits.reconnect {
|
||||
self.reconnect = reconnect;
|
||||
}
|
||||
if let Some(v) = edits.retry_interval {
|
||||
self.retry_interval = v;
|
||||
}
|
||||
if let Some(v) = edits.backoff_max {
|
||||
self.backoff_max = v;
|
||||
}
|
||||
if let Some(v) = edits.keepalive {
|
||||
self.keepalive = v;
|
||||
}
|
||||
self.updated_at = timefmt::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Splits `via` into the `-J` jump-chain value (comma-joined, all but
|
||||
/// the last hop; `None` for a single-hop `via`) and the final `ssh`
|
||||
/// connection target - see spec §3.1. Every path that constructs a
|
||||
/// `Profile` validates `via` as non-empty.
|
||||
pub fn ssh_target(&self) -> (Option<String>, &str) {
|
||||
match self.via.split_last() {
|
||||
Some((target, jumps)) if !jumps.is_empty() => (Some(jumps.join(",")), target.as_str()),
|
||||
Some((target, _)) => (None, target.as_str()),
|
||||
None => (None, ""),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize(name: &str) -> String {
|
||||
name.to_lowercase()
|
||||
}
|
||||
|
||||
fn profiles_dir() -> PathBuf {
|
||||
if let Ok(dir) = std::env::var("PORTHOLE_STATE_DIR_OVERRIDE") {
|
||||
return PathBuf::from(dir).join("profiles");
|
||||
}
|
||||
|
||||
dirs::config_dir().expect("could not resolve config dir").join("porthole").join("profiles")
|
||||
}
|
||||
|
||||
fn profile_path(name: &str) -> PathBuf { profiles_dir().join(format!("{name}.toml")) }
|
||||
fn profile_path(name: &str) -> PathBuf {
|
||||
profiles_dir().join(format!("{name}.toml"))
|
||||
}
|
||||
|
||||
pub fn exists(name: &str) -> bool { profile_path(name).is_file() }
|
||||
pub fn exists(name: &str) -> bool {
|
||||
profile_path(name).is_file()
|
||||
}
|
||||
|
||||
pub fn load(name: &str) -> Result<Profile>
|
||||
{
|
||||
pub fn load(name: &str) -> Result<Profile> {
|
||||
require_valid_name(name)?;
|
||||
|
||||
let path = profile_path(name);
|
||||
let text = std::fs::read_to_string(&path).map_err(|_| PortholeError::NotFound(name.to_string()))?;
|
||||
|
||||
Ok(toml::from_str(&text)?)
|
||||
}
|
||||
|
||||
pub fn save(profile: &Profile) -> Result<()>
|
||||
{
|
||||
pub fn save(profile: &Profile) -> Result<()> {
|
||||
let dir = profiles_dir();
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
|
||||
let text = toml::to_string_pretty(profile)?;
|
||||
atomic::write(&profile_path(&profile.name), text.as_bytes())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete(name: &str) -> Result<()>
|
||||
{
|
||||
pub fn delete(name: &str) -> Result<()> {
|
||||
let path = profile_path(name);
|
||||
std::fs::remove_file(&path).map_err(|_| PortholeError::NotFound(name.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lists every saved profile, sorted by name.
|
||||
pub fn list_all() -> Result<Vec<Profile>>
|
||||
{
|
||||
pub fn list_all() -> Result<Vec<Profile>> {
|
||||
let dir = profiles_dir();
|
||||
if !dir.is_dir() { return Ok(Vec::new()); }
|
||||
|
||||
if !dir.is_dir() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut names: Vec<String> = std::fs::read_dir(&dir)?
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
@@ -303,12 +304,12 @@ pub fn list_all() -> Result<Vec<Profile>>
|
||||
.flatten()
|
||||
})
|
||||
.collect();
|
||||
|
||||
names.sort();
|
||||
|
||||
let mut out = Vec::with_capacity(names.len());
|
||||
for name in names { out.push(load(&name)?); }
|
||||
|
||||
for name in names {
|
||||
out.push(load(&name)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
||||
179
src/ssh.rs
179
src/ssh.rs
@@ -1,107 +1,47 @@
|
||||
//! Builds the `ssh` invocation for a profile.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::fmt::Write as _;
|
||||
use std::process::{ Stdio, Command };
|
||||
//! Builds the `ssh` invocation for a profile - spec §3.1.
|
||||
|
||||
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()),
|
||||
]
|
||||
}
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// Builds the `ssh` command for `profile`, stdio wired for the supervisor
|
||||
/// to capture (stdout/stderr piped so failure text can be classified;
|
||||
/// stdin from `/dev/null` since porthole never wants a shell). The second
|
||||
/// 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>)
|
||||
{
|
||||
/// to capture (stdout/stderr piped so failure text can be classified per
|
||||
/// spec §4.2; stdin from `/dev/null` since porthole never wants a shell).
|
||||
pub fn build(profile: &Profile) -> Command {
|
||||
let mut cmd = Command::new("ssh");
|
||||
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
|
||||
// Forced flags, see spec §3.1.
|
||||
cmd.args([
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"ExitOnForwardFailure=yes",
|
||||
"-o",
|
||||
"ConnectTimeout=10",
|
||||
"-o",
|
||||
&format!("ServerAliveInterval={}", profile.keepalive),
|
||||
"-N",
|
||||
]);
|
||||
|
||||
let (jumps, target) = profile.ssh_target();
|
||||
|
||||
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]); }
|
||||
if let Some(jumps) = jumps {
|
||||
cmd.args(["-J", &jumps]);
|
||||
}
|
||||
|
||||
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.args(["-o", "IdentitiesOnly=yes"]);
|
||||
}
|
||||
if let Some(user) = &profile.user { cmd.arg("-l").arg(user); }
|
||||
if let Some(user) = &profile.user {
|
||||
cmd.arg("-l").arg(user);
|
||||
}
|
||||
|
||||
cmd.arg(profile.kind.ssh_flag()).arg(&profile.mapping);
|
||||
cmd.arg(target);
|
||||
|
||||
(cmd, jump_config)
|
||||
cmd
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -120,50 +60,17 @@ mod tests {
|
||||
.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]
|
||||
fn single_hop_has_no_dash_j() {
|
||||
let (cmd, _guard) = build(&profile_with(vec!["jumpbox"]));
|
||||
let cmd = build(&profile_with(vec!["jumpbox"]));
|
||||
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||
assert!(!args.contains(&"-J".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]
|
||||
fn multi_hop_splits_jumps_from_target() {
|
||||
let (cmd, _guard) = build(&profile_with(vec!["bastion1", "bastion2:2222"]));
|
||||
let cmd = build(&profile_with(vec!["bastion1", "bastion2:2222"]));
|
||||
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");
|
||||
assert_eq!(args[j_idx + 1], "bastion1");
|
||||
@@ -174,37 +81,9 @@ mod tests {
|
||||
fn includes_forward_flag_and_mapping() {
|
||||
let p = profile_with(vec!["jumpbox"]);
|
||||
assert_eq!(p.kind, Kind::Local);
|
||||
let (cmd, _guard) = build(&p);
|
||||
let cmd = build(&p);
|
||||
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");
|
||||
assert_eq!(args[l_idx + 1], "5432:db.internal:5432");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identities_only_set_when_identity_given() {
|
||||
let p = Profile::new(
|
||||
"t".into(),
|
||||
&ProfileEdits {
|
||||
local: Some("5432:db.internal:5432".into()),
|
||||
via: Some(vec!["jumpbox".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 i_idx = args.iter().position(|a| a == "-i").expect("-i present");
|
||||
assert_eq!(args[i_idx + 1], "/home/me/.ssh/id_ed25519");
|
||||
assert!(args.windows(2).any(|w| w == ["-o", "IdentitiesOnly=yes"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identities_only_absent_without_identity() {
|
||||
let p = profile_with(vec!["jumpbox"]);
|
||||
assert!(p.identity.is_none());
|
||||
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"]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
//! The `__supervise` loop runs as a detached, re-exec'd copy
|
||||
//! The `__supervise` loop - spec §3/§4. Runs as a detached, re-exec'd copy
|
||||
//! of this same binary (`porthole __supervise <name>`, see `main.rs`); owns
|
||||
//! the `ssh` child process for one profile's entire supervised lifetime.
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::sync::{ Arc, Mutex };
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::{ Instant, Duration };
|
||||
use std::io::{ Write, BufRead, BufReader };
|
||||
use std::sync::atomic::{ Ordering, AtomicBool };
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::{ ssh, timefmt };
|
||||
use crate::profile::{ self, Profile };
|
||||
use crate::instance::{ self, Lock, State, Instance };
|
||||
use crate::instance::{self, Instance, Lock, State};
|
||||
use crate::profile::{self, Profile};
|
||||
use crate::{ssh, timefmt};
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How long a connection must survive before its uptime resets the backoff
|
||||
/// counter back to the base delay.
|
||||
/// counter back to the base delay - spec §4.1.
|
||||
const STABLE_THRESHOLD_SECS: i64 = 60;
|
||||
/// Consecutive unrecognized (not pattern-matched) failures before porthole
|
||||
/// gives up on an apparently-permanently-broken profile.
|
||||
/// gives up on an apparently-permanently-broken profile - spec §4.2.
|
||||
const MAX_UNRECOGNIZED_STREAK: u32 = 10;
|
||||
/// How long `ssh` must stay alive before porthole treats it as connected;
|
||||
/// see `run_ssh_once` for the heuristic this backs.
|
||||
@@ -32,7 +31,7 @@ extern "C" fn handle_sigterm(_sig: libc::c_int) { SHUTDOWN.store(true, Ordering:
|
||||
|
||||
/// Traps SIGTERM and SIGINT into a flag instead of the default
|
||||
/// terminate-immediately behavior. This is how `close` (SIGTERM) and
|
||||
/// `-f/--foreground`'s Ctrl-C (SIGINT) are distinguished from a
|
||||
/// `-f/--foreground`'s Ctrl-C (SIGINT, spec §5.2) are distinguished from a
|
||||
/// dropped `ssh` connection: by which signal arrived, not by inferring
|
||||
/// intent from `ssh`'s exit status. In foreground mode this function runs
|
||||
/// in the process the terminal sends Ctrl-C to directly, since
|
||||
@@ -57,9 +56,8 @@ enum Outcome {
|
||||
|
||||
/// Entry point for `porthole __supervise <name>`. This function is the
|
||||
/// supervisor process: it runs until told to stop (SIGTERM/SIGINT) or
|
||||
/// gives up.
|
||||
pub fn run(name: &str) -> Result<()>
|
||||
{
|
||||
/// gives up per §4.
|
||||
pub fn run(name: &str) -> Result<()> {
|
||||
install_signal_handler();
|
||||
|
||||
let profile = profile::load(name)?;
|
||||
@@ -155,16 +153,13 @@ fn sleep_or_shutdown(dur: Duration) -> bool
|
||||
/// requested. Marks `inst` as `State::Up` once the process has survived
|
||||
/// `CONNECT_GRACE`. `ssh` does not report "the forward is bound" directly
|
||||
/// without parsing `-v` debug output; a real failure exits near-instantly
|
||||
/// under `ExitOnForwardFailure=yes`, so staying alive past the grace
|
||||
/// under `ExitOnForwardFailure=yes` (§3.1), so staying alive past the grace
|
||||
/// window is used as a proxy for connected.
|
||||
fn run_ssh_once(name: &str, profile: &Profile, inst: &mut Instance) -> Outcome
|
||||
{
|
||||
rotate_log_if_large(name);
|
||||
|
||||
// `_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 cmd = ssh::build(profile);
|
||||
let mut child = match cmd.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Outcome::Failed { class: Class::Unrecognized, message: format!("failed to spawn ssh: {e}") },
|
||||
@@ -219,7 +214,7 @@ fn join_all<const N: usize>(handles: [Option<JoinHandle<()>>; N]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifies `ssh`'s captured stderr. Fatal patterns stop
|
||||
/// Classifies `ssh`'s captured stderr per spec §4.2. Fatal patterns stop
|
||||
/// the reconnect loop outright; known-transient patterns retry without
|
||||
/// counting toward the unrecognized-failure escalation; anything else
|
||||
/// still retries, but does count toward it.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! profile/instance state; this module only turns them into text for
|
||||
//! `status`/`list` output.
|
||||
|
||||
use std::time::{ SystemTime, UNIX_EPOCH };
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub fn now() -> i64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)
|
||||
|
||||
@@ -1,504 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Live integration test suite for porthole.
|
||||
#
|
||||
# Exercises every command against a REAL SSH server: real tunnels, real
|
||||
# auth (success and failure), a real two-hop ProxyJump, a real SOCKS proxy
|
||||
# carrying real traffic. Deliberately NOT part of `cargo test` (same reason
|
||||
# as vmic's tests/live_test.sh: this needs a real remote server, not a CI
|
||||
# sandbox), live-only, opt-in, run by hand.
|
||||
#
|
||||
# Usage:
|
||||
# tests/live_test.sh [options]
|
||||
#
|
||||
# Options (env var or flag; flag wins if both given):
|
||||
# --bin PATH PORTHOLE_TEST_BIN porthole binary to test (default: target/debug/porthole)
|
||||
# --host HOST PORTHOLE_TEST_HOST test server hostname (default: vpn.security-command.org)
|
||||
# --user USER PORTHOLE_TEST_USER test server login user (default: overlord)
|
||||
# --identity PATH PORTHOLE_TEST_IDENTITY identity file (default: ~/.ssh/id_ed25519_vpn)
|
||||
# --name PREFIX PORTHOLE_TEST_NAME profile name prefix (default: porttestsuite)
|
||||
# --skip-network PORTHOLE_TEST_SKIP_NETWORK=1 skip the SOCKS/icanhazip.com phase
|
||||
# --skip-wipe PORTHOLE_TEST_SKIP_WIPE=1 skip the destructive `wipe` phase
|
||||
# --no-build skip the `cargo build` preflight
|
||||
# -h, --help
|
||||
#
|
||||
# Known limitation: only one real server is available, so multi-hop (`-J`)
|
||||
# is tested by chaining the server through itself (--via user@host,user@host),
|
||||
# a real two-hop ProxyJump handshake, just with both hops the same box.
|
||||
# There is no way to test a genuine distinct-host chain without a second
|
||||
# server.
|
||||
#
|
||||
# Safety:
|
||||
# - Profiles/instances/locks/logs are sandboxed for the whole run under
|
||||
# one `PORTHOLE_STATE_DIR_OVERRIDE` temp dir (profile.rs/instance.rs
|
||||
# both honor it); this suite NEVER touches the real
|
||||
# ~/.config/porthole or ~/.local/state/porthole.
|
||||
# - Every profile created is named "$NAME_..." (default prefix
|
||||
# porttestsuite); no operation targets anything outside that prefix.
|
||||
# - `wipe` (src/commands/wipe.rs) kills ANY process on the
|
||||
# whole system whose cmdline contains "__supervise", regardless of
|
||||
# which state dir it belongs to; it is NOT scoped by the sandboxing
|
||||
# above. Before running it, this script scans the real process table
|
||||
# and skips the wipe phase entirely (not "wipe only the safe parts")
|
||||
# if it finds a live __supervise process that isn't one of this run's
|
||||
# own test profiles, so a real tunnel you have open elsewhere is never
|
||||
# killed as a side effect of running this suite.
|
||||
# - Never runs a bare ssh with interactive prompting: every verification
|
||||
# ssh call this script itself makes uses BatchMode=yes plus a
|
||||
# throwaway UserKnownHostsFile=/dev/null, so it never prompts and
|
||||
# never writes to your real ~/.ssh/known_hosts (only porthole's own
|
||||
# spawned ssh does, against the real file, using the accept-new policy
|
||||
# already forced in src/ssh.rs).
|
||||
# - An EXIT trap always attempts full cleanup, closes/removes every
|
||||
# test-prefixed profile in every sandbox dir used, force-kills any
|
||||
# stray matching __supervise process, deletes the throwaway bad-auth
|
||||
# key, even on failure or Ctrl-C.
|
||||
|
||||
set -uo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||
|
||||
BIN="${PORTHOLE_TEST_BIN:-target/debug/porthole}"
|
||||
HOST="${PORTHOLE_TEST_HOST:-vpn.security-command.org}"
|
||||
USER_="${PORTHOLE_TEST_USER:-overlord}"
|
||||
IDENTITY="${PORTHOLE_TEST_IDENTITY:-$HOME/.ssh/id_ed25519_vpn}"
|
||||
NAME="${PORTHOLE_TEST_NAME:-porttestsuite}"
|
||||
SKIP_NETWORK="${PORTHOLE_TEST_SKIP_NETWORK:-0}"
|
||||
SKIP_WIPE="${PORTHOLE_TEST_SKIP_WIPE:-0}"
|
||||
DO_BUILD=1
|
||||
|
||||
usage() { sed -n '2,/^set -uo/p' "$0" | sed '$d; s/^# \{0,1\}//'; }
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--bin) BIN="$2"; shift 2 ;;
|
||||
--host) HOST="$2"; shift 2 ;;
|
||||
--user) USER_="$2"; shift 2 ;;
|
||||
--identity) IDENTITY="$2"; shift 2 ;;
|
||||
--name) NAME="$2"; shift 2 ;;
|
||||
--skip-network) SKIP_NETWORK=1; shift ;;
|
||||
--skip-wipe) SKIP_WIPE=1; shift ;;
|
||||
--no-build) DO_BUILD=0; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
NAME="$(tr '[:upper:]' '[:lower:]' <<<"$NAME")"
|
||||
|
||||
RED=$'\e[31m'; GREEN=$'\e[32m'; YELLOW=$'\e[33m'; BLUE=$'\e[34m'; RESET=$'\e[0m'
|
||||
[[ -t 1 ]] || { RED=""; GREEN=""; YELLOW=""; BLUE=""; RESET=""; }
|
||||
|
||||
PASS=0; FAIL=0; SKIP=0
|
||||
section() { echo; echo "${BLUE}== $1 ==${RESET}"; }
|
||||
pass() { PASS=$((PASS+1)); echo " ${GREEN}PASS${RESET} $1"; }
|
||||
fail() { FAIL=$((FAIL+1)); echo " ${RED}FAIL${RESET} $1"; [[ -n "${2:-}" ]] && echo " ${2//$'\n'/$'\n '}"; }
|
||||
skip() { SKIP=$((SKIP+1)); echo " ${YELLOW}SKIP${RESET} $1"; }
|
||||
|
||||
LAST_OUT=""; LAST_CODE=0
|
||||
porthole_run() { LAST_OUT="$("$BIN" "$@" 2>&1)"; LAST_CODE=$?; }
|
||||
|
||||
expect_exit() { # expect_exit <desc> <expected_code>
|
||||
if [[ "$LAST_CODE" == "$2" ]]; then pass "$1 (exit $LAST_CODE)"
|
||||
else fail "$1 (expected exit $2, got $LAST_CODE)" "$LAST_OUT"; fi
|
||||
}
|
||||
expect_contains() { # expect_contains <desc> <needle>
|
||||
if [[ "$LAST_OUT" == *"$2"* ]]; then pass "$1"
|
||||
else fail "$1 (expected output to contain: $2)" "$LAST_OUT"; fi
|
||||
}
|
||||
expect_not_contains() {
|
||||
if [[ "$LAST_OUT" != *"$2"* ]]; then pass "$1"
|
||||
else fail "$1 (expected output NOT to contain: $2)" "$LAST_OUT"; fi
|
||||
}
|
||||
assert_eq() { if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1" "expected '$3', got '$2'"; fi; }
|
||||
assert_true() { if "${@:2}" >/dev/null 2>&1; then pass "$1"; else fail "$1"; fi; }
|
||||
|
||||
# Every use below passes an explicit PORTHOLE_STATE_DIR_OVERRIDE, so this
|
||||
# never touches ~/.config/porthole or ~/.local/state/porthole.
|
||||
p() { PORTHOLE_STATE_DIR_OVERRIDE="$1" "$BIN" "${@:2}"; }
|
||||
p_run() { LAST_OUT="$(PORTHOLE_STATE_DIR_OVERRIDE="$1" "$BIN" "${@:2}" 2>&1)"; LAST_CODE=$?; }
|
||||
|
||||
# status --json's "state" field is always one of closed/up/reconnecting/error
|
||||
# (print_json normalizes a dead-supervisor instance file to "error" too, see
|
||||
# status.rs); polling that key is far more robust than scraping the padded
|
||||
# human-readable field.
|
||||
json_field() { # json_field <state-dir> <name> <field> -> value, empty if absent/no instance
|
||||
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]
|
||||
local dir="$1" name="$2" want="$3" tries="${4:-20}"
|
||||
for _ in $(seq 1 "$tries"); do
|
||||
[[ "$(json_field "$dir" "$name" state)" == "$want" ]] && return 0
|
||||
sleep 0.5
|
||||
done
|
||||
return 1
|
||||
}
|
||||
port_open() { timeout 1 bash -c "exec 3<>/dev/tcp/127.0.0.1/$1" 2>/dev/null; } # port_open <port>
|
||||
wait_port_closed() { # wait_port_closed <port> [tries, x0.5s]
|
||||
for _ in $(seq 1 "${2:-10}"); do
|
||||
port_open "$1" || return 0
|
||||
sleep 0.5
|
||||
done
|
||||
return 1
|
||||
}
|
||||
SSH_PROBE_OPTS=(-o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null)
|
||||
|
||||
# mktemp's output is captured via $(...), which forks a subshell; any
|
||||
# array append done *inside* a function called that way would be lost when
|
||||
# the subshell exits, so state dirs are appended here at the call site
|
||||
# instead of through a helper function.
|
||||
STATE_DIRS=()
|
||||
STATE_DIR="$(mktemp -d)"; STATE_DIRS+=("$STATE_DIR")
|
||||
BADKEY="$(mktemp -u)"
|
||||
|
||||
cleanup() {
|
||||
section "Cleanup"
|
||||
for d in "${STATE_DIRS[@]:-}"; do
|
||||
[[ -z "$d" ]] && continue
|
||||
for prof in $(p "$d" list --json 2>/dev/null | sed -n 's/.*"name": "\([^"]*\)".*/\1/p'); do
|
||||
p "$d" close --force "$prof" >/dev/null 2>&1 || true
|
||||
p "$d" remove "$prof" >/dev/null 2>&1 || true
|
||||
done
|
||||
rm -rf "$d"
|
||||
done
|
||||
pkill -f "__supervise ${NAME}_" 2>/dev/null || true
|
||||
rm -f "$BADKEY" "$BADKEY.pub" 2>/dev/null || true
|
||||
echo " done."
|
||||
echo
|
||||
echo "${BLUE}== Results ==${RESET} ${GREEN}$PASS passed${RESET}, ${RED}$FAIL failed${RESET}, ${YELLOW}$SKIP skipped${RESET}"
|
||||
[[ "$FAIL" -eq 0 ]]
|
||||
}
|
||||
trap 'cleanup; exit $(( $? ))' EXIT
|
||||
|
||||
echo "porthole: $BIN"
|
||||
echo "server: $USER_@$HOST (identity: $IDENTITY)"
|
||||
echo "test name: $NAME (+ suffixes) in $STATE_DIR"
|
||||
|
||||
if [[ "$DO_BUILD" == "1" ]]; then
|
||||
section "Build"
|
||||
if cargo build 2>&1 | tee /dev/stderr | grep -q '^error'; then
|
||||
echo "build failed, aborting." >&2; exit 1
|
||||
fi
|
||||
fi
|
||||
[[ -x "$BIN" ]] || { echo "binary not found/executable: $BIN" >&2; exit 1; }
|
||||
[[ -f "$IDENTITY" ]] || { echo "identity file not found: $IDENTITY" >&2; exit 1; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
section "Phase 1: CLI surface"
|
||||
# ---------------------------------------------------------------------------
|
||||
porthole_run; expect_exit "bare 'porthole' shows help" 2
|
||||
expect_contains "bare 'porthole' mentions Usage" "Usage:"
|
||||
porthole_run -h; expect_exit "'porthole -h'" 0
|
||||
porthole_run help; expect_exit "'porthole help'" 0
|
||||
porthole_run --version; expect_exit "'porthole --version'" 0
|
||||
expect_contains "'--version' mentions porthole" "porthole"
|
||||
for cmd in add open close edit status list remove wipe transfer; do
|
||||
porthole_run "$cmd" --help; expect_exit "'porthole $cmd --help'" 0
|
||||
done
|
||||
for shell in bash zsh fish; do
|
||||
porthole_run completions "$shell"
|
||||
assert_eq "'porthole completions $shell' exits 0" "$LAST_CODE" "0"
|
||||
[[ -n "$LAST_OUT" ]] && pass "'porthole completions $shell' produces output" || fail "'porthole completions $shell' produces output" "(empty)"
|
||||
done
|
||||
|
||||
porthole_run --help
|
||||
expect_contains "value-name shows real mapping grammar" "<[BIND:]PORT:HOST:PORT>"
|
||||
expect_contains "value-name shows PATH.toml for transfer" "<PATH.toml>"
|
||||
expect_contains "required positional renders as <name>" "add <name>"
|
||||
expect_contains "optional positional renders as [name]" "open [name]"
|
||||
expect_contains "optional positional renders as [name] (transfer)" "transfer [name]"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
section "Phase 2: error paths (pre-creation)"
|
||||
# ---------------------------------------------------------------------------
|
||||
p_run "$STATE_DIR" add; expect_exit "'add' with no name fails" 2
|
||||
p_run "$STATE_DIR" add "bad name!"; assert_eq "'add' with an invalid name exits 1" "$LAST_CODE" "1"
|
||||
expect_contains "invalid name error message" "invalid name"
|
||||
p_run "$STATE_DIR" add "${NAME}_x"; assert_eq "'add' with no mapping kind exits 1" "$LAST_CODE" "1"
|
||||
expect_contains "no-mapping-kind error message" "exactly one of -l/--local"
|
||||
p_run "$STATE_DIR" add "${NAME}_x" -l 1:h:1 -r 2:h:2; assert_eq "'add' with conflicting mapping kinds exits 1" "$LAST_CODE" "1"
|
||||
expect_contains "conflicting-mapping error message" "only one of -l/--local"
|
||||
p_run "$STATE_DIR" add "${NAME}_x" -l 1:h:1; assert_eq "'add' with no --via exits 1" "$LAST_CODE" "1"
|
||||
expect_contains "no-via error message" "--via is required"
|
||||
p_run "$STATE_DIR" status "${NAME}_nope"; assert_eq "'status' on nonexistent profile exits 1" "$LAST_CODE" "1"
|
||||
expect_contains "nonexistent-profile error (status)" "no profile named"
|
||||
p_run "$STATE_DIR" close "${NAME}_nope"; assert_eq "'close' on nonexistent profile exits 1" "$LAST_CODE" "1"
|
||||
p_run "$STATE_DIR" edit "${NAME}_nope" -l 1:h:1; assert_eq "'edit' on nonexistent profile exits 1" "$LAST_CODE" "1"
|
||||
p_run "$STATE_DIR" remove "${NAME}_nope"; assert_eq "'remove' on nonexistent profile exits 1" "$LAST_CODE" "1"
|
||||
p_run "$STATE_DIR" transfer; assert_eq "'transfer' with no mode exits 1" "$LAST_CODE" "1"
|
||||
expect_contains "transfer no-mode error message" "exactly one of -i/--import"
|
||||
p_run "$STATE_DIR" transfer -e /tmp/x.toml -i /tmp/x.toml; assert_eq "'transfer' with both modes exits 1" "$LAST_CODE" "1"
|
||||
expect_contains "transfer conflicting-mode error message" "only one of -i/--import"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
section "Phase 3: local forward (-l), single hop - the baseline path"
|
||||
# ---------------------------------------------------------------------------
|
||||
LOCAL_PORT=28221
|
||||
p_run "$STATE_DIR" add "${NAME}_local" -l "$LOCAL_PORT:localhost:22" -i "$IDENTITY" --via "$USER_@$HOST"
|
||||
expect_exit "'add ${NAME}_local'" 0
|
||||
p_run "$STATE_DIR" status "${NAME}_local"; expect_contains "fresh profile is closed" "closed"
|
||||
|
||||
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
|
||||
|
||||
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"
|
||||
else
|
||||
fail "forwarded port $LOCAL_PORT actually round-trips to the real sshd" "$(cat /tmp/porthole_test_probe.$$ 2>/dev/null)"
|
||||
fi
|
||||
rm -f "/tmp/porthole_test_probe.$$" 2>/dev/null
|
||||
|
||||
p_run "$STATE_DIR" close "${NAME}_local"; expect_exit "'close ${NAME}_local'" 0
|
||||
assert_true "port $LOCAL_PORT stops listening after close" wait_port_closed "$LOCAL_PORT"
|
||||
p_run "$STATE_DIR" close "${NAME}_local"; expect_exit "re-'close' on an already-closed profile still exits 0" 0
|
||||
expect_contains "idempotent-close message" "is not open"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
section "Phase 4: remote forward (-r) - binds on the server, dials back out locally"
|
||||
# ---------------------------------------------------------------------------
|
||||
REMOTE_PORT=28225
|
||||
p_run "$STATE_DIR" add "${NAME}_remote" -r "$REMOTE_PORT:$HOST:22" -i "$IDENTITY" --via "$USER_@$HOST"
|
||||
expect_exit "'add ${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
|
||||
|
||||
# The inner ssh below runs on the remote box itself (reached through the
|
||||
# outer session), then loops back out through the -R forward to a real
|
||||
# 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"
|
||||
else
|
||||
fail "remote-bound port $REMOTE_PORT round-trips back out through the tunnel" "$remote_check"
|
||||
fi
|
||||
|
||||
p_run "$STATE_DIR" close "${NAME}_remote"; expect_exit "'close ${NAME}_remote'" 0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
section "Phase 5: dynamic forward (-d, SOCKS) - proves traffic actually transits"
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ "$SKIP_NETWORK" == "1" ]]; then
|
||||
skip "SOCKS traffic-routing check (--skip-network passed)"
|
||||
elif ! command -v curl >/dev/null; then
|
||||
skip "SOCKS traffic-routing check (curl not installed)"
|
||||
else
|
||||
SOCKS_PORT=28226
|
||||
p_run "$STATE_DIR" add "${NAME}_dynamic" -d "$SOCKS_PORT" -i "$IDENTITY" --via "$USER_@$HOST"
|
||||
expect_exit "'add ${NAME}_dynamic'" 0
|
||||
p_run "$STATE_DIR" open "${NAME}_dynamic"; expect_exit "'open ${NAME}_dynamic'" 0
|
||||
assert_true "'${NAME}_dynamic' reaches state: up" wait_for_state "$STATE_DIR" "${NAME}_dynamic" up 10
|
||||
|
||||
direct_ip="$(curl -s --max-time 8 https://icanhazip.com | tr -d '[:space:]')"
|
||||
proxied_ip="$(curl -s --max-time 8 -x "socks5h://localhost:$SOCKS_PORT" https://icanhazip.com | tr -d '[:space:]')"
|
||||
if [[ -z "$direct_ip" || -z "$proxied_ip" ]]; then
|
||||
skip "SOCKS traffic-routing check (icanhazip.com unreachable right now)"
|
||||
elif [[ "$proxied_ip" != "$direct_ip" ]]; then
|
||||
pass "SOCKS proxy traffic exits via the remote server ($proxied_ip != local $direct_ip)"
|
||||
else
|
||||
fail "SOCKS proxy traffic exits via the remote server" "proxied IP ($proxied_ip) matched direct IP - traffic didn't actually route through the tunnel"
|
||||
fi
|
||||
p_run "$STATE_DIR" close "${NAME}_dynamic"; expect_exit "'close ${NAME}_dynamic'" 0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
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
|
||||
HOST_IP="$(getent ahostsv4 "$HOST" 2>/dev/null | awk '{print $1; exit}')"
|
||||
if [[ -z "$HOST_IP" ]]; then
|
||||
skip "multi-hop phase (could not resolve $HOST to an IP for the loop workaround)"
|
||||
else
|
||||
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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
section "Phase 7: -u/--user without an embedded via user - real -l flag auth"
|
||||
# ---------------------------------------------------------------------------
|
||||
ALTUSER_PORT=28224
|
||||
p_run "$STATE_DIR" add "${NAME}_altuser" -l "$ALTUSER_PORT:localhost:22" -i "$IDENTITY" --via "$HOST" -u "$USER_"
|
||||
expect_exit "'add ${NAME}_altuser' (--via with no embedded user, -u instead)" 0
|
||||
p_run "$STATE_DIR" open "${NAME}_altuser"; expect_exit "'open ${NAME}_altuser'" 0
|
||||
assert_true "'${NAME}_altuser' authenticates via -u/-l, reaches state: up" wait_for_state "$STATE_DIR" "${NAME}_altuser" up 10
|
||||
p_run "$STATE_DIR" close "${NAME}_altuser"; expect_exit "'close ${NAME}_altuser'" 0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
section "Phase 8: real auth failure - Fatal classification"
|
||||
# ---------------------------------------------------------------------------
|
||||
if ! command -v ssh-keygen >/dev/null; then
|
||||
skip "real Fatal-classification check (ssh-keygen not installed)"
|
||||
else
|
||||
ssh-keygen -q -t ed25519 -N '' -f "$BADKEY" >/dev/null
|
||||
p_run "$STATE_DIR" add "${NAME}_badauth" -l 28230:localhost:22 -i "$BADKEY" --via "$USER_@$HOST"
|
||||
expect_exit "'add ${NAME}_badauth' with a never-authorized key" 0
|
||||
p_run "$STATE_DIR" open --once "${NAME}_badauth"
|
||||
assert_eq "'open --once' with bad auth exits 1" "$LAST_CODE" "1"
|
||||
assert_eq "'${NAME}_badauth' lands in state: error" "$(json_field "$STATE_DIR" "${NAME}_badauth" state)" "error"
|
||||
last_err="$(json_field "$STATE_DIR" "${NAME}_badauth" last_error)"
|
||||
[[ "$last_err" == *"Permission denied"* ]] && pass "last_error reports a real Permission-denied rejection" \
|
||||
|| fail "last_error reports a real Permission-denied rejection" "got: $last_err"
|
||||
p_run "$STATE_DIR" remove "${NAME}_badauth"; expect_exit "'remove ${NAME}_badauth'" 0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
section "Phase 9: real connection-refused - KnownTransient classification"
|
||||
# ---------------------------------------------------------------------------
|
||||
p_run "$STATE_DIR" add "${NAME}_deadport" -l 28231:localhost:22 -i "$IDENTITY" --via "$USER_@$HOST" \
|
||||
-p 9 --retry-interval 2 --backoff-max 4
|
||||
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
|
||||
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)"
|
||||
# Port 9 may be silently dropped rather than actively refused, so a single
|
||||
# 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
|
||||
pass "reconnect_count keeps increasing on a real refused connection ($rc1 -> $rc2)"
|
||||
else
|
||||
fail "reconnect_count keeps increasing on a real refused connection" "rc1=$rc1 rc2=$rc2"
|
||||
fi
|
||||
p_run "$STATE_DIR" close --force "${NAME}_deadport"; expect_exit "'close --force ${NAME}_deadport'" 0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
section "Phase 10: open --all only starts reconnect-enabled profiles"
|
||||
# ---------------------------------------------------------------------------
|
||||
p_run "$STATE_DIR" add "${NAME}_all1" -l 28232:localhost:22 -i "$IDENTITY" --via "$USER_@$HOST"
|
||||
expect_exit "'add ${NAME}_all1' (reconnect: true, the default)" 0
|
||||
p_run "$STATE_DIR" add "${NAME}_all2" -l 28233:localhost:22 -i "$IDENTITY" --via "$USER_@$HOST" --reconnect false
|
||||
expect_exit "'add ${NAME}_all2' (reconnect: false)" 0
|
||||
p_run "$STATE_DIR" open --all; expect_exit "'open --all'" 0
|
||||
assert_true "'${NAME}_all1' was started by --all" wait_for_state "$STATE_DIR" "${NAME}_all1" up 10
|
||||
assert_eq "'${NAME}_all2' was NOT started by --all (reconnect: false)" "$(json_field "$STATE_DIR" "${NAME}_all2" state)" "closed"
|
||||
p_run "$STATE_DIR" close "${NAME}_all1"; expect_exit "'close ${NAME}_all1'" 0
|
||||
p_run "$STATE_DIR" remove "${NAME}_all2"; expect_exit "'remove ${NAME}_all2' (was never opened)" 0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
section "Phase 11: close --force skips the graceful wait"
|
||||
# ---------------------------------------------------------------------------
|
||||
FORCE_PORT=28234
|
||||
p_run "$STATE_DIR" add "${NAME}_force" -l "$FORCE_PORT:localhost:22" -i "$IDENTITY" --via "$USER_@$HOST"
|
||||
expect_exit "'add ${NAME}_force'" 0
|
||||
p_run "$STATE_DIR" open "${NAME}_force"; expect_exit "'open ${NAME}_force'" 0
|
||||
t0=$(date +%s)
|
||||
p_run "$STATE_DIR" close --force "${NAME}_force"; expect_exit "'close --force ${NAME}_force'" 0
|
||||
t1=$(date +%s)
|
||||
assert_true "'--force' returns fast, without the 5s graceful-wait" bash -c "[[ $((t1 - t0)) -lt 4 ]]"
|
||||
assert_true "port $FORCE_PORT stops listening after force-close" wait_port_closed "$FORCE_PORT" 6
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
section "Phase 12: edit while running warns instead of restarting"
|
||||
# ---------------------------------------------------------------------------
|
||||
p_run "$STATE_DIR" add "${NAME}_edit" -l 28235:localhost:22 -i "$IDENTITY" --via "$USER_@$HOST"
|
||||
expect_exit "'add ${NAME}_edit'" 0
|
||||
p_run "$STATE_DIR" open "${NAME}_edit"; expect_exit "'open ${NAME}_edit'" 0
|
||||
p_run "$STATE_DIR" edit "${NAME}_edit" --keepalive 20
|
||||
expect_exit "'edit ${NAME}_edit --keepalive 20' while open" 0
|
||||
expect_contains "warns the change won't apply until reopened" "won't take effect until"
|
||||
p_run "$STATE_DIR" close "${NAME}_edit"; expect_exit "'close ${NAME}_edit'" 0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
section "Phase 13: remove --keep-running leaves a genuine orphan"
|
||||
# ---------------------------------------------------------------------------
|
||||
KEEP_PORT=28236
|
||||
p_run "$STATE_DIR" add "${NAME}_keep" -l "$KEEP_PORT:localhost:22" -i "$IDENTITY" --via "$USER_@$HOST"
|
||||
expect_exit "'add ${NAME}_keep'" 0
|
||||
p_run "$STATE_DIR" open "${NAME}_keep"; expect_exit "'open ${NAME}_keep'" 0
|
||||
p_run "$STATE_DIR" remove "${NAME}_keep" --keep-running
|
||||
expect_exit "'remove ${NAME}_keep --keep-running'" 0
|
||||
expect_contains "warns it's left running untracked" "left running untracked"
|
||||
p_run "$STATE_DIR" status "${NAME}_keep"; assert_eq "profile is gone from tracking" "$LAST_CODE" "1"
|
||||
assert_true "the untracked process is still actually alive" pgrep -f "__supervise ${NAME}_keep\$"
|
||||
assert_true "port $KEEP_PORT is still live, untracked" port_open "$KEEP_PORT"
|
||||
# left running on purpose; phase 15's wipe is what's being tested against it
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
section "Phase 14: transfer round trip against a live profile"
|
||||
# ---------------------------------------------------------------------------
|
||||
XFER_PORT=28237
|
||||
XFER_FILE="$(mktemp -u)"
|
||||
STATE_DIR2="$(mktemp -d)"; STATE_DIRS+=("$STATE_DIR2")
|
||||
p_run "$STATE_DIR" add "${NAME}_xfer" -l "$XFER_PORT:localhost:22" -i "$IDENTITY" --via "$USER_@$HOST"
|
||||
expect_exit "'add ${NAME}_xfer'" 0
|
||||
p_run "$STATE_DIR" transfer -e "$XFER_FILE" "${NAME}_xfer"
|
||||
expect_exit "'transfer -e ... ${NAME}_xfer'" 0
|
||||
expect_contains "export warns the identity file isn't included" "identity files are not included"
|
||||
|
||||
p_run "$STATE_DIR2" transfer -i "$XFER_FILE"
|
||||
expect_exit "'transfer -i ...' into a fresh state dir" 0
|
||||
p_run "$STATE_DIR2" open "${NAME}_xfer"; expect_exit "'open' the imported profile" 0
|
||||
assert_true "the imported profile actually connects, not just parses" wait_for_state "$STATE_DIR2" "${NAME}_xfer" up 10
|
||||
p_run "$STATE_DIR2" close "${NAME}_xfer"; expect_exit "'close' the imported profile" 0
|
||||
rm -f "$XFER_FILE" 2>/dev/null
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
section "Phase 15: wipe (guarded - kills every __supervise process system-wide)"
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ "$SKIP_WIPE" == "1" ]]; then
|
||||
skip "wipe phase (--skip-wipe passed)"
|
||||
else
|
||||
foreign=""
|
||||
while read -r pid; do
|
||||
[[ -z "$pid" ]] && continue
|
||||
cmd="$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null)"
|
||||
[[ "$cmd" == *"__supervise ${NAME}_"* ]] || foreign="$foreign $pid"
|
||||
done < <(pgrep -f '__supervise' 2>/dev/null)
|
||||
|
||||
if [[ -n "$foreign" ]]; then
|
||||
skip "wipe phase (found __supervise process(es) not from this run: pid$foreign - not safe to run a system-wide wipe)"
|
||||
else
|
||||
p_run "$STATE_DIR" add "${NAME}_wa" -l 28238:localhost:22 -i "$IDENTITY" --via "$USER_@$HOST"
|
||||
expect_exit "create throwaway closed profile for wipe test" 0
|
||||
p_run "$STATE_DIR" add "${NAME}_wb" -l 28239:localhost:22 -i "$IDENTITY" --via "$USER_@$HOST"
|
||||
expect_exit "create throwaway open profile for wipe test" 0
|
||||
p_run "$STATE_DIR" open "${NAME}_wb"; expect_exit "open it" 0
|
||||
|
||||
p_run "$STATE_DIR" wipe --yes; expect_exit "'wipe --yes'" 0
|
||||
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."
|
||||
# kill_orphaned_supervisors only sends SIGTERM and returns; the orphan's
|
||||
# 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
|
||||
fi
|
||||
fi
|
||||
Reference in New Issue
Block a user