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:
22
src/atomic.rs
Normal file
22
src/atomic.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
//! 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
|
||||
//! file, and a crash mid-write must never corrupt the last-known-good state.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn write(path: &Path, contents: &[u8]) -> std::io::Result<()> {
|
||||
let tmp = path.with_extension(format!(
|
||||
"{}.tmp.{}",
|
||||
path.extension().and_then(|e| e.to_str()).unwrap_or(""),
|
||||
std::process::id()
|
||||
));
|
||||
{
|
||||
let mut f = std::fs::File::create(&tmp)?;
|
||||
f.write_all(contents)?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
std::fs::rename(&tmp, path)
|
||||
}
|
||||
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,
|
||||
}
|
||||
22
src/commands/add.rs
Normal file
22
src/commands/add.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use crate::cli::AddArgs;
|
||||
use crate::commands::edits_from_mapping;
|
||||
use crate::error::{PortholeError, Result};
|
||||
use crate::profile::{self, Profile};
|
||||
use crate::ui;
|
||||
|
||||
pub fn run(args: AddArgs) -> Result<()> {
|
||||
profile::require_valid_name(&args.name)?;
|
||||
let name = profile::normalize(&args.name);
|
||||
|
||||
if profile::exists(&name) {
|
||||
return Err(PortholeError::AlreadyExists(name));
|
||||
}
|
||||
|
||||
let edits = edits_from_mapping(&args.mapping);
|
||||
let new_profile = Profile::new(name.clone(), &edits)?;
|
||||
profile::save(&new_profile)?;
|
||||
|
||||
ui::ok(&format!("Saved profile '{name}' ({} {}).", new_profile.kind.label(), new_profile.mapping));
|
||||
println!(" Run 'porthole open {name}' to start it.");
|
||||
Ok(())
|
||||
}
|
||||
49
src/commands/close.rs
Normal file
49
src/commands/close.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use crate::cli::CloseArgs;
|
||||
use crate::error::Result;
|
||||
use crate::{instance, profile, ui};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const GRACEFUL_WAIT: Duration = Duration::from_secs(5);
|
||||
|
||||
pub fn run(args: CloseArgs) -> Result<()> {
|
||||
let name = profile::normalize(&args.name);
|
||||
profile::load(&name)?; // validate the profile itself exists
|
||||
|
||||
if close_instance(&name, args.force)? {
|
||||
ui::ok(&format!("Closed '{name}'."));
|
||||
} else {
|
||||
ui::info(&format!("'{name}' is not open."));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stops `name`'s supervisor if one is actually running, and clears any
|
||||
/// 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
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
if force {
|
||||
// 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) };
|
||||
let deadline = Instant::now() + GRACEFUL_WAIT;
|
||||
while instance::process_alive(pid) && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
if instance::process_alive(pid) {
|
||||
unsafe { libc::kill(-pid, libc::SIGKILL) };
|
||||
}
|
||||
}
|
||||
|
||||
// The supervisor removes its own instance file on a clean SIGTERM
|
||||
// shutdown; this covers the force-killed case where it never got to.
|
||||
instance::delete(name)?;
|
||||
Ok(true)
|
||||
}
|
||||
9
src/commands/completions.rs
Normal file
9
src/commands/completions.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use crate::cli::Cli;
|
||||
use clap::CommandFactory;
|
||||
use clap_complete::{generate, Shell};
|
||||
|
||||
pub fn run(shell: Shell) {
|
||||
let mut cmd = Cli::command();
|
||||
let name = cmd.get_name().to_string();
|
||||
generate(shell, &mut cmd, name, &mut std::io::stdout());
|
||||
}
|
||||
31
src/commands/edit.rs
Normal file
31
src/commands/edit.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use crate::cli::EditArgs;
|
||||
use crate::commands::{edits_from_mapping, mapping_is_empty};
|
||||
use crate::error::{PortholeError, Result};
|
||||
use crate::{instance, profile, ui};
|
||||
|
||||
pub fn run(args: EditArgs) -> Result<()> {
|
||||
if mapping_is_empty(&args.mapping) {
|
||||
return Err(PortholeError::NothingToDo(
|
||||
"pass at least one of -l/-r/-d, --via, --user, --identity, --port, --reconnect, \
|
||||
--retry-interval, --backoff-max, --keepalive."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
let name = profile::normalize(&args.name);
|
||||
let mut p = profile::load(&name)?;
|
||||
|
||||
let edits = edits_from_mapping(&args.mapping);
|
||||
p.apply_edits(&edits)?;
|
||||
profile::save(&p)?;
|
||||
|
||||
// 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."
|
||||
));
|
||||
}
|
||||
|
||||
ui::ok(&format!("Updated profile '{name}'."));
|
||||
Ok(())
|
||||
}
|
||||
120
src/commands/list.rs
Normal file
120
src/commands/list.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
use crate::cli::ListArgs;
|
||||
use crate::error::Result;
|
||||
use crate::{instance, profile, timefmt, ui};
|
||||
use serde::Serialize;
|
||||
|
||||
struct Row {
|
||||
name: String,
|
||||
kind: String,
|
||||
mapping: String,
|
||||
via: String,
|
||||
state: String,
|
||||
uptime: String,
|
||||
}
|
||||
|
||||
pub fn run(args: ListArgs) -> Result<()> {
|
||||
let profiles = profile::list_all()?;
|
||||
if profiles.is_empty() {
|
||||
ui::info("No profiles saved.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut rows = Vec::new();
|
||||
for p in &profiles {
|
||||
let inst = instance::load(&p.name)?;
|
||||
let live = inst.as_ref().is_some_and(|i| instance::supervisor_alive(i.pid, &p.name));
|
||||
let (state, uptime) = match &inst {
|
||||
None => ("closed".to_string(), String::new()),
|
||||
Some(_) if !live => ("error".to_string(), String::new()),
|
||||
Some(i) => (
|
||||
i.state.label().to_string(),
|
||||
i.connected_at.map(|c| timefmt::fmt_duration(timefmt::now() - c)).unwrap_or_default(),
|
||||
),
|
||||
};
|
||||
|
||||
if args.running && !matches!(state.as_str(), "up" | "reconnecting") {
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push(Row {
|
||||
name: p.name.clone(),
|
||||
kind: p.kind.label().to_string(),
|
||||
mapping: p.mapping.clone(),
|
||||
via: p.via.join(","),
|
||||
state,
|
||||
uptime,
|
||||
});
|
||||
}
|
||||
|
||||
if args.json {
|
||||
print_json(&rows);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if rows.is_empty() {
|
||||
ui::info("No matching profiles.");
|
||||
return Ok(());
|
||||
}
|
||||
print_table(&rows);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn col(i: usize, r: &Row) -> &str {
|
||||
match i {
|
||||
0 => &r.name,
|
||||
1 => &r.kind,
|
||||
2 => &r.mapping,
|
||||
3 => &r.via,
|
||||
4 => &r.state,
|
||||
_ => &r.uptime,
|
||||
}
|
||||
}
|
||||
|
||||
fn print_table(rows: &[Row]) {
|
||||
let headers = ["NAME", "KIND", "MAPPING", "VIA", "STATE", "UPTIME"];
|
||||
let widths: Vec<usize> =
|
||||
(0..6).map(|i| rows.iter().map(|r| col(i, r).len()).max().unwrap_or(0).max(headers[i].len())).collect();
|
||||
|
||||
let header_line: Vec<String> = headers.iter().enumerate().map(|(i, h)| format!("{h:<w$}", w = widths[i])).collect();
|
||||
println!("{}", ui::blue(&header_line.join(" ")));
|
||||
|
||||
for r in rows {
|
||||
let state_colored = match r.state.as_str() {
|
||||
"up" => ui::green(&r.state),
|
||||
"reconnecting" => ui::yellow(&r.state),
|
||||
"error" => ui::red(&r.state),
|
||||
_ => r.state.clone(),
|
||||
};
|
||||
let cells = [
|
||||
format!("{:<w$}", r.name, w = widths[0]),
|
||||
format!("{:<w$}", r.kind, w = widths[1]),
|
||||
format!("{:<w$}", r.mapping, w = widths[2]),
|
||||
format!("{:<w$}", r.via, w = widths[3]),
|
||||
// padded on the uncolored text width, then swapped for the
|
||||
// colored version so ANSI codes don't throw off alignment
|
||||
format!("{:<w$}", r.state, w = widths[4]).replacen(&r.state, &state_colored, 1),
|
||||
format!("{:<w$}", r.uptime, w = widths[5]),
|
||||
];
|
||||
println!("{}", cells.join(" "));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RowJson<'a> {
|
||||
name: &'a str,
|
||||
kind: &'a str,
|
||||
mapping: &'a str,
|
||||
via: &'a str,
|
||||
state: &'a str,
|
||||
uptime: &'a str,
|
||||
}
|
||||
|
||||
fn print_json(rows: &[Row]) {
|
||||
let out: Vec<RowJson> = rows
|
||||
.iter()
|
||||
.map(|r| RowJson { name: &r.name, kind: &r.kind, mapping: &r.mapping, via: &r.via, state: &r.state, uptime: &r.uptime })
|
||||
.collect();
|
||||
if let Ok(text) = serde_json::to_string_pretty(&out) {
|
||||
println!("{text}");
|
||||
}
|
||||
}
|
||||
49
src/commands/mod.rs
Normal file
49
src/commands/mod.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
pub mod add;
|
||||
pub mod close;
|
||||
pub mod completions;
|
||||
pub mod edit;
|
||||
pub mod list;
|
||||
pub mod open;
|
||||
pub mod remove;
|
||||
pub mod status;
|
||||
pub mod wipe;
|
||||
|
||||
use crate::cli::MappingArgs;
|
||||
use crate::profile::ProfileEdits;
|
||||
|
||||
/// Turns clap's `MappingArgs` into a `ProfileEdits`, splitting `--via`'s
|
||||
/// comma-separated raw string into the ordered hop list (spec §5.1).
|
||||
pub fn edits_from_mapping(m: &MappingArgs) -> ProfileEdits {
|
||||
let via = m.via.as_deref().map(|raw| {
|
||||
raw.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect::<Vec<_>>()
|
||||
});
|
||||
ProfileEdits {
|
||||
local: m.local.clone(),
|
||||
remote: m.remote.clone(),
|
||||
dynamic: m.dynamic.clone(),
|
||||
via,
|
||||
user: m.user.clone(),
|
||||
identity: m.identity.clone(),
|
||||
port: m.port,
|
||||
reconnect: m.reconnect,
|
||||
retry_interval: m.retry_interval,
|
||||
backoff_max: m.backoff_max,
|
||||
keepalive: m.keepalive,
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` if `MappingArgs` carries no edits at all - used by `edit` to
|
||||
/// reject a no-op invocation the same way vmic's commands do.
|
||||
pub fn mapping_is_empty(m: &MappingArgs) -> bool {
|
||||
m.local.is_none()
|
||||
&& m.remote.is_none()
|
||||
&& m.dynamic.is_none()
|
||||
&& m.via.is_none()
|
||||
&& m.user.is_none()
|
||||
&& m.identity.is_none()
|
||||
&& m.port.is_none()
|
||||
&& m.reconnect.is_none()
|
||||
&& m.retry_interval.is_none()
|
||||
&& m.backoff_max.is_none()
|
||||
&& m.keepalive.is_none()
|
||||
}
|
||||
131
src/commands/open.rs
Normal file
131
src/commands/open.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
//! `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;
|
||||
use crate::error::{PortholeError, Result};
|
||||
use crate::{instance, profile, supervisor, ui};
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const CONFIRM_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const CONFIRM_POLL: Duration = Duration::from_millis(150);
|
||||
|
||||
pub fn run(args: OpenArgs) -> Result<()> {
|
||||
if args.all {
|
||||
return open_all(args.once);
|
||||
}
|
||||
let Some(raw_name) = &args.name else {
|
||||
return Err(PortholeError::NothingToDo("pass a profile name, or --all.".into()));
|
||||
};
|
||||
let name = profile::normalize(raw_name);
|
||||
profile::load(&name)?; // validate the profile exists
|
||||
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 (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;
|
||||
let mut failed = 0;
|
||||
for p in profiles.iter().filter(|p| p.reconnect) {
|
||||
if instance::running_pid(&p.name)?.is_some() {
|
||||
continue;
|
||||
}
|
||||
match open_one(&p.name, false, once) {
|
||||
Ok(()) => opened += 1,
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
ui::warn(&format!("'{}': {e}", p.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
if failed > 0 {
|
||||
ui::ok(&format!("Opened {opened} profile(s), {failed} failed."));
|
||||
} else {
|
||||
ui::ok(&format!("Opened {opened} profile(s)."));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_one(name: &str, foreground: bool, once: bool) -> Result<()> {
|
||||
if let Some(pid) = instance::running_pid(name)? {
|
||||
ui::info(&format!("'{name}' is already open (pid {pid})."));
|
||||
return Ok(());
|
||||
}
|
||||
// A stale instance file left by a crash shouldn't linger through a
|
||||
// fresh spawn - the lock is the real authority (spec §5.2), this just
|
||||
// keeps `status` from showing ghost state mid-spawn.
|
||||
instance::delete(name)?;
|
||||
|
||||
if foreground {
|
||||
ui::info(&format!("Opening '{name}' in the foreground - Ctrl-C to close."));
|
||||
if once {
|
||||
std::env::set_var("PORTHOLE_SUPERVISE_ONCE", "1");
|
||||
}
|
||||
return supervisor::run(name);
|
||||
}
|
||||
|
||||
spawn_detached(name, once)?;
|
||||
wait_for_confirmation(name)
|
||||
}
|
||||
|
||||
/// 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);
|
||||
if let Some(parent) = log_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let log_file = std::fs::OpenOptions::new().create(true).append(true).open(&log_path)?;
|
||||
|
||||
let mut cmd = Command::new(exe);
|
||||
cmd.args(["__supervise", name]).stdin(Stdio::null()).stdout(log_file.try_clone()?).stderr(log_file);
|
||||
if once {
|
||||
cmd.env("PORTHOLE_SUPERVISE_ONCE", "1");
|
||||
}
|
||||
unsafe {
|
||||
cmd.pre_exec(|| if libc::setsid() < 0 { Err(std::io::Error::last_os_error()) } else { Ok(()) });
|
||||
}
|
||||
cmd.spawn()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// (spec §5.2). The instance file's *initial* write is always
|
||||
/// `State::Reconnecting` (the first attempt hasn't concluded yet) - that's
|
||||
/// indistinguishable from "already failed once, backing off" by state
|
||||
/// alone, so this must wait specifically for `Up` or `Error`, not just
|
||||
/// "anything other than Error", or it would report success before the
|
||||
/// first connection attempt has even had a chance to run.
|
||||
fn wait_for_confirmation(name: &str) -> Result<()> {
|
||||
let deadline = Instant::now() + CONFIRM_TIMEOUT;
|
||||
loop {
|
||||
if let Some(inst) = instance::load(name)? {
|
||||
match inst.state {
|
||||
instance::State::Error => {
|
||||
let reason = inst.last_error.unwrap_or_else(|| "see the log for details".into());
|
||||
return Err(PortholeError::OpenFailed(name.to_string(), reason));
|
||||
}
|
||||
instance::State::Up => {
|
||||
ui::ok(&format!("Opened '{name}' (pid {}).", inst.pid));
|
||||
return Ok(());
|
||||
}
|
||||
instance::State::Reconnecting => {} // first attempt still in flight; keep polling
|
||||
}
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
ui::ok(&format!("Opened '{name}' - still connecting, check 'porthole status {name}'."));
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(CONFIRM_POLL);
|
||||
}
|
||||
}
|
||||
24
src/commands/remove.rs
Normal file
24
src/commands/remove.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use crate::cli::RemoveArgs;
|
||||
use crate::commands::close::close_instance;
|
||||
use crate::error::Result;
|
||||
use crate::{instance, profile, ui};
|
||||
|
||||
pub fn run(args: RemoveArgs) -> Result<()> {
|
||||
let name = profile::normalize(&args.name);
|
||||
profile::load(&name)?; // validate existence
|
||||
|
||||
if args.keep_running {
|
||||
if instance::running_pid(&name)?.is_some() {
|
||||
ui::warn(&format!(
|
||||
"'{name}' left running untracked - it's no longer visible to 'list'/'status', \
|
||||
only 'wipe' will still find it."
|
||||
));
|
||||
}
|
||||
} else {
|
||||
close_instance(&name, false)?;
|
||||
}
|
||||
|
||||
profile::delete(&name)?;
|
||||
ui::ok(&format!("Removed profile '{name}'."));
|
||||
Ok(())
|
||||
}
|
||||
105
src/commands/status.rs
Normal file
105
src/commands/status.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
use crate::cli::StatusArgs;
|
||||
use crate::error::Result;
|
||||
use crate::instance::{Instance, State};
|
||||
use crate::profile::{self, Profile};
|
||||
use crate::{instance, timefmt, ui};
|
||||
use serde::Serialize;
|
||||
|
||||
pub fn run(args: StatusArgs) -> Result<()> {
|
||||
let name = profile::normalize(&args.name);
|
||||
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 (spec §2.2).
|
||||
let live = inst.as_ref().is_some_and(|i| instance::supervisor_alive(i.pid, &name));
|
||||
|
||||
if args.json {
|
||||
print_json(&p, inst.as_ref(), live);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("{}", ui::blue(&p.name));
|
||||
println!(" kind: {}", p.kind.label());
|
||||
println!(" mapping: {}", p.mapping);
|
||||
println!(" via: {}", p.via.join(","));
|
||||
if let Some(user) = &p.user {
|
||||
println!(" user: {user}");
|
||||
}
|
||||
println!(" reconnect: {}", p.reconnect);
|
||||
|
||||
match &inst {
|
||||
None => println!(" state: {}", ui::yellow("closed")),
|
||||
Some(i) if !live => {
|
||||
println!(" state: {}", ui::red("error (supervisor process not found)"));
|
||||
if let Some(err) = &i.last_error {
|
||||
println!(" last error: {err}");
|
||||
}
|
||||
}
|
||||
Some(i) => {
|
||||
let label = match i.state {
|
||||
State::Up => ui::green(i.state.label()),
|
||||
State::Reconnecting => ui::yellow(i.state.label()),
|
||||
State::Error => ui::red(i.state.label()),
|
||||
};
|
||||
println!(" state: {label}");
|
||||
println!(" session uptime: {}", timefmt::fmt_duration(timefmt::now() - i.opened_at));
|
||||
if let Some(connected_at) = i.connected_at {
|
||||
println!(" connection uptime: {}", timefmt::fmt_duration(timefmt::now() - connected_at));
|
||||
}
|
||||
println!(" reconnect count: {}", i.reconnect_count);
|
||||
if let Some(t) = i.last_reconnect_at {
|
||||
println!(" last reconnect: {}", timefmt::fmt_timestamp(t));
|
||||
}
|
||||
if let Some(err) = &i.last_error {
|
||||
println!(" last error: {err}");
|
||||
}
|
||||
println!(" log: {}", instance::log_path(&name).display());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct StatusJson<'a> {
|
||||
name: &'a str,
|
||||
kind: &'static str,
|
||||
mapping: &'a str,
|
||||
via: &'a [String],
|
||||
user: Option<&'a str>,
|
||||
reconnect: bool,
|
||||
state: &'static str,
|
||||
session_uptime_secs: Option<i64>,
|
||||
connection_uptime_secs: Option<i64>,
|
||||
reconnect_count: Option<u32>,
|
||||
last_reconnect_at: Option<i64>,
|
||||
last_error: Option<&'a str>,
|
||||
log: Option<String>,
|
||||
}
|
||||
|
||||
fn print_json(p: &Profile, inst: Option<&Instance>, live: bool) {
|
||||
let state = match (inst, live) {
|
||||
(None, _) => "closed",
|
||||
(Some(_), false) => "error",
|
||||
(Some(i), true) => i.state.label(),
|
||||
};
|
||||
let now = timefmt::now();
|
||||
let json = StatusJson {
|
||||
name: &p.name,
|
||||
kind: p.kind.label(),
|
||||
mapping: &p.mapping,
|
||||
via: &p.via,
|
||||
user: p.user.as_deref(),
|
||||
reconnect: p.reconnect,
|
||||
state,
|
||||
session_uptime_secs: inst.map(|i| now - i.opened_at),
|
||||
connection_uptime_secs: inst.and_then(|i| i.connected_at).map(|c| now - c),
|
||||
reconnect_count: inst.map(|i| i.reconnect_count),
|
||||
last_reconnect_at: inst.and_then(|i| i.last_reconnect_at),
|
||||
last_error: inst.and_then(|i| i.last_error.as_deref()),
|
||||
log: inst.map(|_| instance::log_path(&p.name).display().to_string()),
|
||||
};
|
||||
if let Ok(text) = serde_json::to_string_pretty(&json) {
|
||||
println!("{text}");
|
||||
}
|
||||
}
|
||||
59
src/commands/wipe.rs
Normal file
59
src/commands/wipe.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use crate::cli::WipeArgs;
|
||||
use crate::commands::close::close_instance;
|
||||
use crate::error::Result;
|
||||
use crate::{profile, ui};
|
||||
use std::io::Write;
|
||||
|
||||
pub fn run(args: WipeArgs) -> Result<()> {
|
||||
let profiles = profile::list_all()?;
|
||||
|
||||
if !args.yes {
|
||||
print!(
|
||||
"This will close and delete every forward, including any untracked ones. Continue? [y/N] "
|
||||
);
|
||||
std::io::stdout().flush().ok();
|
||||
let mut answer = String::new();
|
||||
std::io::stdin().read_line(&mut answer).ok();
|
||||
if !matches!(answer.trim().to_lowercase().as_str(), "y" | "yes") {
|
||||
ui::info("Aborted.");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let mut closed = 0;
|
||||
for p in &profiles {
|
||||
if close_instance(&p.name, false)? {
|
||||
closed += 1;
|
||||
}
|
||||
profile::delete(&p.name)?;
|
||||
}
|
||||
|
||||
let orphans = kill_orphaned_supervisors();
|
||||
|
||||
if profiles.is_empty() && orphans == 0 {
|
||||
ui::info("Nothing to wipe: no profiles or forwards found.");
|
||||
} else {
|
||||
ui::ok("Wiped all forwards:");
|
||||
println!(" profiles deleted: {}", profiles.len());
|
||||
println!(" running forwards closed: {closed}");
|
||||
println!(" orphaned supervisors killed: {orphans}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Kills any supervisor process not backed by a tracked profile (e.g.
|
||||
/// orphaned after a crash), matched by cmdline rather than tracked state -
|
||||
/// same spirit as vmic's `wipe` (spec §5.8). SIGTERM to the whole process
|
||||
/// group, same reasoning as `close_instance`'s force path.
|
||||
fn kill_orphaned_supervisors() -> u32 {
|
||||
let mut killed = 0;
|
||||
let Ok(entries) = std::fs::read_dir("/proc") else { return 0 };
|
||||
for entry in entries.flatten() {
|
||||
let Ok(pid) = entry.file_name().to_string_lossy().parse::<libc::pid_t>() else { continue };
|
||||
let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else { continue };
|
||||
if String::from_utf8_lossy(&cmdline).contains("__supervise") && unsafe { libc::kill(-pid, libc::SIGTERM) } == 0 {
|
||||
killed += 1;
|
||||
}
|
||||
}
|
||||
killed
|
||||
}
|
||||
63
src/error.rs
Normal file
63
src/error.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum PortholeError {
|
||||
#[error(
|
||||
"invalid name '{0}' (use 1-64 chars: letters, digits, '_' or '-'; \
|
||||
must start with a letter or digit)"
|
||||
)]
|
||||
InvalidName(String),
|
||||
|
||||
#[error("no profile named '{0}'")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("profile '{0}' already exists (use 'edit' to modify it)")]
|
||||
AlreadyExists(String),
|
||||
|
||||
#[error("exactly one of -l/--local, -r/--remote, -d/--dynamic is required")]
|
||||
NoMappingKind,
|
||||
|
||||
#[error("only one of -l/--local, -r/--remote, -d/--dynamic may be given")]
|
||||
MultipleMappingKinds,
|
||||
|
||||
#[error("invalid forward spec '{0}': expected [bind:]port:host:hostport (or [bind:]port for -d)")]
|
||||
InvalidMapping(String),
|
||||
|
||||
#[error("invalid --via hop '{0}': expected [user@]host[:port]")]
|
||||
InvalidVia(String),
|
||||
|
||||
#[error("--via is required: at least one hop (its last entry is the ssh connection target)")]
|
||||
NoViaHosts,
|
||||
|
||||
#[error("nothing to do: {0}")]
|
||||
NothingToDo(String),
|
||||
|
||||
#[error("'{0}' failed to start: {1}")]
|
||||
OpenFailed(String, String),
|
||||
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("state file error: {0}")]
|
||||
Serde(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, PortholeError>;
|
||||
|
||||
impl From<toml::de::Error> for PortholeError {
|
||||
fn from(e: toml::de::Error) -> Self {
|
||||
PortholeError::Serde(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<toml::ser::Error> for PortholeError {
|
||||
fn from(e: toml::ser::Error) -> Self {
|
||||
PortholeError::Serde(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for PortholeError {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
PortholeError::Serde(e.to_string())
|
||||
}
|
||||
}
|
||||
166
src/instance.rs
Normal file
166
src/instance.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
//! 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 crate::error::Result;
|
||||
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")]
|
||||
pub enum State {
|
||||
Up,
|
||||
Reconnecting,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
State::Up => "up",
|
||||
State::Reconnecting => "reconnecting",
|
||||
State::Error => "error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Instance {
|
||||
pub name: String,
|
||||
pub pid: i32,
|
||||
pub state: State,
|
||||
/// 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>,
|
||||
pub last_error: Option<String>,
|
||||
pub reconnect_count: u32,
|
||||
pub last_reconnect_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl Instance {
|
||||
pub fn new(name: String, pid: i32) -> Self {
|
||||
Self {
|
||||
name,
|
||||
pid,
|
||||
state: State::Reconnecting,
|
||||
opened_at: timefmt::now(),
|
||||
connected_at: None,
|
||||
last_error: None,
|
||||
reconnect_count: 0,
|
||||
last_reconnect_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
|
||||
/// Loads the instance file for `name`, if any. `None` means "closed" - the
|
||||
/// absence of this file *is* the closed state (spec §2.2); there's 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)?)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
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<()> {
|
||||
match std::fs::remove_file(instance_path(name)) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process_alive(pid: i32) -> bool {
|
||||
unsafe { libc::kill(pid, 0) == 0 }
|
||||
}
|
||||
|
||||
/// True only if `pid` is a live process whose cmdline looks like our own
|
||||
/// supervisor for `name` - insurance against a stale/reused pid, same
|
||||
/// spirit as vmic's `pid_matches` check on `pw-loopback` processes.
|
||||
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 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"
|
||||
/// rather than trusting stale on-disk state).
|
||||
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) -
|
||||
/// releases automatically when the holding process exits or is killed, no
|
||||
/// matter how abruptly, which is what makes it a reliable "is a supervisor
|
||||
/// actually alive for this profile" primitive even across a crash: the OS
|
||||
/// drops the lock the instant the fd closes, no stale-lock cleanup needed
|
||||
/// the way a plain pidfile would require.
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
42
src/main.rs
Normal file
42
src/main.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
mod atomic;
|
||||
mod cli;
|
||||
mod commands;
|
||||
mod error;
|
||||
mod instance;
|
||||
mod profile;
|
||||
mod ssh;
|
||||
mod supervisor;
|
||||
mod timefmt;
|
||||
mod ui;
|
||||
|
||||
use clap::Parser;
|
||||
use cli::{Cli, Commands};
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
let result = match cli.command {
|
||||
Commands::Add(args) => commands::add::run(args),
|
||||
Commands::Open(args) => commands::open::run(args),
|
||||
Commands::Close(args) => commands::close::run(args),
|
||||
Commands::Edit(args) => commands::edit::run(args),
|
||||
Commands::Status(args) => commands::status::run(args),
|
||||
Commands::List(args) => commands::list::run(args),
|
||||
Commands::Remove(args) => commands::remove::run(args),
|
||||
Commands::Wipe(args) => commands::wipe::run(args),
|
||||
Commands::Completions { shell } => {
|
||||
commands::completions::run(shell);
|
||||
Ok(())
|
||||
}
|
||||
// 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),
|
||||
};
|
||||
|
||||
if let Err(e) = result {
|
||||
ui::err(&e.to_string());
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
374
src/profile.rs
Normal file
374
src/profile.rs
Normal file
@@ -0,0 +1,374 @@
|
||||
//! 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;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Kind {
|
||||
Local,
|
||||
Remote,
|
||||
Dynamic,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
/// The `ssh` forward flag this kind maps to (`-L`/`-R`/`-D`).
|
||||
pub fn ssh_flag(self) -> &'static str {
|
||||
match self {
|
||||
Kind::Local => "-L",
|
||||
Kind::Remote => "-R",
|
||||
Kind::Dynamic => "-D",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Kind::Local => "local",
|
||||
Kind::Remote => "remote",
|
||||
Kind::Dynamic => "dynamic",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Profile {
|
||||
pub name: String,
|
||||
pub kind: Kind,
|
||||
/// Raw `-L/-R/-D` payload, without the flag itself - see spec §5.1.
|
||||
pub mapping: String,
|
||||
/// Ordered hop list, each `[user@]host[:port]` - see spec §3.1.
|
||||
#[serde(default)]
|
||||
pub via: Vec<String>,
|
||||
pub user: Option<String>,
|
||||
pub identity: Option<String>,
|
||||
#[serde(default = "default_ssh_port")]
|
||||
pub ssh_port: u16,
|
||||
#[serde(default = "default_true")]
|
||||
pub reconnect: bool,
|
||||
#[serde(default = "default_retry_interval")]
|
||||
pub retry_interval: u32,
|
||||
#[serde(default = "default_backoff_max")]
|
||||
pub backoff_max: u32,
|
||||
#[serde(default = "default_keepalive")]
|
||||
pub keepalive: u32,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
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)]
|
||||
pub struct ProfileEdits {
|
||||
pub local: Option<String>,
|
||||
pub remote: Option<String>,
|
||||
pub dynamic: Option<String>,
|
||||
pub via: Option<Vec<String>>,
|
||||
pub user: Option<String>,
|
||||
pub identity: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
pub reconnect: Option<bool>,
|
||||
pub retry_interval: Option<u32>,
|
||||
pub backoff_max: Option<u32>,
|
||||
pub keepalive: Option<u32>,
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
match given.len() {
|
||||
0 => Ok(None),
|
||||
1 => Ok(Some(given[0])),
|
||||
_ => Err(PortholeError::MultipleMappingKinds),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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()))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
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() {
|
||||
[port] if valid_port(port) => Ok(()),
|
||||
[_bind, port] if valid_port(port) => Ok(()),
|
||||
_ => Err(bad()),
|
||||
},
|
||||
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()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates a `--via` hop (`[user@]host[:port]`).
|
||||
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.is_empty() || port.parse::<u16>().is_err() {
|
||||
return Err(bad());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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. `via` is validated non-empty by
|
||||
/// every path that constructs a `Profile`, so the empty case here is
|
||||
/// unreachable in practice.
|
||||
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"))
|
||||
}
|
||||
|
||||
pub fn exists(name: &str) -> bool {
|
||||
profile_path(name).is_file()
|
||||
}
|
||||
|
||||
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<()> {
|
||||
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<()> {
|
||||
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>> {
|
||||
let dir = profiles_dir();
|
||||
if !dir.is_dir() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut names: Vec<String> = std::fs::read_dir(&dir)?
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let path = e.path();
|
||||
(path.extension().and_then(|x| x.to_str()) == Some("toml"))
|
||||
.then(|| path.file_stem().and_then(|s| s.to_str()).map(str::to_string))
|
||||
.flatten()
|
||||
})
|
||||
.collect();
|
||||
names.sort();
|
||||
|
||||
let mut out = Vec::with_capacity(names.len());
|
||||
for name in names {
|
||||
out.push(load(&name)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validates_local_mapping() {
|
||||
assert!(validate_mapping(Kind::Local, "5432:db.internal:5432").is_ok());
|
||||
assert!(validate_mapping(Kind::Local, "127.0.0.1:5432:db.internal:5432").is_ok());
|
||||
assert!(validate_mapping(Kind::Local, "not-a-port:db.internal:5432").is_err());
|
||||
assert!(validate_mapping(Kind::Local, "5432:db.internal").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_dynamic_mapping() {
|
||||
assert!(validate_mapping(Kind::Dynamic, "1080").is_ok());
|
||||
assert!(validate_mapping(Kind::Dynamic, "0.0.0.0:1080").is_ok());
|
||||
assert!(validate_mapping(Kind::Dynamic, "abc").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_via_hops() {
|
||||
assert!(validate_via_hop("jumpbox").is_ok());
|
||||
assert!(validate_via_hop("ops@jumpbox").is_ok());
|
||||
assert!(validate_via_hop("jumpbox:2222").is_ok());
|
||||
assert!(validate_via_hop("ops@jumpbox:2222").is_ok());
|
||||
assert!(validate_via_hop("jumpbox:notaport").is_err());
|
||||
assert!(validate_via_hop("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_via_into_jumps_and_target() {
|
||||
let mut p = Profile::new(
|
||||
"t".into(),
|
||||
&ProfileEdits { local: Some("80:h:80".into()), via: Some(vec!["jumpbox".into()]), ..Default::default() },
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(p.ssh_target(), (None, "jumpbox"));
|
||||
|
||||
p.via = vec!["bastion1".into(), "bastion2:2222".into()];
|
||||
assert_eq!(p.ssh_target(), (Some("bastion1".into()), "bastion2:2222"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_via() {
|
||||
let edits = ProfileEdits { local: Some("80:h:80".into()), via: Some(vec![]), ..Default::default() };
|
||||
assert!(matches!(Profile::new("t".into(), &edits), Err(PortholeError::NoViaHosts)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_multiple_mapping_kinds() {
|
||||
let edits = ProfileEdits {
|
||||
local: Some("8080:localhost:8080".into()),
|
||||
remote: Some("9000:localhost:9000".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(matches!(edits.mapping_kind(), Err(PortholeError::MultipleMappingKinds)));
|
||||
}
|
||||
}
|
||||
88
src/ssh.rs
Normal file
88
src/ssh.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
//! Builds the `ssh` invocation for a profile - spec §3.1.
|
||||
|
||||
use crate::profile::Profile;
|
||||
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 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 for why each of these is non-negotiable.
|
||||
cmd.args([
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"ExitOnForwardFailure=yes",
|
||||
"-o",
|
||||
"ConnectTimeout=10",
|
||||
"-o",
|
||||
&format!("ServerAliveInterval={}", profile.keepalive),
|
||||
"-N",
|
||||
]);
|
||||
|
||||
let (jumps, target) = profile.ssh_target();
|
||||
if let Some(jumps) = jumps {
|
||||
cmd.args(["-J", &jumps]);
|
||||
}
|
||||
|
||||
cmd.arg("-p").arg(profile.ssh_port.to_string());
|
||||
if let Some(identity) = &profile.identity {
|
||||
cmd.arg("-i").arg(identity);
|
||||
}
|
||||
if let Some(user) = &profile.user {
|
||||
cmd.arg("-l").arg(user);
|
||||
}
|
||||
|
||||
cmd.arg(profile.kind.ssh_flag()).arg(&profile.mapping);
|
||||
cmd.arg(target);
|
||||
|
||||
cmd
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::profile::{Kind, ProfileEdits};
|
||||
|
||||
fn profile_with(via: Vec<&str>) -> Profile {
|
||||
Profile::new(
|
||||
"t".into(),
|
||||
&ProfileEdits {
|
||||
local: Some("5432:db.internal:5432".into()),
|
||||
via: Some(via.into_iter().map(String::from).collect()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_hop_has_no_dash_j() {
|
||||
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_splits_jumps_from_target() {
|
||||
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");
|
||||
assert_eq!(args.last(), Some(&"bastion2:2222".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn includes_forward_flag_and_mapping() {
|
||||
let p = profile_with(vec!["jumpbox"]);
|
||||
assert_eq!(p.kind, Kind::Local);
|
||||
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");
|
||||
}
|
||||
}
|
||||
283
src/supervisor.rs
Normal file
283
src/supervisor.rs
Normal file
@@ -0,0 +1,283 @@
|
||||
//! 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 crate::error::Result;
|
||||
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 - 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 - spec §4.2.
|
||||
const MAX_UNRECOGNIZED_STREAK: u32 = 10;
|
||||
/// How long `ssh` must stay alive before porthole calls it "connected" -
|
||||
/// see `run_ssh_once`'s doc comment for why this heuristic is used at all.
|
||||
const CONNECT_GRACE: Duration = Duration::from_secs(2);
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(200);
|
||||
const LOG_ROTATE_BYTES: u64 = 10 * 1024 * 1024;
|
||||
|
||||
static SHUTDOWN: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
extern "C" fn handle_sigterm(_sig: libc::c_int) {
|
||||
SHUTDOWN.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Traps SIGTERM (and SIGINT, for `-f/--foreground`'s Ctrl-C - spec §5.2)
|
||||
/// into a flag instead of the default terminate-immediately behavior, so
|
||||
/// `close` is distinguished from a dropped `ssh` connection by *why* the
|
||||
/// loop is unwinding, not by guessing from `ssh`'s exit status - which is
|
||||
/// not a reliable signal either way. In foreground mode this function runs
|
||||
/// in the same process the terminal sends Ctrl-C's SIGINT to, since
|
||||
/// `commands::open` calls `supervisor::run` inline rather than detaching.
|
||||
fn install_signal_handler() {
|
||||
unsafe {
|
||||
libc::signal(libc::SIGTERM, handle_sigterm as *const () as usize);
|
||||
libc::signal(libc::SIGINT, handle_sigterm as *const () as usize);
|
||||
}
|
||||
}
|
||||
|
||||
enum Class {
|
||||
Fatal,
|
||||
KnownTransient,
|
||||
Unrecognized,
|
||||
}
|
||||
|
||||
enum Outcome {
|
||||
ShutdownRequested,
|
||||
Failed { class: Class, message: String },
|
||||
}
|
||||
|
||||
/// Entry point for `porthole __supervise <name>`. Runs until told to stop
|
||||
/// (SIGTERM) or gives up per §4 - this *is* the supervisor process.
|
||||
pub fn run(name: &str) -> Result<()> {
|
||||
install_signal_handler();
|
||||
|
||||
let profile = profile::load(name)?;
|
||||
|
||||
// Holding this for our entire lifetime is what makes "is <name>
|
||||
// already open" a reliable, race-free check for `open` (spec §2.3/§3).
|
||||
let Some(_lock) = Lock::try_acquire(name)? else {
|
||||
return Ok(()); // another supervisor beat us to it; nothing to do
|
||||
};
|
||||
|
||||
let pid = std::process::id() as i32;
|
||||
let mut inst = Instance::new(name.to_string(), pid);
|
||||
instance::save(&inst)?;
|
||||
|
||||
let once = std::env::var_os("PORTHOLE_SUPERVISE_ONCE").is_some();
|
||||
let base_delay = profile.retry_interval.max(1) as u64;
|
||||
let max_delay = (profile.backoff_max as u64).max(base_delay);
|
||||
let mut delay = base_delay;
|
||||
let mut unrecognized_streak: u32 = 0;
|
||||
|
||||
loop {
|
||||
let attempt_started = timefmt::now();
|
||||
match run_ssh_once(name, &profile, &mut inst) {
|
||||
Outcome::ShutdownRequested => {
|
||||
instance::delete(name)?;
|
||||
return Ok(());
|
||||
}
|
||||
Outcome::Failed { class, message } => {
|
||||
let uptime = timefmt::now() - attempt_started;
|
||||
if uptime >= STABLE_THRESHOLD_SECS {
|
||||
delay = base_delay;
|
||||
unrecognized_streak = 0;
|
||||
}
|
||||
match class {
|
||||
Class::Unrecognized => unrecognized_streak += 1,
|
||||
Class::KnownTransient => unrecognized_streak = 0,
|
||||
Class::Fatal => {}
|
||||
}
|
||||
|
||||
inst.last_error =
|
||||
Some(if message.is_empty() { "ssh exited unexpectedly (no output captured)".to_string() } else { message });
|
||||
|
||||
let fatal = matches!(class, Class::Fatal);
|
||||
let give_up = fatal || !profile.reconnect || once || unrecognized_streak > MAX_UNRECOGNIZED_STREAK;
|
||||
if give_up {
|
||||
inst.state = State::Error;
|
||||
instance::save(&inst)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
inst.state = State::Reconnecting;
|
||||
inst.reconnect_count += 1;
|
||||
inst.last_reconnect_at = Some(timefmt::now());
|
||||
inst.connected_at = None;
|
||||
instance::save(&inst)?;
|
||||
|
||||
if sleep_or_shutdown(Duration::from_secs(delay)) {
|
||||
instance::delete(name)?;
|
||||
return Ok(());
|
||||
}
|
||||
delay = (delay * 2).min(max_delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sleeps for `dur`, polling `SHUTDOWN` periodically so a `close` that
|
||||
/// arrives during a reconnect backoff window is honored promptly instead
|
||||
/// of waiting out the full delay. Returns `true` if shutdown was requested.
|
||||
fn sleep_or_shutdown(dur: Duration) -> bool {
|
||||
let deadline = Instant::now() + dur;
|
||||
while Instant::now() < deadline {
|
||||
if SHUTDOWN.load(Ordering::SeqCst) {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(POLL_INTERVAL.min(dur));
|
||||
}
|
||||
SHUTDOWN.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Spawns one `ssh` attempt and supervises it until it exits or shutdown is
|
||||
/// requested. Marks `inst` as `State::Up` once the process has survived
|
||||
/// `CONNECT_GRACE` - `ssh` gives no more reliable "the forward is actually
|
||||
/// bound" signal than that without parsing `-v` debug output, and a real
|
||||
/// failure exits near-instantly under `ExitOnForwardFailure=yes` (§3.1), so
|
||||
/// staying alive past the grace window is a reasonable proxy for connected.
|
||||
fn run_ssh_once(name: &str, profile: &Profile, inst: &mut Instance) -> Outcome {
|
||||
rotate_log_if_large(name);
|
||||
|
||||
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}") },
|
||||
};
|
||||
|
||||
let stderr_tail = Arc::new(Mutex::new(String::new()));
|
||||
let stdout_thread = child.stdout.take().map(|out| spawn_log_drain(name, out));
|
||||
let stderr_thread = child.stderr.take().map(|err| spawn_stderr_drain(name, err, stderr_tail.clone()));
|
||||
|
||||
let grace_deadline = Instant::now() + CONNECT_GRACE;
|
||||
let mut marked_up = false;
|
||||
|
||||
loop {
|
||||
if SHUTDOWN.load(Ordering::SeqCst) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
join_all([stdout_thread, stderr_thread]);
|
||||
return Outcome::ShutdownRequested;
|
||||
}
|
||||
match child.try_wait() {
|
||||
Ok(Some(_status)) => break,
|
||||
Ok(None) => {
|
||||
if !marked_up && Instant::now() >= grace_deadline {
|
||||
marked_up = true;
|
||||
inst.state = State::Up;
|
||||
inst.connected_at = Some(timefmt::now());
|
||||
let _ = instance::save(inst);
|
||||
}
|
||||
std::thread::sleep(POLL_INTERVAL);
|
||||
}
|
||||
Err(_) => break, // process table race (should not happen on unix); treat as exited
|
||||
}
|
||||
}
|
||||
|
||||
join_all([stdout_thread, stderr_thread]);
|
||||
|
||||
let tail = stderr_tail.lock().map(|s| s.clone()).unwrap_or_default();
|
||||
let (class, message) = classify(&tail);
|
||||
Outcome::Failed { class, message }
|
||||
}
|
||||
|
||||
fn join_all<const N: usize>(handles: [Option<JoinHandle<()>>; N]) {
|
||||
for h in handles.into_iter().flatten() {
|
||||
let _ = h.join();
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn classify(stderr_tail: &str) -> (Class, String) {
|
||||
const FATAL: &[&str] = &["Permission denied", "Host key verification failed", "bind: Address already in use"];
|
||||
const KNOWN_TRANSIENT: &[&str] = &[
|
||||
"Connection refused",
|
||||
"No route to host",
|
||||
"Could not resolve hostname",
|
||||
"Connection timed out",
|
||||
"Operation timed out",
|
||||
];
|
||||
|
||||
let message = stderr_tail.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("").trim().to_string();
|
||||
|
||||
if FATAL.iter().any(|p| stderr_tail.contains(p)) {
|
||||
(Class::Fatal, message)
|
||||
} else if KNOWN_TRANSIENT.iter().any(|p| stderr_tail.contains(p)) {
|
||||
(Class::KnownTransient, message)
|
||||
} else {
|
||||
(Class::Unrecognized, message)
|
||||
}
|
||||
}
|
||||
|
||||
fn rotate_log_if_large(name: &str) {
|
||||
let path = instance::log_path(name);
|
||||
if let Ok(meta) = std::fs::metadata(&path) {
|
||||
if meta.len() > LOG_ROTATE_BYTES {
|
||||
let _ = std::fs::rename(&path, path.with_extension("log.1"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn append_log(name: &str, line: &str) {
|
||||
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(instance::log_path(name)) {
|
||||
let _ = writeln!(f, "{line}");
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_log_drain(name: &str, out: std::process::ChildStdout) -> JoinHandle<()> {
|
||||
let name = name.to_string();
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(out).lines().map_while(std::result::Result::ok) {
|
||||
append_log(&name, &line);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_stderr_drain(name: &str, err: std::process::ChildStderr, tail: Arc<Mutex<String>>) -> JoinHandle<()> {
|
||||
let name = name.to_string();
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(err).lines().map_while(std::result::Result::ok) {
|
||||
append_log(&name, &line);
|
||||
if let Ok(mut t) = tail.lock() {
|
||||
if !t.is_empty() {
|
||||
t.push('\n');
|
||||
}
|
||||
t.push_str(&line);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classifies_fatal_patterns() {
|
||||
assert!(matches!(classify("foo\nPermission denied (publickey).").0, Class::Fatal));
|
||||
assert!(matches!(classify("bind: Address already in use").0, Class::Fatal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_known_transient_patterns() {
|
||||
assert!(matches!(classify("ssh: connect to host x port 22: Connection refused").0, Class::KnownTransient));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_unrecognized_as_transient() {
|
||||
assert!(matches!(classify("something completely unexpected").0, Class::Unrecognized));
|
||||
assert!(matches!(classify("").0, Class::Unrecognized));
|
||||
}
|
||||
}
|
||||
70
src/timefmt.rs
Normal file
70
src/timefmt.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
//! Minimal UTC timestamp/duration formatting - no `chrono`/`time` dependency,
|
||||
//! matching vmic's minimal-dependency footprint. Timestamps are stored as
|
||||
//! Unix seconds (`i64`) everywhere in profile/instance state; this module
|
||||
//! only turns them into text for `status`/`list` output.
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD HH:MM:SS UTC`, via Howard Hinnant's civil-from-days algorithm
|
||||
/// (public domain, http://howardhinnant.github.io/date_algorithms.html) -
|
||||
/// avoids pulling in a whole calendar/timezone crate for what's otherwise a
|
||||
/// handful of integer operations.
|
||||
pub fn fmt_timestamp(unix_secs: i64) -> String {
|
||||
let days = unix_secs.div_euclid(86_400);
|
||||
let secs_of_day = unix_secs.rem_euclid(86_400);
|
||||
let (h, m, s) = (secs_of_day / 3600, (secs_of_day / 60) % 60, secs_of_day % 60);
|
||||
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = (z - era * 146_097) as i64; // [0, 146096]
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
|
||||
let mp = (5 * doy + 2) / 153; // [0, 11]
|
||||
let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
|
||||
let m_num = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
|
||||
let y = if m_num <= 2 { y + 1 } else { y };
|
||||
|
||||
format!("{y:04}-{m_num:02}-{d:02} {h:02}:{m:02}:{s:02} UTC")
|
||||
}
|
||||
|
||||
/// Compact `1d 02h 03m 04s`-style duration, dropping leading zero units.
|
||||
pub fn fmt_duration(secs: i64) -> String {
|
||||
let secs = secs.max(0);
|
||||
let (d, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (h, rem) = (rem / 3600, rem % 3600);
|
||||
let (m, s) = (rem / 60, rem % 60);
|
||||
|
||||
if d > 0 {
|
||||
format!("{d}d {h:02}h {m:02}m {s:02}s")
|
||||
} else if h > 0 {
|
||||
format!("{h}h {m:02}m {s:02}s")
|
||||
} else if m > 0 {
|
||||
format!("{m}m {s:02}s")
|
||||
} else {
|
||||
format!("{s}s")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn formats_known_epoch() {
|
||||
assert_eq!(fmt_timestamp(0), "1970-01-01 00:00:00 UTC");
|
||||
assert_eq!(fmt_timestamp(1_700_000_000), "2023-11-14 22:13:20 UTC");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_durations() {
|
||||
assert_eq!(fmt_duration(5), "5s");
|
||||
assert_eq!(fmt_duration(65), "1m 05s");
|
||||
assert_eq!(fmt_duration(3665), "1h 01m 05s");
|
||||
assert_eq!(fmt_duration(90_065), "1d 01h 01m 05s");
|
||||
}
|
||||
}
|
||||
55
src/ui.rs
Normal file
55
src/ui.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
//! Colorized status output (NO_COLOR-aware). Same conventions as vmic's
|
||||
//! `ui.rs` - kept identical on purpose so the two tools feel like one family.
|
||||
|
||||
use std::io::IsTerminal;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
fn color_enabled() -> bool {
|
||||
static ENABLED: OnceLock<bool> = OnceLock::new();
|
||||
*ENABLED.get_or_init(|| {
|
||||
std::env::var_os("NO_COLOR").is_none()
|
||||
&& std::env::var("TERM").map(|t| t != "dumb").unwrap_or(true)
|
||||
&& std::io::stdout().is_terminal()
|
||||
&& std::io::stderr().is_terminal()
|
||||
})
|
||||
}
|
||||
|
||||
fn paint(code: &str, s: &str) -> String {
|
||||
if color_enabled() {
|
||||
format!("\x1b[{code}m{s}\x1b[0m")
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn red(s: &str) -> String {
|
||||
paint("31", s)
|
||||
}
|
||||
|
||||
pub fn yellow(s: &str) -> String {
|
||||
paint("33", s)
|
||||
}
|
||||
|
||||
pub fn green(s: &str) -> String {
|
||||
paint("32", s)
|
||||
}
|
||||
|
||||
pub fn blue(s: &str) -> String {
|
||||
paint("34", s)
|
||||
}
|
||||
|
||||
pub fn err(msg: &str) {
|
||||
eprintln!("{}", red(&format!("Error: {msg}")));
|
||||
}
|
||||
|
||||
pub fn warn(msg: &str) {
|
||||
eprintln!("{}", yellow(&format!("Warning: {msg}")));
|
||||
}
|
||||
|
||||
pub fn info(msg: &str) {
|
||||
println!("{}", blue(msg));
|
||||
}
|
||||
|
||||
pub fn ok(msg: &str) {
|
||||
println!("{}", green(msg));
|
||||
}
|
||||
Reference in New Issue
Block a user