Implement porthole v0.1: profiles, supervised open/close, reconnect

Full CLI per spec v0.2 - add/open/close/edit/status/list/remove/wipe/
completions, plus a hidden `__supervise` subcommand that IS the
supervisor process.

- profile.rs: TOML-backed profiles at ~/.config/porthole/profiles/,
  validated -l/-r/-d mapping + --via grammar, atomic writes.
- instance.rs: JSON runtime state at ~/.local/state/porthole/, an
  flock-based lock file that's the source of truth for "is this open"
  (survives a crash/kill -9 without stale-lock cleanup), pid liveness
  checked against /proc rather than trusted from disk.
- supervisor.rs: the __supervise loop - spawns ssh, traps SIGTERM/SIGINT
  into a flag (rather than inferring intent from ssh's exit status),
  classifies failures as fatal/known-transient/unrecognized, backs off
  with a stability-reset, rotates its log.
- ssh.rs: builds the ssh invocation, including splitting --via into a
  -J jump chain plus the mandatory positional target.
- open.rs: the detach/re-exec dance (setsid via pre_exec) and a bounded
  wait for the supervisor to reach Up/Error before open returns, so an
  immediate failure surfaces as a non-zero exit instead of a false
  "opened" - this took a real bug fix during smoke testing, since the
  instance file's initial state (Reconnecting, meaning "attempt in
  flight") was indistinguishable from "already failed once" by state
  alone.
- close.rs: SIGTERM+wait, or SIGKILL the whole process group with
  --force so ssh can't be left orphaned.

Smoke-tested against invalid/unreachable hosts (no real infrastructure
touched): CLI surface, validation errors, add/edit/list/status/remove,
the reconnect/backoff loop with live state transitions, close mid-retry,
edit-while-running's warning, open --all, and wipe. cargo test: 14/14.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 15:43:49 +02:00
parent bc8f1cc5d2
commit 0c9f0585fa
22 changed files with 2417 additions and 0 deletions

185
src/cli.rs Normal file
View File

@@ -0,0 +1,185 @@
use clap::{Args, Parser, Subcommand};
use clap_complete::Shell;
/// porthole - create and manage named SSH port forwards.
#[derive(Parser)]
#[command(name = "porthole", version, about)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
pub enum Commands {
/// Save a new forward profile (doesn't open it).
#[command(visible_alias = "create", visible_alias = "new")]
Add(AddArgs),
/// Start a saved forward as a supervised background process.
#[command(visible_alias = "start")]
Open(OpenArgs),
/// Stop a running forward.
#[command(visible_alias = "stop")]
Close(CloseArgs),
/// Update a saved profile.
Edit(EditArgs),
/// Deep-dive health for one forward.
Status(StatusArgs),
/// List all saved profiles with live status.
#[command(visible_alias = "ls")]
List(ListArgs),
/// Delete a saved profile.
#[command(visible_alias = "rm", visible_alias = "delete")]
Remove(RemoveArgs),
/// Close and delete every forward, tracked or not.
#[command(visible_alias = "reset")]
Wipe(WipeArgs),
/// Generate a shell completion script.
Completions { shell: Shell },
/// Internal: runs the supervisor loop for one profile. Not for direct
/// 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
/// struct (`#[command(flatten)]`ed into both) so the two can never drift.
#[derive(Args, Default)]
pub struct MappingArgs {
/// Local forward: your machine -> remote. [bind:]port:host:hostport
#[arg(short, long, value_name = "SPEC")]
pub local: Option<String>,
/// Remote forward: remote -> your machine. [bind:]port:host:hostport
#[arg(short, long, value_name = "SPEC")]
pub remote: Option<String>,
/// Dynamic forward (SOCKS proxy). [bind:]port
#[arg(short, long, value_name = "SPEC")]
pub dynamic: Option<String>,
/// SSH hop chain, comma-separated; the last hop is the actual
/// connection target, any before it are -J jumps.
#[arg(long, value_name = "[user@]host[:port][,...]")]
pub via: Option<String>,
/// Default user for the target and any --via hop without its own.
#[arg(short, long, value_name = "USER")]
pub user: Option<String>,
/// Identity file override.
#[arg(short, long, value_name = "PATH")]
pub identity: Option<String>,
/// SSH port on the final target only.
#[arg(short, long, value_name = "PORT")]
pub port: Option<u16>,
/// Auto-reconnect on drop.
#[arg(long, num_args = 0..=1, default_missing_value = "true", value_name = "BOOL")]
pub reconnect: Option<bool>,
/// Base delay between reconnect attempts, in seconds.
#[arg(long = "retry-interval", value_name = "SECONDS")]
pub retry_interval: Option<u32>,
/// Cap on the doubling reconnect delay, in seconds.
#[arg(long = "backoff-max", value_name = "SECONDS")]
pub backoff_max: Option<u32>,
/// ServerAliveInterval, in seconds.
#[arg(long, value_name = "SECONDS")]
pub keepalive: Option<u32>,
}
#[derive(Args)]
pub struct AddArgs {
/// Name for the new profile.
pub name: String,
#[command(flatten)]
pub mapping: MappingArgs,
}
#[derive(Args)]
pub struct EditArgs {
/// Name of the profile to edit.
pub name: String,
#[command(flatten)]
pub mapping: MappingArgs,
}
#[derive(Args)]
pub struct OpenArgs {
/// Name of the profile to open. Ignored (and optional) with --all.
pub name: Option<String>,
/// Run attached in the current shell instead of detaching.
#[arg(short, long)]
pub foreground: bool,
/// Open without auto-reconnect, regardless of the profile setting.
#[arg(long)]
pub once: bool,
/// Open every profile with reconnect enabled that isn't already open.
#[arg(long)]
pub all: bool,
}
#[derive(Args)]
pub struct CloseArgs {
/// Name of the profile to close.
pub name: String,
/// SIGKILL immediately instead of graceful SIGTERM + wait.
#[arg(long)]
pub force: bool,
}
#[derive(Args)]
pub struct StatusArgs {
/// Name of the profile to inspect.
pub name: String,
/// Machine-readable output.
#[arg(long)]
pub json: bool,
}
#[derive(Args)]
pub struct ListArgs {
/// Show only currently-open forwards.
#[arg(long)]
pub running: bool,
/// Machine-readable output.
#[arg(long)]
pub json: bool,
}
#[derive(Args)]
pub struct RemoveArgs {
/// Name of the profile to delete.
pub name: String,
/// Delete the profile but leave an active instance running untracked.
#[arg(long = "keep-running")]
pub keep_running: bool,
}
#[derive(Args)]
pub struct WipeArgs {
/// Skip the confirmation prompt.
#[arg(short, long)]
pub yes: bool,
}