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:
185
src/cli.rs
Normal file
185
src/cli.rs
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user