diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..950fd9d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,440 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libredox" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "porthole" +version = "0.1.0" +dependencies = [ + "clap", + "clap_complete", + "dirs", + "libc", + "serde", + "serde_json", + "thiserror", + "toml", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a34e85c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "porthole" +version = "0.1.0" +edition = "2021" +description = "Create and manage named SSH port forwards." +license = "AGPL-3+" + +[[bin]] +name = "porthole" +path = "src/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } +clap_complete = "4" + +thiserror = "^2" +dirs = "6" + +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" + +libc = "0.2" + +# Keep panics from corrupting a partially-written state file - abort is +# fine for a CLI/supervisor as there's no long-lived in-process state to +# unwind (state lives on disk and is written atomically, see src/atomic.rs). +[profile.release] +panic = "abort" +lto = true diff --git a/src/atomic.rs b/src/atomic.rs new file mode 100644 index 0000000..a8556e1 --- /dev/null +++ b/src/atomic.rs @@ -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) +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..683c883 --- /dev/null +++ b/src/cli.rs @@ -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, + + /// Remote forward: remote -> your machine. [bind:]port:host:hostport + #[arg(short, long, value_name = "SPEC")] + pub remote: Option, + + /// Dynamic forward (SOCKS proxy). [bind:]port + #[arg(short, long, value_name = "SPEC")] + pub dynamic: Option, + + /// 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, + + /// Default user for the target and any --via hop without its own. + #[arg(short, long, value_name = "USER")] + pub user: Option, + + /// Identity file override. + #[arg(short, long, value_name = "PATH")] + pub identity: Option, + + /// SSH port on the final target only. + #[arg(short, long, value_name = "PORT")] + pub port: Option, + + /// Auto-reconnect on drop. + #[arg(long, num_args = 0..=1, default_missing_value = "true", value_name = "BOOL")] + pub reconnect: Option, + + /// Base delay between reconnect attempts, in seconds. + #[arg(long = "retry-interval", value_name = "SECONDS")] + pub retry_interval: Option, + + /// Cap on the doubling reconnect delay, in seconds. + #[arg(long = "backoff-max", value_name = "SECONDS")] + pub backoff_max: Option, + + /// ServerAliveInterval, in seconds. + #[arg(long, value_name = "SECONDS")] + pub keepalive: Option, +} + +#[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, + + /// 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, +} diff --git a/src/commands/add.rs b/src/commands/add.rs new file mode 100644 index 0000000..a35e627 --- /dev/null +++ b/src/commands/add.rs @@ -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(()) +} diff --git a/src/commands/close.rs b/src/commands/close.rs new file mode 100644 index 0000000..fa63b07 --- /dev/null +++ b/src/commands/close.rs @@ -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 { + 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) +} diff --git a/src/commands/completions.rs b/src/commands/completions.rs new file mode 100644 index 0000000..a4e5f04 --- /dev/null +++ b/src/commands/completions.rs @@ -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()); +} diff --git a/src/commands/edit.rs b/src/commands/edit.rs new file mode 100644 index 0000000..d1046e9 --- /dev/null +++ b/src/commands/edit.rs @@ -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(()) +} diff --git a/src/commands/list.rs b/src/commands/list.rs new file mode 100644 index 0000000..e694265 --- /dev/null +++ b/src/commands/list.rs @@ -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 = + (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 = headers.iter().enumerate().map(|(i, h)| format!("{h: ui::green(&r.state), + "reconnecting" => ui::yellow(&r.state), + "error" => ui::red(&r.state), + _ => r.state.clone(), + }; + let cells = [ + format!("{: { + 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 = 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}"); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..a2c7738 --- /dev/null +++ b/src/commands/mod.rs @@ -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::>() + }); + 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() +} diff --git a/src/commands/open.rs b/src/commands/open.rs new file mode 100644 index 0000000..22146de --- /dev/null +++ b/src/commands/open.rs @@ -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 `) 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 ` 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); + } +} diff --git a/src/commands/remove.rs b/src/commands/remove.rs new file mode 100644 index 0000000..f37a170 --- /dev/null +++ b/src/commands/remove.rs @@ -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(()) +} diff --git a/src/commands/status.rs b/src/commands/status.rs new file mode 100644 index 0000000..afa0f50 --- /dev/null +++ b/src/commands/status.rs @@ -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, + connection_uptime_secs: Option, + reconnect_count: Option, + last_reconnect_at: Option, + last_error: Option<&'a str>, + log: Option, +} + +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}"); + } +} diff --git a/src/commands/wipe.rs b/src/commands/wipe.rs new file mode 100644 index 0000000..e647c6a --- /dev/null +++ b/src/commands/wipe.rs @@ -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::() 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 +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..e623b12 --- /dev/null +++ b/src/error.rs @@ -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 = std::result::Result; + +impl From for PortholeError { + fn from(e: toml::de::Error) -> Self { + PortholeError::Serde(e.to_string()) + } +} + +impl From for PortholeError { + fn from(e: toml::ser::Error) -> Self { + PortholeError::Serde(e.to_string()) + } +} + +impl From for PortholeError { + fn from(e: serde_json::Error) -> Self { + PortholeError::Serde(e.to_string()) + } +} diff --git a/src/instance.rs b/src/instance.rs new file mode 100644 index 0000000..d02b7a6 --- /dev/null +++ b/src/instance.rs @@ -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, + pub last_error: Option, + pub reconnect_count: u32, + pub last_reconnect_at: Option, +} + +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> { + 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> { + 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> { + 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()) + } + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..0480dde --- /dev/null +++ b/src/main.rs @@ -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); + } +} diff --git a/src/profile.rs b/src/profile.rs new file mode 100644 index 0000000..04da784 --- /dev/null +++ b/src/profile.rs @@ -0,0 +1,374 @@ +//! Persisted forward definitions - spec §2.1. One TOML file per profile at +//! `~/.config/porthole/profiles/.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, + pub user: Option, + pub identity: Option, + #[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, + pub remote: Option, + pub dynamic: Option, + pub via: Option>, + pub user: Option, + pub identity: Option, + pub port: Option, + pub reconnect: Option, + pub retry_interval: Option, + pub backoff_max: Option, + pub keepalive: Option, +} + +impl ProfileEdits { + fn mapping_kind(&self) -> Result> { + 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::().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::().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 { + 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, &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 { + 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> { + let dir = profiles_dir(); + if !dir.is_dir() { + return Ok(Vec::new()); + } + let mut names: Vec = 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))); + } +} diff --git a/src/ssh.rs b/src/ssh.rs new file mode 100644 index 0000000..104fae2 --- /dev/null +++ b/src/ssh.rs @@ -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 = 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 = 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 = 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"); + } +} diff --git a/src/supervisor.rs b/src/supervisor.rs new file mode 100644 index 0000000..c8d43bd --- /dev/null +++ b/src/supervisor.rs @@ -0,0 +1,283 @@ +//! The `__supervise` loop - spec §3/§4. Runs as a detached, re-exec'd copy +//! of this same binary (`porthole __supervise `, 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 `. 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 + // 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(handles: [Option>; 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>) -> 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)); + } +} diff --git a/src/timefmt.rs b/src/timefmt.rs new file mode 100644 index 0000000..c4bae72 --- /dev/null +++ b/src/timefmt.rs @@ -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"); + } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..b5bc515 --- /dev/null +++ b/src/ui.rs @@ -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 = 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)); +}