diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..79a8a58 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ + +.* +!.gitignore + +target/ +*.lock +!Cargo.lock diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..e9b94cc --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,435 @@ +# 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 = "1.0.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 = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[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 = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[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 = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[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..a66db8a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "porthole" +version = "1.0.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 = "^1" + +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 +opt-level = 3 +codegen-units = 1 diff --git a/README.md b/README.md index 52dbbb0..cf8323a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,166 @@ # porthole -Create and manage named SSH port fowards easily. \ No newline at end of file +Create and manage named SSH port forwards easily. + +Porthole wraps `ssh` to turn tunnel commands into named profiles: define a +forward once, then open, close, and inspect it by name. Each open forward +runs under a small supervisor process that keeps it alive and reconnects +automatically if the connection drops. + +--- + +## Features + +- **Named profiles** for local, remote, and dynamic/SOCKS forwards, saved + to disk instead of retyped each time. +- **Multi-hop jump chains**, built on `ssh -J`. +- **Auto-reconnect** with exponential backoff, unless a failure looks + permanent (bad auth, host key mismatch, port already bound). +- **Status and listing** with live state, uptime, and reconnect counts, as + text or JSON. +- **Import/export** of profiles as a single TOML file, for moving a setup + to another machine. +- **Shell completions** for bash, zsh, fish, and others. +- **Hardened by default**: every spawned `ssh` call ignores the caller's + own config, runs in batch mode with no interactive prompts, and applies + the same hardening to every hop in a jump chain, not just the final + target. + +--- + +## Installation + +Requires a Rust toolchain and an `ssh` binary on `PATH`. + +```sh +cargo build --release +``` + +The binary is written to `target/release/porthole`. Place it on `PATH`, +e.g.: + +```sh +install -Dm755 target/release/porthole ~/.local/bin/porthole +``` + +--- + +## Commands + +| Command | Aliases | Description | +|---------------|----------------|-----------------------------------------------------------| +| `add` | `mk`, `create` | Save a new forward profile. | +| `open` | `start` | Start a saved forward as a supervised background process. | +| `close` | `stop` | Stop a running forward. | +| `edit` | `change` | Update a saved profile. | +| `status` | | Show detailed status for one forward. | +| `list` | `ls` | List all saved profiles with live status. | +| `remove` | `rm`, `delete` | Delete a saved profile. | +| `wipe` | `reset` | Close and delete every forward, tracked or not. | +| `transfer` | `data` | Export saved profiles to a file, or import them from one. | +| `completions` | | Generate a shell completion script. | + +Run `porthole` or `porthole --help` for the full flag reference. + +### `add` / `edit` flags + +One of `-l/--local`, `-r/--remote`, or `-d/--dynamic` selects the forward +kind (required for `add`, optional for `edit`): + +| Flag | Value | Meaning | Default | +|--------------------|-------------------------|-------------------------------------------------------------|:-------:| +| `-l, --local` | `[BIND:]PORT:HOST:PORT` | Local forward (this machine -> remote machine). | - | +| `-r, --remote` | `[BIND:]PORT:HOST:PORT` | Remote forward (remote machine -> this machine). | - | +| `-d, --dynamic` | `[BIND:]PORT` | Dynamic forward (SOCKS proxy). | - | +| `--via` | `[USER@]HOST[:PORT]` | Jump-host chain, ending at the connection target. Required. | - | +| `-u, --user` | `USER` | Default user for the target and any hop without one. | - | +| `-i, --identity` | `PATH` | Identity file override. | - | +| `-p, --port` | `PORT` | SSH port of the final target. | `22` | +| `--reconnect` | `BOOL` | Auto-reconnect on connection drop. | `true` | +| `--retry-interval` | `SECONDS` | Base delay between reconnection attempts. | `5` | +| `--backoff-max` | `SECONDS` | Cap on the doubling reconnection delay. | `60` | +| `--keepalive` | `SECONDS` | SSH `ServerAliveInterval`. | `15` | + +`edit` only touches the fields given on the command line; everything else +stays as-is. + +### Examples + +```sh +# Remote forward, exposing this machine's port 3000 to the remote host. +porthole add expose-app -r 3000:localhost:3000 --via ops@server.example.com + +# Dynamic SOCKS proxy. +porthole add socks -d 1080 --via user@gateway + +# Two-hop jump chain: bastion1, then bastion2, ending at db-host. +porthole add mydb -l 5432:internal-db:5432 --via bastion1 --via bastion2 -u ops + +# Open every profile with reconnect enabled that isn't already running. +porthole open --all + +# Run attached in the current shell instead of detaching. +porthole open mydb --foreground + +# Force-kill instead of a graceful SIGTERM-then-wait. +porthole close mydb --force + +# Machine-readable status/listing. +porthole status mydb --json +porthole list --json + +# Back up all profiles, then restore them elsewhere. +porthole transfer --export backup.toml +porthole transfer --import backup.toml +``` + +> **ⓘ** Note:
+> `transfer --export` does not include identity file contents, only +> their configured paths; key files need to be copied to the target machine +> separately. + +--- + +## How a forward stays open + +`open` spawns a detached copy of the `porthole` binary running an internal +supervisor loop for that one profile. The supervisor: + +- Builds and runs the `ssh` command for the profile (`-N -T` plus the + right `-L`/`-R`/`-D` flag), holding an advisory file lock for its whole + lifetime so `status`/`list` can reliably tell if it's still alive. +- Watches the connection; once it survives a short grace period it's + reported as `up`. +- On a dropped or failed connection, classifies the failure: + - **Fatal** (bad auth, host key mismatch, port already in use): gives up + immediately, state becomes `error`. + - **Known transient** (connection refused, DNS failure, timeout): + reconnects with exponential backoff. + - **Unrecognized**: also retries, but gives up after too many + consecutive unrecognized failures in a row. +- Resets the backoff delay once a connection has stayed up long enough to + be considered stable again. +- Logs `ssh` output to a per-profile log file, rotating it once it grows + past 10 MB. +- Exits cleanly and removes its own state on `close` (SIGTERM) or Ctrl-C + in foreground mode (SIGINT). + +--- + +## State on disk + +- Profiles: `$XDG_CONFIG_HOME/porthole/profiles/.toml` +- Runtime state, lock, and log for each open forward: + `$XDG_STATE_HOME/porthole/.{json,lock,log}`. + +State locations can be overridden with the `PORTHOLE_STATE_DIR_OVERRIDE` environment variable. + +--- + +## Shell completions + +```sh +porthole completions bash > /etc/bash_completion.d/porthole +porthole completions zsh > "${fpath[1]}/_porthole" +porthole completions fish > ~/.config/fish/completions/porthole.fish +``` diff --git a/src/atomic.rs b/src/atomic.rs new file mode 100644 index 0000000..708f9bd --- /dev/null +++ b/src/atomic.rs @@ -0,0 +1,25 @@ +//! 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..e0a794c --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,202 @@ +use clap_complete::Shell; +use clap::{ Args, Parser, Subcommand }; + +/// 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. + #[command(visible_alias = "create", visible_alias = "mk")] + 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), + + /// Show detailed status 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), + + /// Export saved profiles to a file, or import them from one. + #[command(visible_alias = "data")] + Transfer(TransferArgs), + + /// Generate a shell completion script. + Completions { shell: Shell }, + + /// Internal: runs the supervisor loop for one profile. Not for direct + /// use; `open` spawns this itself. + #[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 (current machine -> remote machine). + #[arg(short, long, value_name = "[BIND:]PORT:HOST:PORT")] + pub local: Option, + + /// Remote forward (remote machine -> current machine). + #[arg(short, long, value_name = "[BIND:]PORT:HOST:PORT")] + pub remote: Option, + + /// Dynamic forward (SOCKS proxy). + #[arg(short, long, value_name = "[BIND:]PORT")] + pub dynamic: Option, + + /// Jump-host chain, ending at the connection target. + #[arg(long, value_name = "[USER@]HOST[:PORT]", value_delimiter = ',')] + pub via: Vec, + + /// Default user for the target and any hop without one. + #[arg(short, long, value_name = "USER")] + pub user: Option, + + /// Identity file override. + #[arg(short, long, value_name = "PATH")] + pub identity: Option, + + /// SSH port of the final target. + #[arg(short, long, value_name = "PORT")] + pub port: Option, + + /// Auto-reconnect on connection drop. + #[arg(long, num_args = 0..=1, default_missing_value = "true", value_name = "BOOL")] + pub reconnect: Option, + + /// Base delay between reconnection attempts, in seconds. + #[arg(long = "retry-interval", value_name = "SECONDS")] + pub retry_interval: Option, + + /// Cap on the doubling reconnection delay, in seconds. + #[arg(long = "backoff-max", value_name = "SECONDS")] + pub backoff_max: Option, + + /// SSH ServerAliveInterval, in seconds. + #[arg(long, value_name = "SECONDS")] + pub keepalive: Option, +} + +#[derive(Args)] +pub struct AddArgs { + /// Name of 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 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, + + /// Send SIGKILL immediately instead of SIGTERM with a graceful 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, +} + +#[derive(Args)] +pub struct TransferArgs { + /// Restrict export/import to just this profile; every profile in scope if omitted. + pub name: Option, + + /// Export saved profiles to a file. + #[arg(short, long, value_name = "PATH.toml")] + pub export: Option, + + /// Import profiles from a file. + #[arg(short, long, value_name = "PATH.toml")] + pub import: Option, +} 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..fd5cd8d --- /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, 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 { + // 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..20841c6 --- /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)?; + + // 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..92491bd --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,51 @@ +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 transfer; +pub mod wipe; + +use crate::cli::MappingArgs; +use crate::profile::ProfileEdits; + +/// Turns clap's `MappingArgs` into a `ProfileEdits`. `--via` is +/// collected by clap itself: `value_delimiter = ','` splits each +/// occurrence on commas, and the field being a `Vec` allows repeated +/// `--via` flags, so both `--via a,b` and `--via a --via b` reach here as +/// `["a", "b"]`. +pub fn edits_from_mapping(m: &MappingArgs) -> ProfileEdits { + let via = (!m.via.is_empty()).then(|| m.via.clone()); + 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. +pub fn mapping_is_empty(m: &MappingArgs) -> bool { + m.local.is_none() + && m.remote.is_none() + && m.dynamic.is_none() + && m.via.is_empty() + && 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..1cd4ff0 --- /dev/null +++ b/src/commands/open.rs @@ -0,0 +1,131 @@ +//! `open` 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. 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(()); + } + // Clear a stale instance file left by a crash before spawning. The + // lock, not this file, is the authority on "already open"; this just + // keeps `status` from reading stale state mid-spawn. + 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: 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. +/// The instance file's initial write is always +/// `State::Reconnecting`, since the first attempt has not concluded yet; +/// that value is indistinguishable from "already failed once, backing +/// off". This function waits specifically for `Up` or `Error`, not merely +/// for a state other than `Error`, so it does not report success before +/// the first connection attempt has 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..53ac5f4 --- /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. + 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/transfer.rs b/src/commands/transfer.rs new file mode 100644 index 0000000..62cd8ff --- /dev/null +++ b/src/commands/transfer.rs @@ -0,0 +1,133 @@ +//! Bulk profile backup/restore, a flat TOML array of the same `Profile` +//! records `profile::save`/`load` already read and write, so it round-trips +//! through the exact same serialization with nothing profile-specific here. + +use crate::cli::TransferArgs; +use crate::error::{PortholeError, Result}; +use crate::profile::{self, Profile}; +use crate::{atomic, ui}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +#[derive(Default, Serialize, Deserialize)] +struct TransferFile { + #[serde(rename = "profile", default)] + profiles: Vec, +} + +pub fn run(args: TransferArgs) -> Result<()> { + match (&args.export, &args.import) { + (Some(_), Some(_)) => Err(PortholeError::TransferConflictingMode), + (None, None) => Err(PortholeError::TransferNoMode), + (Some(path), None) => export(path, args.name.as_deref()), + (None, Some(path)) => import(path, args.name.as_deref()), + } +} + +fn export(path: &str, name: Option<&str>) -> Result<()> { + let profiles = match name { + Some(n) => { + let n = profile::normalize(n); + vec![profile::load(&n)?] + } + None => profile::list_all()?, + }; + + warn_about_identities(&profiles); + + let count = profiles.len(); + let file = TransferFile { profiles }; + let text = toml::to_string_pretty(&file)?; + atomic::write(Path::new(path), text.as_bytes())?; + + match name { + Some(n) => ui::ok(&format!("Exported profile '{n}' to '{path}'.")), + None => ui::ok(&format!("Exported {count} profile(s) to '{path}'.")), + } + Ok(()) +} + +fn import(path: &str, name: Option<&str>) -> Result<()> { + let text = std::fs::read_to_string(path)?; + let file: TransferFile = toml::from_str(&text)?; + + let selected = match name { + Some(n) => { + let n = profile::normalize(n); + let found = file.profiles.into_iter().find(|p| p.name == n); + vec![found.ok_or(PortholeError::TransferProfileNotFound(n))?] + } + None => file.profiles, + }; + + for p in &selected { + profile::require_valid_name(&p.name)?; + if profile::exists(&p.name) { + return Err(PortholeError::AlreadyExists(p.name.clone())); + } + } + for p in &selected { + profile::save(p)?; + } + + match name { + Some(n) => ui::ok(&format!("Imported profile '{n}' from '{path}'.")), + None => ui::ok(&format!("Imported {} profile(s) from '{path}'.", selected.len())), + } + warn_about_missing_identities(&selected); + Ok(()) +} + +/// Identity files are never included in the export, only the path; warn so +/// that doesn't come as a surprise on the importing end. +fn warn_about_identities(profiles: &[Profile]) { + let names: Vec<&str> = profiles.iter().filter(|p| p.identity.is_some()).map(|p| p.name.as_str()).collect(); + if !names.is_empty() { + ui::warn(&format!( + "identity files are not included in the export ({}) - copy them to the importing machine yourself", + names.join(", ") + )); + } +} + +/// After import, flag any profile whose identity path doesn't resolve on +/// this machine, the most likely sign of a not-yet-copied key file. +fn warn_about_missing_identities(profiles: &[Profile]) { + for p in profiles { + if let Some(identity) = &p.identity { + if !expand_home(identity).is_file() { + ui::warn(&format!( + "'{}': identity file '{identity}' not found on this machine - fix it with \ + 'porthole edit {} -i ' before opening", + p.name, p.name + )); + } + } + } +} + +/// Expands a leading `~/` the same way `ssh` itself does at spawn time +/// (`ssh.rs`); without this, a valid `~/...` identity path would be +/// misreported as missing since `Path::is_file` never expands `~` on its own. +fn expand_home(path: &str) -> PathBuf { + match path.strip_prefix("~/").zip(dirs::home_dir()) { + Some((rest, home)) => home.join(rest), + None => PathBuf::from(path), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn expands_leading_tilde() { + let home = dirs::home_dir().unwrap(); + assert_eq!(expand_home("~/.ssh/id"), home.join(".ssh/id")); + } + + #[test] + fn leaves_absolute_path_untouched() { + assert_eq!(expand_home("/etc/ssh/id"), PathBuf::from("/etc/ssh/id")); + } +} diff --git a/src/commands/wipe.rs b/src/commands/wipe.rs new file mode 100644 index 0000000..2ec0154 --- /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. one +/// orphaned after a crash), matched by cmdline rather than tracked state. +/// Sends SIGTERM to each supervisor's process group so its `ssh` child is +/// included. +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..3bdcf0a --- /dev/null +++ b/src/error.rs @@ -0,0 +1,72 @@ +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:PORT (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 (connection target)")] + NoViaHosts, + + #[error("exactly one of -i/--import, -e/--export is required")] + TransferNoMode, + + #[error("only one of -i/--import, -e/--export may be given")] + TransferConflictingMode, + + #[error("'{0}' not found in the transfer file")] + TransferProfileNotFound(String), + + #[error("nothing to do: {0}")] + NothingToDo(String), + + #[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..695189f --- /dev/null +++ b/src/instance.rs @@ -0,0 +1,170 @@ +//! Runtime state for one open profile. Written only by the +//! supervisor (`src/supervisor.rs`); everything else here just reads it. + +use std::path::PathBuf; +use std::os::unix::io::AsRawFd; +use std::fs::{ File, OpenOptions }; + +use crate::error::Result; +use crate::{ atomic, timefmt }; + +use serde::{ Serialize, Deserialize }; + +#[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"; 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, + } + } +} + +// + + +/// Advisory `flock` held for the supervisor's entire lifetime. +/// The OS releases it the instant the holding process's file descriptors +/// close, including on a crash or SIGKILL, so it needs no stale-lock +/// cleanup and reliably answers "is a supervisor running for this profile." +pub struct Lock { + _file: File, +} + +impl Lock +{ + /// Tries to take the lock non-blocking. `Ok(None)` means another live + /// process already acquired it. + pub fn try_acquire(name: &str) -> Result> + { + let dir = state_dir(); + std::fs::create_dir_all(&dir)?; + + let file = OpenOptions::new().create(true).write(true).open(lock_path(name))?; + let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + + if ret == 0 { Ok(Some(Self { _file: file })) } + else + { + let errno = std::io::Error::last_os_error(); + + if errno.raw_os_error() == Some(libc::EWOULDBLOCK) { Ok(None) } + else { Err(errno.into()) } + } + } +} + +// + +fn state_dir() -> PathBuf +{ + if let Ok(dir) = std::env::var("PORTHOLE_STATE_DIR_OVERRIDE") { return PathBuf::from(dir); }; + + 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; its +/// absence *is* the closed state; there is 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 identifies it as the +/// supervisor for `name`, guards against a stale or reused pid. +pub fn supervisor_alive(pid: i32, name: &str) -> bool +{ + if !process_alive(pid) { return false }; + + let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else { return false }; + + let text = String::from_utf8_lossy(&cmdline); + text.contains("__supervise") && text.contains(name) +} + +/// The live supervisor pid for `name`, if one is currently running +/// (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), + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..0ad60ad --- /dev/null +++ b/src/main.rs @@ -0,0 +1,208 @@ +mod atomic; +mod cli; +mod commands; +mod error; +mod instance; +mod profile; +mod ssh; +mod supervisor; +mod timefmt; +mod ui; + +use clap::{CommandFactory, Parser}; +use cli::{Cli, Commands}; + +fn main() +{ + let args: Vec = std::env::args().collect(); + + // Plain `porthole`, `-h`/`--help`, or `help` at the top level: show + // every subcommand's own flags inline instead of requiring a + // per-subcommand `--help`. + if wants_top_level_help(&args) { + print_full_help(); + std::process::exit(if args.len() <= 1 { 2 } else { 0 }); + } + + let cli = match Cli::try_parse_from(&args) { + Ok(cli) => cli, + Err(e) => e.exit(), + }; + + 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::Transfer(args) => commands::transfer::run(args), + Commands::Completions { shell } => { + commands::completions::run(shell); + Ok(()) + } + // Internal + Commands::Supervise { name } => supervisor::run(&name), + }; + + if let Err(e) = result { + ui::err(&e.to_string()); + std::process::exit(1); + } +} + +/// True for a bare `porthole` invocation, or top-level `-h`/`--help`/`help`, +/// i.e. anything that should show the expanded help rather than being +/// handled (or rejected) by a specific subcommand. +fn wants_top_level_help(args: &[String]) -> bool { + match args.get(1..) { + Some([]) => true, + Some([a]) => a == "-h" || a == "--help" || a == "help", + _ => false, + } +} + +/// Prints one screen of help: one summary line per subcommand (name, +/// positional args, description), followed by an indented line per flag +/// with its own help text. +fn print_full_help() { + let mut cmd = Cli::command(); + cmd.build(); // resolve default value names etc. before introspecting + let bin = cmd.get_name().to_string(); + + if let Some(about) = cmd.get_about() { + println!("{about}"); + println!(); + } + println!("{} {bin} {} {}", ui::blue("Usage:"), ui::cyan(""), ui::yellow("[ARGS]")); + println!(); + + println!("{}", ui::blue("Commands:")); + let subcommands: Vec<&clap::Command> = + cmd.get_subcommands().filter(|s| s.get_name() != "help" && !s.is_hide_set()).collect(); + let flag_rows_by_cmd: Vec> = subcommands.iter().map(|s| flag_rows(s)).collect(); + + let name_w = subcommands.iter().map(|s| s.get_name().len()).max().unwrap_or(0); + let pos_w = subcommands.iter().map(|s| visual_width(&positional_args(s))).max().unwrap_or(0); + // One width across every subcommand's flags, not just its own, so the + // help-text column lines up no matter which command it's under. + let flag_w = flag_rows_by_cmd.iter().flatten().map(|(f, _)| visual_width(f)).max().unwrap_or(0); + + for (i, s) in subcommands.iter().enumerate() { + let name = s.get_name(); + let positionals = positional_args(s); + let about = s.get_about().map(|a| a.to_string()).unwrap_or_default(); + + println!(" {name:name_w$} {} {about}", pad_visual(&positionals, pos_w)); + + let rows = &flag_rows_by_cmd[i]; + for (flag, help) in rows { + println!(" {} {help}", pad_visual(flag, flag_w)); + } + // Flagless commands stay a tight single line; only the multi-line + // (flag-bearing) ones get a blank line to separate them visually. + if !rows.is_empty() && i + 1 < subcommands.len() { + println!(); + } + } + println!(); + + let alias_rows: Vec<(String, String)> = subcommands + .iter() + .filter_map(|s| { + let aliases = s.get_visible_aliases().collect::>().join(", "); + (!aliases.is_empty()).then(|| (s.get_name().to_string(), aliases)) + }) + .collect(); + if !alias_rows.is_empty() { + println!("{}", ui::blue("Aliases:")); + let name_w = alias_rows.iter().map(|(n, _)| n.len()).max().unwrap_or(0); + for (name, aliases) in &alias_rows { + println!(" {name:name_w$} {aliases}"); + } + println!(); + } + + println!("{}", ui::blue("Options:")); + let opt_rows: Vec<(String, String)> = cmd + .get_arguments() + .filter(|a| !a.is_positional()) + .filter_map(|a| Some((flag_names(a)?, a.get_help().map(|h| h.to_string()).unwrap_or_default()))) + .collect(); + let flag_w = opt_rows.iter().map(|(f, _)| f.len()).max().unwrap_or(0); + for (flag, help) in &opt_rows { + println!(" {flag:flag_w$} {help}"); + } +} + +/// `` for each required positional arg of `cmd`, `[name]` for an +/// optional one, space-joined and colored cyan. +fn positional_args(cmd: &clap::Command) -> String { + cmd.get_positionals() + .map(|a| { + let id = a.get_id().as_str(); + let text = if a.is_required_set() { format!("<{id}>") } else { format!("[{id}]") }; + ui::cyan(&text) + }) + .collect::>() + .join(" ") +} + +/// `(flag display, help text)` for each of `cmd`'s non-positional, non-help +/// args, e.g. `("-l/--local <[BIND:]PORT:HOST:PORT>", "Local forward: your +/// machine -> remote")`. The flag display is colored yellow. +fn flag_rows(cmd: &clap::Command) -> Vec<(String, String)> { + cmd.get_arguments() + .filter(|a| !a.is_positional() && a.get_id().as_str() != "help") + .filter_map(|a| { + let flag = flag_names(a)?; + let display = if matches!(a.get_action(), clap::ArgAction::Set | clap::ArgAction::Append) { + let value = a + .get_value_names() + .and_then(|v| v.first()) + .map(|v| v.to_string()) + .unwrap_or_else(|| a.get_id().as_str().to_uppercase()); + format!("{flag} <{value}>") + } else { + flag + }; + let help = a.get_help().map(|h| h.to_string()).unwrap_or_default(); + Some((ui::yellow(&display), help)) + }) + .collect() +} + +/// `-x/--long` / `-x` / `--long` for a non-positional arg, or `None` for +/// one with no visible flag at all. +fn flag_names(arg: &clap::Arg) -> Option { + match (arg.get_short(), arg.get_long()) { + (Some(s), Some(l)) => Some(format!("-{s}/--{l}")), + (Some(s), None) => Some(format!("-{s}")), + (None, Some(l)) => Some(format!("--{l}")), + (None, None) => None, + } +} + +/// Number of visible columns in `s`, skipping any `\x1b[...m` ANSI SGR +/// escape sequences it contains. +fn visual_width(s: &str) -> usize { + let mut width = 0; + let mut in_escape = false; + for c in s.chars() { + if in_escape { + in_escape = c != 'm'; + } else if c == '\x1b' { + in_escape = true; + } else { + width += 1; + } + } + width +} + +/// Right-pads `s` with spaces to `width` visible columns, per `visual_width`. +fn pad_visual(s: &str, width: usize) -> String { + format!("{s}{}", " ".repeat(width.saturating_sub(visual_width(s)))) +} diff --git a/src/profile.rs b/src/profile.rs new file mode 100644 index 0000000..4406242 --- /dev/null +++ b/src/profile.rs @@ -0,0 +1,372 @@ +//! Persisted forward definitions: One TOML file per profile at +//! `~/.config/porthole/profiles/.toml`. + +use std::path::PathBuf; + +use crate::{ atomic, timefmt }; +use crate::error::{ Result, PortholeError }; + +use serde::{ Serialize, Deserialize }; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Kind { + 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. + pub mapping: String, + /// Ordered hop list, each `[user@]host[:port]`. + #[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, +} + +impl Profile +{ + /// Builds a brand-new profile from `add`'s flags. + //noinspection RsFieldInitShorthand + 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: name, + kind: kind, + mapping: mapping.to_string(), + via: via, + user: edits.user.clone(), + identity: edits.identity.clone(), + ssh_port: edits.port.unwrap_or_else(default_ssh_port), + reconnect: edits.reconnect.unwrap_or_else(default_true), + retry_interval: edits.retry_interval.unwrap_or_else(default_retry_interval), + backoff_max: edits.backoff_max.unwrap_or_else(default_backoff_max), + keepalive: edits.keepalive.unwrap_or_else(default_keepalive), + created_at: now, + updated_at: now, + }) + } + + /// Applies `edits` on top of an existing profile (`edit`'s semantics: + /// only provided fields change). + pub fn apply_edits(&mut self, edits: &ProfileEdits) -> Result<()> + { + if let Some((kind, mapping)) = edits.mapping_kind()? + { + validate_mapping(kind, mapping)?; + + self.kind = kind; + self.mapping = mapping.to_string(); + } + + if let Some(via) = &edits.via + { + if via.is_empty() { return Err(PortholeError::NoViaHosts); } + + for hop in via { validate_via_hop(hop)?; } + self.via = via.clone(); + } + + if let Some(user) = &edits.user { self.user = Some(user.clone()); } + if let Some(identity) = &edits.identity { self.identity = Some(identity.clone()); } + if let Some(port) = edits.port { self.ssh_port = port; } + if let Some(reconnect) = edits.reconnect { self.reconnect = reconnect; } + if let Some(v) = edits.retry_interval { self.retry_interval = v; } + if let Some(v) = edits.backoff_max { self.backoff_max = v; } + if let Some(v) = edits.keepalive { self.keepalive = v; } + self.updated_at = timefmt::now(); + + Ok(()) + } + + /// Splits `via` into the `-J` jump-chain value (comma-joined, all but + /// the last hop; `None` for a single-hop `via`) and the final `ssh` + /// connection target. Every path that constructs a `Profile` validates + /// `via` as non-empty. + pub fn ssh_target(&self) -> (Option, &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, ""), + } + } +} + +// + +/// 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 default_ssh_port() -> u16 { 22 } +fn default_true() -> bool { true } +fn default_retry_interval() -> u32 { 5 } +fn default_backoff_max() -> u32 { 60 } +fn default_keepalive() -> u32 { 15 } + +fn valid_name(name: &str) -> bool +{ + 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(()) +} + +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..f055ac6 --- /dev/null +++ b/src/ssh.rs @@ -0,0 +1,210 @@ +//! Builds the `ssh` invocation for a profile. + +use std::path::PathBuf; +use std::fmt::Write as _; +use std::process::{ Stdio, Command }; + +use crate::profile::Profile; + +/// A throwaway `ssh_config` applying the same hardening (and identity, if +/// any) to every host, so `-J`'s inner proxy connection picks it up too +/// instead of falling back to default identities and prompting for +/// host-key confirmation on a `/dev/null`-less stdin. Deleted on drop, so +/// callers just need to keep this alive for as long as the `ssh` process +/// that reads it runs. +pub struct JumpConfig(PathBuf); + +impl JumpConfig +{ + /// Named after the profile and this process's pid rather than + /// something unique per call, so a long-lived supervisor's repeated + /// reconnect attempts overwrite the same file instead of littering a + /// new one on every retry. + fn write(profile: &Profile) -> Option + { + let mut body = String::from("Host *\n"); + for (key, value) in forced_options(profile) { writeln!(body, "\t{key} {value}").ok()?; } + if let Some(identity) = &profile.identity + { + writeln!(body, "\tIdentityFile {identity}").ok()?; + writeln!(body, "\tIdentitiesOnly yes").ok()?; + } + + let path = std::env::temp_dir().join(format!("porthole-{}-{}.sshconfig", profile.name, std::process::id())); + std::fs::write(&path, body).ok()?; + Some(Self(path)) + } + + fn arg(&self) -> &str { self.0.to_str().unwrap_or("/dev/null") } +} + +impl Drop for JumpConfig +{ + fn drop(&mut self) { let _ = std::fs::remove_file(&self.0); } +} + + +// + +/// The `-o` options forced on every invocation, not user-configurable. +/// Shared by `build()` (as command-line `-o key=value` args) and +/// `JumpConfig::write` (as `ssh_config` lines), so the two representations +/// of "what's forced" can't drift apart. +fn forced_options(profile: &Profile) -> [(&'static str, String); 9] +{ + [ + ("BatchMode", "yes".into()), + ("StrictHostKeyChecking", "accept-new".into()), + ("LogLevel", "ERROR".into()), + ("ExitOnForwardFailure", "yes".into()), + ("ConnectTimeout", "10".into()), + ("ServerAliveCountMax", "3".into()), + ("ControlMaster", "no".into()), + ("ControlPath", "none".into()), + ("ServerAliveInterval", profile.keepalive.to_string()), + ] +} + +/// Builds the `ssh` command for `profile`, stdio wired for the supervisor +/// to capture (stdout/stderr piped so failure text can be classified; +/// stdin from `/dev/null` since porthole never wants a shell). The second +/// return value, when present, must outlive the spawned process: it owns +/// the config file `-F` points at and deletes it on drop. +pub fn build(profile: &Profile) -> (Command, Option) +{ + let mut cmd = Command::new("ssh"); + cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); + + let (jumps, target) = profile.ssh_target(); + + let jump_config = jumps.is_some().then(|| JumpConfig::write(profile)).flatten(); + let config_arg = jump_config.as_ref().map_or("/dev/null", |c| c.arg()); + + cmd.args(["-F", config_arg]); + for (key, value) in forced_options(profile) { cmd.args(["-o", &format!("{key}={value}")]); } + cmd.args(["-N", "-T"]); + + if let Some(jumps) = jumps { cmd.args(["-J", &jumps]); } + + cmd.arg("-p").arg(profile.ssh_port.to_string()); + if let Some(identity) = &profile.identity + { + cmd.arg("-i").arg(identity); + cmd.args(["-o", "IdentitiesOnly=yes"]); + } + if let Some(user) = &profile.user { cmd.arg("-l").arg(user); } + + cmd.arg(profile.kind.ssh_flag()).arg(&profile.mapping); + cmd.arg(target); + + (cmd, jump_config) +} + +// + +#[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 ignores_the_callers_own_ssh_config() { + let (cmd, _guard) = build(&profile_with(vec!["jumpbox"])); + let args: Vec = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect(); + assert!(args.windows(2).any(|w| w == ["-F", "/dev/null"])); + } + + #[test] + fn single_hop_has_no_dash_j() { + let (cmd, _guard) = build(&profile_with(vec!["jumpbox"])); + let 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_writes_a_config_carrying_hardening_and_identity_to_every_hop() { + let p = Profile::new( + "t2".into(), + &ProfileEdits { + local: Some("5432:db.internal:5432".into()), + via: Some(vec!["bastion1".into(), "bastion2".into()]), + identity: Some("/home/me/.ssh/id_ed25519".into()), + ..Default::default() + }, + ) + .unwrap(); + let (cmd, guard) = build(&p); + let args: Vec = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect(); + let f_idx = args.iter().position(|a| a == "-F").expect("-F present"); + let config_path = &args[f_idx + 1]; + assert_ne!(config_path, "/dev/null"); + let contents = std::fs::read_to_string(config_path).expect("config file should exist"); + assert!(contents.contains("BatchMode yes")); + assert!(contents.contains("StrictHostKeyChecking accept-new")); + assert!(contents.contains("IdentityFile /home/me/.ssh/id_ed25519")); + assert!(contents.contains("IdentitiesOnly yes")); + drop(guard); + assert!(!std::path::Path::new(config_path).exists()); + } + + #[test] + fn multi_hop_splits_jumps_from_target() { + let (cmd, _guard) = build(&profile_with(vec!["bastion1", "bastion2:2222"])); + let 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, _guard) = 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"); + } + + #[test] + fn identities_only_set_when_identity_given() { + let p = Profile::new( + "t".into(), + &ProfileEdits { + local: Some("5432:db.internal:5432".into()), + via: Some(vec!["jumpbox".into()]), + identity: Some("/home/me/.ssh/id_ed25519".into()), + ..Default::default() + }, + ) + .unwrap(); + let (cmd, _guard) = build(&p); + let args: Vec = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect(); + let i_idx = args.iter().position(|a| a == "-i").expect("-i present"); + assert_eq!(args[i_idx + 1], "/home/me/.ssh/id_ed25519"); + assert!(args.windows(2).any(|w| w == ["-o", "IdentitiesOnly=yes"])); + } + + #[test] + fn identities_only_absent_without_identity() { + let p = profile_with(vec!["jumpbox"]); + assert!(p.identity.is_none()); + let (cmd, _guard) = build(&p); + let args: Vec = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect(); + assert!(!args.windows(2).any(|w| w == ["-o", "IdentitiesOnly=yes"])); + } +} diff --git a/src/supervisor.rs b/src/supervisor.rs new file mode 100644 index 0000000..941e370 --- /dev/null +++ b/src/supervisor.rs @@ -0,0 +1,311 @@ +//! The `__supervise` loop 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 std::fs::OpenOptions; +use std::sync::{ Arc, Mutex }; +use std::thread::JoinHandle; +use std::time::{ Instant, Duration }; +use std::io::{ Write, BufRead, BufReader }; +use std::sync::atomic::{ Ordering, AtomicBool }; + +use crate::error::Result; +use crate::{ ssh, timefmt }; +use crate::profile::{ self, Profile }; +use crate::instance::{ self, Lock, State, Instance }; + +/// How long a connection must survive before its uptime resets the backoff +/// counter back to the base delay. +const STABLE_THRESHOLD_SECS: i64 = 60; +/// Consecutive unrecognized (not pattern-matched) failures before porthole +/// gives up on an apparently-permanently-broken profile. +const MAX_UNRECOGNIZED_STREAK: u32 = 10; +/// How long `ssh` must stay alive before porthole treats it as connected; +/// see `run_ssh_once` for the heuristic this backs. +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 into a flag instead of the default +/// terminate-immediately behavior. This is how `close` (SIGTERM) and +/// `-f/--foreground`'s Ctrl-C (SIGINT) are distinguished from a +/// dropped `ssh` connection: by which signal arrived, not by inferring +/// intent from `ssh`'s exit status. In foreground mode this function runs +/// in the process the terminal sends Ctrl-C to directly, since +/// `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 `. This function is the +/// supervisor process: it runs until told to stop (SIGTERM/SIGINT) or +/// gives up. +pub fn run(name: &str) -> Result<()> +{ + install_signal_handler(); + + let profile = profile::load(name)?; + + // Holding this for our entire lifetime is what makes + // for a reliable, race-free check for `open`. + let Some(_lock) = Lock::try_acquire(name)? else { + return Ok(()); // another supervisor; 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: u64 = 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` does not report "the forward is bound" directly +/// without parsing `-v` debug output; a real failure exits near-instantly +/// under `ExitOnForwardFailure=yes`, so staying alive past the grace +/// window is used as a proxy for connected. +fn run_ssh_once(name: &str, profile: &Profile, inst: &mut Instance) -> Outcome +{ + rotate_log_if_large(name); + + // `_jump_config`, when present, must stay alive for this whole + // function: it owns the config file `-F` points ssh at, and every + // return path below runs the ssh process to completion first. + let (mut cmd, _jump_config) = ssh::build(profile); + let mut 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. 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..f654d2b --- /dev/null +++ b/src/timefmt.rs @@ -0,0 +1,73 @@ +//! Minimal UTC timestamp/duration formatting, without a `chrono`/`time` +//! dependency. 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); + + match (d, h, m) { + (d, _, _) if d > 0 => format!("{d}d {h:02}h {m:02}m {s:02}s"), + (_, h, _) if h > 0 => format!("{h}h {m:02}m {s:02}s"), + (_, _, m) if m > 0 => format!("{m}m {s:02}s"), + _ => 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..16af074 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,32 @@ +//! Colorized status output, honouring `NO_COLOR` and terminal detection. + +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 cyan(s: &str) -> String { paint("36", 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)); } diff --git a/tests/live_test.sh b/tests/live_test.sh new file mode 100755 index 0000000..45de1c0 --- /dev/null +++ b/tests/live_test.sh @@ -0,0 +1,504 @@ +#!/usr/bin/env bash +# Live integration test suite for porthole. +# +# Exercises every command against a REAL SSH server: real tunnels, real +# auth (success and failure), a real two-hop ProxyJump, a real SOCKS proxy +# carrying real traffic. Deliberately NOT part of `cargo test` (same reason +# as vmic's tests/live_test.sh: this needs a real remote server, not a CI +# sandbox), live-only, opt-in, run by hand. +# +# Usage: +# tests/live_test.sh [options] +# +# Options (env var or flag; flag wins if both given): +# --bin PATH PORTHOLE_TEST_BIN porthole binary to test (default: target/debug/porthole) +# --host HOST PORTHOLE_TEST_HOST test server hostname (default: vpn.security-command.org) +# --user USER PORTHOLE_TEST_USER test server login user (default: overlord) +# --identity PATH PORTHOLE_TEST_IDENTITY identity file (default: ~/.ssh/id_ed25519_vpn) +# --name PREFIX PORTHOLE_TEST_NAME profile name prefix (default: porttestsuite) +# --skip-network PORTHOLE_TEST_SKIP_NETWORK=1 skip the SOCKS/icanhazip.com phase +# --skip-wipe PORTHOLE_TEST_SKIP_WIPE=1 skip the destructive `wipe` phase +# --no-build skip the `cargo build` preflight +# -h, --help +# +# Known limitation: only one real server is available, so multi-hop (`-J`) +# is tested by chaining the server through itself (--via user@host,user@host), +# a real two-hop ProxyJump handshake, just with both hops the same box. +# There is no way to test a genuine distinct-host chain without a second +# server. +# +# Safety: +# - Profiles/instances/locks/logs are sandboxed for the whole run under +# one `PORTHOLE_STATE_DIR_OVERRIDE` temp dir (profile.rs/instance.rs +# both honor it); this suite NEVER touches the real +# ~/.config/porthole or ~/.local/state/porthole. +# - Every profile created is named "$NAME_..." (default prefix +# porttestsuite); no operation targets anything outside that prefix. +# - `wipe` (src/commands/wipe.rs) kills ANY process on the +# whole system whose cmdline contains "__supervise", regardless of +# which state dir it belongs to; it is NOT scoped by the sandboxing +# above. Before running it, this script scans the real process table +# and skips the wipe phase entirely (not "wipe only the safe parts") +# if it finds a live __supervise process that isn't one of this run's +# own test profiles, so a real tunnel you have open elsewhere is never +# killed as a side effect of running this suite. +# - Never runs a bare ssh with interactive prompting: every verification +# ssh call this script itself makes uses BatchMode=yes plus a +# throwaway UserKnownHostsFile=/dev/null, so it never prompts and +# never writes to your real ~/.ssh/known_hosts (only porthole's own +# spawned ssh does, against the real file, using the accept-new policy +# already forced in src/ssh.rs). +# - An EXIT trap always attempts full cleanup, closes/removes every +# test-prefixed profile in every sandbox dir used, force-kills any +# stray matching __supervise process, deletes the throwaway bad-auth +# key, even on failure or Ctrl-C. + +set -uo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +BIN="${PORTHOLE_TEST_BIN:-target/debug/porthole}" +HOST="${PORTHOLE_TEST_HOST:-vpn.security-command.org}" +USER_="${PORTHOLE_TEST_USER:-overlord}" +IDENTITY="${PORTHOLE_TEST_IDENTITY:-$HOME/.ssh/id_ed25519_vpn}" +NAME="${PORTHOLE_TEST_NAME:-porttestsuite}" +SKIP_NETWORK="${PORTHOLE_TEST_SKIP_NETWORK:-0}" +SKIP_WIPE="${PORTHOLE_TEST_SKIP_WIPE:-0}" +DO_BUILD=1 + +usage() { sed -n '2,/^set -uo/p' "$0" | sed '$d; s/^# \{0,1\}//'; } + +while [[ $# -gt 0 ]]; do + case "$1" in + --bin) BIN="$2"; shift 2 ;; + --host) HOST="$2"; shift 2 ;; + --user) USER_="$2"; shift 2 ;; + --identity) IDENTITY="$2"; shift 2 ;; + --name) NAME="$2"; shift 2 ;; + --skip-network) SKIP_NETWORK=1; shift ;; + --skip-wipe) SKIP_WIPE=1; shift ;; + --no-build) DO_BUILD=0; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done +NAME="$(tr '[:upper:]' '[:lower:]' <<<"$NAME")" + +RED=$'\e[31m'; GREEN=$'\e[32m'; YELLOW=$'\e[33m'; BLUE=$'\e[34m'; RESET=$'\e[0m' +[[ -t 1 ]] || { RED=""; GREEN=""; YELLOW=""; BLUE=""; RESET=""; } + +PASS=0; FAIL=0; SKIP=0 +section() { echo; echo "${BLUE}== $1 ==${RESET}"; } +pass() { PASS=$((PASS+1)); echo " ${GREEN}PASS${RESET} $1"; } +fail() { FAIL=$((FAIL+1)); echo " ${RED}FAIL${RESET} $1"; [[ -n "${2:-}" ]] && echo " ${2//$'\n'/$'\n '}"; } +skip() { SKIP=$((SKIP+1)); echo " ${YELLOW}SKIP${RESET} $1"; } + +LAST_OUT=""; LAST_CODE=0 +porthole_run() { LAST_OUT="$("$BIN" "$@" 2>&1)"; LAST_CODE=$?; } + +expect_exit() { # expect_exit + if [[ "$LAST_CODE" == "$2" ]]; then pass "$1 (exit $LAST_CODE)" + else fail "$1 (expected exit $2, got $LAST_CODE)" "$LAST_OUT"; fi +} +expect_contains() { # expect_contains + if [[ "$LAST_OUT" == *"$2"* ]]; then pass "$1" + else fail "$1 (expected output to contain: $2)" "$LAST_OUT"; fi +} +expect_not_contains() { + if [[ "$LAST_OUT" != *"$2"* ]]; then pass "$1" + else fail "$1 (expected output NOT to contain: $2)" "$LAST_OUT"; fi +} +assert_eq() { if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1" "expected '$3', got '$2'"; fi; } +assert_true() { if "${@:2}" >/dev/null 2>&1; then pass "$1"; else fail "$1"; fi; } + +# Every use below passes an explicit PORTHOLE_STATE_DIR_OVERRIDE, so this +# never touches ~/.config/porthole or ~/.local/state/porthole. +p() { PORTHOLE_STATE_DIR_OVERRIDE="$1" "$BIN" "${@:2}"; } +p_run() { LAST_OUT="$(PORTHOLE_STATE_DIR_OVERRIDE="$1" "$BIN" "${@:2}" 2>&1)"; LAST_CODE=$?; } + +# status --json's "state" field is always one of closed/up/reconnecting/error +# (print_json normalizes a dead-supervisor instance file to "error" too, see +# status.rs); polling that key is far more robust than scraping the padded +# human-readable field. +json_field() { # json_field -> value, empty if absent/no instance + local out v + out="$(p "$1" status "$2" --json 2>/dev/null)" + # Quoted string value first: the closing quote is mandatory here (unlike + # a bare [^",]* class) so a value with an embedded comma - a real ssh + # "Permission denied (publickey,password)." error has one - isn't + # truncated at the first comma instead of its actual end. + v="$(sed -n "s/.*\"$3\": \"\\(.*\\)\",\\{0,1\\}\$/\\1/p" <<<"$out" | head -1)" + if [[ -n "$v" ]]; then echo "$v"; return; fi + # Bare (unquoted) value: number, bool, or null. + sed -n "s/.*\"$3\": \\([^\",]*\\),\\{0,1\\}\$/\\1/p" <<<"$out" | head -1 +} +wait_for_state() { # wait_for_state [tries, x0.5s] + local dir="$1" name="$2" want="$3" tries="${4:-20}" + for _ in $(seq 1 "$tries"); do + [[ "$(json_field "$dir" "$name" state)" == "$want" ]] && return 0 + sleep 0.5 + done + return 1 +} +port_open() { timeout 1 bash -c "exec 3<>/dev/tcp/127.0.0.1/$1" 2>/dev/null; } # port_open +wait_port_closed() { # wait_port_closed [tries, x0.5s] + for _ in $(seq 1 "${2:-10}"); do + port_open "$1" || return 0 + sleep 0.5 + done + return 1 +} +SSH_PROBE_OPTS=(-o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null) + +# mktemp's output is captured via $(...), which forks a subshell; any +# array append done *inside* a function called that way would be lost when +# the subshell exits, so state dirs are appended here at the call site +# instead of through a helper function. +STATE_DIRS=() +STATE_DIR="$(mktemp -d)"; STATE_DIRS+=("$STATE_DIR") +BADKEY="$(mktemp -u)" + +cleanup() { + section "Cleanup" + for d in "${STATE_DIRS[@]:-}"; do + [[ -z "$d" ]] && continue + for prof in $(p "$d" list --json 2>/dev/null | sed -n 's/.*"name": "\([^"]*\)".*/\1/p'); do + p "$d" close --force "$prof" >/dev/null 2>&1 || true + p "$d" remove "$prof" >/dev/null 2>&1 || true + done + rm -rf "$d" + done + pkill -f "__supervise ${NAME}_" 2>/dev/null || true + rm -f "$BADKEY" "$BADKEY.pub" 2>/dev/null || true + echo " done." + echo + echo "${BLUE}== Results ==${RESET} ${GREEN}$PASS passed${RESET}, ${RED}$FAIL failed${RESET}, ${YELLOW}$SKIP skipped${RESET}" + [[ "$FAIL" -eq 0 ]] +} +trap 'cleanup; exit $(( $? ))' EXIT + +echo "porthole: $BIN" +echo "server: $USER_@$HOST (identity: $IDENTITY)" +echo "test name: $NAME (+ suffixes) in $STATE_DIR" + +if [[ "$DO_BUILD" == "1" ]]; then + section "Build" + if cargo build 2>&1 | tee /dev/stderr | grep -q '^error'; then + echo "build failed, aborting." >&2; exit 1 + fi +fi +[[ -x "$BIN" ]] || { echo "binary not found/executable: $BIN" >&2; exit 1; } +[[ -f "$IDENTITY" ]] || { echo "identity file not found: $IDENTITY" >&2; exit 1; } + +# --------------------------------------------------------------------------- +section "Phase 1: CLI surface" +# --------------------------------------------------------------------------- +porthole_run; expect_exit "bare 'porthole' shows help" 2 +expect_contains "bare 'porthole' mentions Usage" "Usage:" +porthole_run -h; expect_exit "'porthole -h'" 0 +porthole_run help; expect_exit "'porthole help'" 0 +porthole_run --version; expect_exit "'porthole --version'" 0 +expect_contains "'--version' mentions porthole" "porthole" +for cmd in add open close edit status list remove wipe transfer; do + porthole_run "$cmd" --help; expect_exit "'porthole $cmd --help'" 0 +done +for shell in bash zsh fish; do + porthole_run completions "$shell" + assert_eq "'porthole completions $shell' exits 0" "$LAST_CODE" "0" + [[ -n "$LAST_OUT" ]] && pass "'porthole completions $shell' produces output" || fail "'porthole completions $shell' produces output" "(empty)" +done + +porthole_run --help +expect_contains "value-name shows real mapping grammar" "<[BIND:]PORT:HOST:PORT>" +expect_contains "value-name shows PATH.toml for transfer" "" +expect_contains "required positional renders as " "add " +expect_contains "optional positional renders as [name]" "open [name]" +expect_contains "optional positional renders as [name] (transfer)" "transfer [name]" + +# --------------------------------------------------------------------------- +section "Phase 2: error paths (pre-creation)" +# --------------------------------------------------------------------------- +p_run "$STATE_DIR" add; expect_exit "'add' with no name fails" 2 +p_run "$STATE_DIR" add "bad name!"; assert_eq "'add' with an invalid name exits 1" "$LAST_CODE" "1" +expect_contains "invalid name error message" "invalid name" +p_run "$STATE_DIR" add "${NAME}_x"; assert_eq "'add' with no mapping kind exits 1" "$LAST_CODE" "1" +expect_contains "no-mapping-kind error message" "exactly one of -l/--local" +p_run "$STATE_DIR" add "${NAME}_x" -l 1:h:1 -r 2:h:2; assert_eq "'add' with conflicting mapping kinds exits 1" "$LAST_CODE" "1" +expect_contains "conflicting-mapping error message" "only one of -l/--local" +p_run "$STATE_DIR" add "${NAME}_x" -l 1:h:1; assert_eq "'add' with no --via exits 1" "$LAST_CODE" "1" +expect_contains "no-via error message" "--via is required" +p_run "$STATE_DIR" status "${NAME}_nope"; assert_eq "'status' on nonexistent profile exits 1" "$LAST_CODE" "1" +expect_contains "nonexistent-profile error (status)" "no profile named" +p_run "$STATE_DIR" close "${NAME}_nope"; assert_eq "'close' on nonexistent profile exits 1" "$LAST_CODE" "1" +p_run "$STATE_DIR" edit "${NAME}_nope" -l 1:h:1; assert_eq "'edit' on nonexistent profile exits 1" "$LAST_CODE" "1" +p_run "$STATE_DIR" remove "${NAME}_nope"; assert_eq "'remove' on nonexistent profile exits 1" "$LAST_CODE" "1" +p_run "$STATE_DIR" transfer; assert_eq "'transfer' with no mode exits 1" "$LAST_CODE" "1" +expect_contains "transfer no-mode error message" "exactly one of -i/--import" +p_run "$STATE_DIR" transfer -e /tmp/x.toml -i /tmp/x.toml; assert_eq "'transfer' with both modes exits 1" "$LAST_CODE" "1" +expect_contains "transfer conflicting-mode error message" "only one of -i/--import" + +# --------------------------------------------------------------------------- +section "Phase 3: local forward (-l), single hop - the baseline path" +# --------------------------------------------------------------------------- +LOCAL_PORT=28221 +p_run "$STATE_DIR" add "${NAME}_local" -l "$LOCAL_PORT:localhost:22" -i "$IDENTITY" --via "$USER_@$HOST" +expect_exit "'add ${NAME}_local'" 0 +p_run "$STATE_DIR" status "${NAME}_local"; expect_contains "fresh profile is closed" "closed" + +p_run "$STATE_DIR" open "${NAME}_local"; expect_exit "'open ${NAME}_local'" 0 +assert_true "'${NAME}_local' reaches state: up" wait_for_state "$STATE_DIR" "${NAME}_local" up 10 + +if ssh "${SSH_PROBE_OPTS[@]}" -i "$IDENTITY" -o IdentitiesOnly=yes -l "$USER_" -p "$LOCAL_PORT" localhost true 2>/tmp/porthole_test_probe.$$; then + pass "forwarded port $LOCAL_PORT actually round-trips to the real sshd" +else + fail "forwarded port $LOCAL_PORT actually round-trips to the real sshd" "$(cat /tmp/porthole_test_probe.$$ 2>/dev/null)" +fi +rm -f "/tmp/porthole_test_probe.$$" 2>/dev/null + +p_run "$STATE_DIR" close "${NAME}_local"; expect_exit "'close ${NAME}_local'" 0 +assert_true "port $LOCAL_PORT stops listening after close" wait_port_closed "$LOCAL_PORT" +p_run "$STATE_DIR" close "${NAME}_local"; expect_exit "re-'close' on an already-closed profile still exits 0" 0 +expect_contains "idempotent-close message" "is not open" + +# --------------------------------------------------------------------------- +section "Phase 4: remote forward (-r) - binds on the server, dials back out locally" +# --------------------------------------------------------------------------- +REMOTE_PORT=28225 +p_run "$STATE_DIR" add "${NAME}_remote" -r "$REMOTE_PORT:$HOST:22" -i "$IDENTITY" --via "$USER_@$HOST" +expect_exit "'add ${NAME}_remote'" 0 +p_run "$STATE_DIR" open "${NAME}_remote"; expect_exit "'open ${NAME}_remote'" 0 +assert_true "'${NAME}_remote' reaches state: up" wait_for_state "$STATE_DIR" "${NAME}_remote" up 10 + +# The inner ssh below runs on the remote box itself (reached through the +# outer session), then loops back out through the -R forward to a real +# sshd on this same server - $IDENTITY is a local path and won't exist +# there, so it has no credentials for that final hop. Reaching the real +# sshd and being rejected already proves the forward round-trips; a +# refused/timed-out connection is what would indicate it's actually broken. +remote_check="$(ssh "${SSH_PROBE_OPTS[@]}" -i "$IDENTITY" -o IdentitiesOnly=yes "$USER_@$HOST" \ + "ssh -p $REMOTE_PORT -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null localhost true; echo EXIT:\$?" 2>&1)" +if [[ "$remote_check" == *"EXIT:0"* || "$remote_check" == *"Permission denied"* ]]; then + pass "remote-bound port $REMOTE_PORT round-trips back out through the tunnel" +else + fail "remote-bound port $REMOTE_PORT round-trips back out through the tunnel" "$remote_check" +fi + +p_run "$STATE_DIR" close "${NAME}_remote"; expect_exit "'close ${NAME}_remote'" 0 + +# --------------------------------------------------------------------------- +section "Phase 5: dynamic forward (-d, SOCKS) - proves traffic actually transits" +# --------------------------------------------------------------------------- +if [[ "$SKIP_NETWORK" == "1" ]]; then + skip "SOCKS traffic-routing check (--skip-network passed)" +elif ! command -v curl >/dev/null; then + skip "SOCKS traffic-routing check (curl not installed)" +else + SOCKS_PORT=28226 + p_run "$STATE_DIR" add "${NAME}_dynamic" -d "$SOCKS_PORT" -i "$IDENTITY" --via "$USER_@$HOST" + expect_exit "'add ${NAME}_dynamic'" 0 + p_run "$STATE_DIR" open "${NAME}_dynamic"; expect_exit "'open ${NAME}_dynamic'" 0 + assert_true "'${NAME}_dynamic' reaches state: up" wait_for_state "$STATE_DIR" "${NAME}_dynamic" up 10 + + direct_ip="$(curl -s --max-time 8 https://icanhazip.com | tr -d '[:space:]')" + proxied_ip="$(curl -s --max-time 8 -x "socks5h://localhost:$SOCKS_PORT" https://icanhazip.com | tr -d '[:space:]')" + if [[ -z "$direct_ip" || -z "$proxied_ip" ]]; then + skip "SOCKS traffic-routing check (icanhazip.com unreachable right now)" + elif [[ "$proxied_ip" != "$direct_ip" ]]; then + pass "SOCKS proxy traffic exits via the remote server ($proxied_ip != local $direct_ip)" + else + fail "SOCKS proxy traffic exits via the remote server" "proxied IP ($proxied_ip) matched direct IP - traffic didn't actually route through the tunnel" + fi + p_run "$STATE_DIR" close "${NAME}_dynamic"; expect_exit "'close ${NAME}_dynamic'" 0 +fi + +# --------------------------------------------------------------------------- +section "Phase 6: multi-hop --via (self-jump - see header comment for why)" +# --------------------------------------------------------------------------- +# A real ssh refuses a ProxyJump hop that's textually identical to the +# final target ("jumphost loop via ..."), so USER@HOST,USER@HOST can never +# work no matter how porthole builds the command - it's rejected before a +# connection is even attempted. Using the server's IP for the first hop and +# its hostname for the final target is the same physical box (still a real +# 2-hop handshake) but sidesteps that string-identity check. +HOP_PORT=28223 +HOST_IP="$(getent ahostsv4 "$HOST" 2>/dev/null | awk '{print $1; exit}')" +if [[ -z "$HOST_IP" ]]; then + skip "multi-hop phase (could not resolve $HOST to an IP for the loop workaround)" +else + p_run "$STATE_DIR" add "${NAME}_multihop" -l "$HOP_PORT:localhost:22" -i "$IDENTITY" --via "$USER_@$HOST_IP,$USER_@$HOST" + expect_exit "'add ${NAME}_multihop' with a 2-hop --via" 0 + p_run "$STATE_DIR" open "${NAME}_multihop"; expect_exit "'open ${NAME}_multihop'" 0 + assert_true "'${NAME}_multihop' reaches state: up (real -J handshake, twice)" wait_for_state "$STATE_DIR" "${NAME}_multihop" up 30 + + if ssh "${SSH_PROBE_OPTS[@]}" -i "$IDENTITY" -o IdentitiesOnly=yes -l "$USER_" -p "$HOP_PORT" localhost true 2>/tmp/porthole_test_probe.$$; then + pass "forwarded port round-trips through the 2-hop chain" + else + fail "forwarded port round-trips through the 2-hop chain" "$(cat /tmp/porthole_test_probe.$$ 2>/dev/null)" + fi + rm -f "/tmp/porthole_test_probe.$$" 2>/dev/null + p_run "$STATE_DIR" close "${NAME}_multihop"; expect_exit "'close ${NAME}_multihop'" 0 +fi + +# --------------------------------------------------------------------------- +section "Phase 7: -u/--user without an embedded via user - real -l flag auth" +# --------------------------------------------------------------------------- +ALTUSER_PORT=28224 +p_run "$STATE_DIR" add "${NAME}_altuser" -l "$ALTUSER_PORT:localhost:22" -i "$IDENTITY" --via "$HOST" -u "$USER_" +expect_exit "'add ${NAME}_altuser' (--via with no embedded user, -u instead)" 0 +p_run "$STATE_DIR" open "${NAME}_altuser"; expect_exit "'open ${NAME}_altuser'" 0 +assert_true "'${NAME}_altuser' authenticates via -u/-l, reaches state: up" wait_for_state "$STATE_DIR" "${NAME}_altuser" up 10 +p_run "$STATE_DIR" close "${NAME}_altuser"; expect_exit "'close ${NAME}_altuser'" 0 + +# --------------------------------------------------------------------------- +section "Phase 8: real auth failure - Fatal classification" +# --------------------------------------------------------------------------- +if ! command -v ssh-keygen >/dev/null; then + skip "real Fatal-classification check (ssh-keygen not installed)" +else + ssh-keygen -q -t ed25519 -N '' -f "$BADKEY" >/dev/null + p_run "$STATE_DIR" add "${NAME}_badauth" -l 28230:localhost:22 -i "$BADKEY" --via "$USER_@$HOST" + expect_exit "'add ${NAME}_badauth' with a never-authorized key" 0 + p_run "$STATE_DIR" open --once "${NAME}_badauth" + assert_eq "'open --once' with bad auth exits 1" "$LAST_CODE" "1" + assert_eq "'${NAME}_badauth' lands in state: error" "$(json_field "$STATE_DIR" "${NAME}_badauth" state)" "error" + last_err="$(json_field "$STATE_DIR" "${NAME}_badauth" last_error)" + [[ "$last_err" == *"Permission denied"* ]] && pass "last_error reports a real Permission-denied rejection" \ + || fail "last_error reports a real Permission-denied rejection" "got: $last_err" + p_run "$STATE_DIR" remove "${NAME}_badauth"; expect_exit "'remove ${NAME}_badauth'" 0 +fi + +# --------------------------------------------------------------------------- +section "Phase 9: real connection-refused - KnownTransient classification" +# --------------------------------------------------------------------------- +p_run "$STATE_DIR" add "${NAME}_deadport" -l 28231:localhost:22 -i "$IDENTITY" --via "$USER_@$HOST" \ + -p 9 --retry-interval 2 --backoff-max 4 +expect_exit "'add ${NAME}_deadport' targeting a closed port on the real host" 0 +p_run "$STATE_DIR" open "${NAME}_deadport"; expect_exit "'open ${NAME}_deadport' (backgrounds even though the first attempt fails)" 0 +assert_true "'${NAME}_deadport' keeps reconnecting rather than giving up" wait_for_state "$STATE_DIR" "${NAME}_deadport" reconnecting 20 +rc1="$(json_field "$STATE_DIR" "${NAME}_deadport" reconnect_count)" +# Port 9 may be silently dropped rather than actively refused, so a single +# attempt can burn the full ConnectTimeout=10s; poll well past worst-case +# instead of a fixed sleep that assumes an instant refusal. +rc2="$rc1" +for _ in $(seq 1 40); do + rc2="$(json_field "$STATE_DIR" "${NAME}_deadport" reconnect_count)" + [[ -n "$rc2" && "$rc2" -gt "${rc1:-0}" ]] && break + sleep 0.5 +done +if [[ -n "$rc2" && "$rc2" -gt "${rc1:-0}" ]]; then + pass "reconnect_count keeps increasing on a real refused connection ($rc1 -> $rc2)" +else + fail "reconnect_count keeps increasing on a real refused connection" "rc1=$rc1 rc2=$rc2" +fi +p_run "$STATE_DIR" close --force "${NAME}_deadport"; expect_exit "'close --force ${NAME}_deadport'" 0 + +# --------------------------------------------------------------------------- +section "Phase 10: open --all only starts reconnect-enabled profiles" +# --------------------------------------------------------------------------- +p_run "$STATE_DIR" add "${NAME}_all1" -l 28232:localhost:22 -i "$IDENTITY" --via "$USER_@$HOST" +expect_exit "'add ${NAME}_all1' (reconnect: true, the default)" 0 +p_run "$STATE_DIR" add "${NAME}_all2" -l 28233:localhost:22 -i "$IDENTITY" --via "$USER_@$HOST" --reconnect false +expect_exit "'add ${NAME}_all2' (reconnect: false)" 0 +p_run "$STATE_DIR" open --all; expect_exit "'open --all'" 0 +assert_true "'${NAME}_all1' was started by --all" wait_for_state "$STATE_DIR" "${NAME}_all1" up 10 +assert_eq "'${NAME}_all2' was NOT started by --all (reconnect: false)" "$(json_field "$STATE_DIR" "${NAME}_all2" state)" "closed" +p_run "$STATE_DIR" close "${NAME}_all1"; expect_exit "'close ${NAME}_all1'" 0 +p_run "$STATE_DIR" remove "${NAME}_all2"; expect_exit "'remove ${NAME}_all2' (was never opened)" 0 + +# --------------------------------------------------------------------------- +section "Phase 11: close --force skips the graceful wait" +# --------------------------------------------------------------------------- +FORCE_PORT=28234 +p_run "$STATE_DIR" add "${NAME}_force" -l "$FORCE_PORT:localhost:22" -i "$IDENTITY" --via "$USER_@$HOST" +expect_exit "'add ${NAME}_force'" 0 +p_run "$STATE_DIR" open "${NAME}_force"; expect_exit "'open ${NAME}_force'" 0 +t0=$(date +%s) +p_run "$STATE_DIR" close --force "${NAME}_force"; expect_exit "'close --force ${NAME}_force'" 0 +t1=$(date +%s) +assert_true "'--force' returns fast, without the 5s graceful-wait" bash -c "[[ $((t1 - t0)) -lt 4 ]]" +assert_true "port $FORCE_PORT stops listening after force-close" wait_port_closed "$FORCE_PORT" 6 + +# --------------------------------------------------------------------------- +section "Phase 12: edit while running warns instead of restarting" +# --------------------------------------------------------------------------- +p_run "$STATE_DIR" add "${NAME}_edit" -l 28235:localhost:22 -i "$IDENTITY" --via "$USER_@$HOST" +expect_exit "'add ${NAME}_edit'" 0 +p_run "$STATE_DIR" open "${NAME}_edit"; expect_exit "'open ${NAME}_edit'" 0 +p_run "$STATE_DIR" edit "${NAME}_edit" --keepalive 20 +expect_exit "'edit ${NAME}_edit --keepalive 20' while open" 0 +expect_contains "warns the change won't apply until reopened" "won't take effect until" +p_run "$STATE_DIR" close "${NAME}_edit"; expect_exit "'close ${NAME}_edit'" 0 + +# --------------------------------------------------------------------------- +section "Phase 13: remove --keep-running leaves a genuine orphan" +# --------------------------------------------------------------------------- +KEEP_PORT=28236 +p_run "$STATE_DIR" add "${NAME}_keep" -l "$KEEP_PORT:localhost:22" -i "$IDENTITY" --via "$USER_@$HOST" +expect_exit "'add ${NAME}_keep'" 0 +p_run "$STATE_DIR" open "${NAME}_keep"; expect_exit "'open ${NAME}_keep'" 0 +p_run "$STATE_DIR" remove "${NAME}_keep" --keep-running +expect_exit "'remove ${NAME}_keep --keep-running'" 0 +expect_contains "warns it's left running untracked" "left running untracked" +p_run "$STATE_DIR" status "${NAME}_keep"; assert_eq "profile is gone from tracking" "$LAST_CODE" "1" +assert_true "the untracked process is still actually alive" pgrep -f "__supervise ${NAME}_keep\$" +assert_true "port $KEEP_PORT is still live, untracked" port_open "$KEEP_PORT" +# left running on purpose; phase 15's wipe is what's being tested against it + +# --------------------------------------------------------------------------- +section "Phase 14: transfer round trip against a live profile" +# --------------------------------------------------------------------------- +XFER_PORT=28237 +XFER_FILE="$(mktemp -u)" +STATE_DIR2="$(mktemp -d)"; STATE_DIRS+=("$STATE_DIR2") +p_run "$STATE_DIR" add "${NAME}_xfer" -l "$XFER_PORT:localhost:22" -i "$IDENTITY" --via "$USER_@$HOST" +expect_exit "'add ${NAME}_xfer'" 0 +p_run "$STATE_DIR" transfer -e "$XFER_FILE" "${NAME}_xfer" +expect_exit "'transfer -e ... ${NAME}_xfer'" 0 +expect_contains "export warns the identity file isn't included" "identity files are not included" + +p_run "$STATE_DIR2" transfer -i "$XFER_FILE" +expect_exit "'transfer -i ...' into a fresh state dir" 0 +p_run "$STATE_DIR2" open "${NAME}_xfer"; expect_exit "'open' the imported profile" 0 +assert_true "the imported profile actually connects, not just parses" wait_for_state "$STATE_DIR2" "${NAME}_xfer" up 10 +p_run "$STATE_DIR2" close "${NAME}_xfer"; expect_exit "'close' the imported profile" 0 +rm -f "$XFER_FILE" 2>/dev/null + +# --------------------------------------------------------------------------- +section "Phase 15: wipe (guarded - kills every __supervise process system-wide)" +# --------------------------------------------------------------------------- +if [[ "$SKIP_WIPE" == "1" ]]; then + skip "wipe phase (--skip-wipe passed)" +else + foreign="" + while read -r pid; do + [[ -z "$pid" ]] && continue + cmd="$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null)" + [[ "$cmd" == *"__supervise ${NAME}_"* ]] || foreign="$foreign $pid" + done < <(pgrep -f '__supervise' 2>/dev/null) + + if [[ -n "$foreign" ]]; then + skip "wipe phase (found __supervise process(es) not from this run: pid$foreign - not safe to run a system-wide wipe)" + else + p_run "$STATE_DIR" add "${NAME}_wa" -l 28238:localhost:22 -i "$IDENTITY" --via "$USER_@$HOST" + expect_exit "create throwaway closed profile for wipe test" 0 + p_run "$STATE_DIR" add "${NAME}_wb" -l 28239:localhost:22 -i "$IDENTITY" --via "$USER_@$HOST" + expect_exit "create throwaway open profile for wipe test" 0 + p_run "$STATE_DIR" open "${NAME}_wb"; expect_exit "open it" 0 + + p_run "$STATE_DIR" wipe --yes; expect_exit "'wipe --yes'" 0 + expect_contains "wipe reports what it did" "Wiped all forwards" + + p_run "$STATE_DIR" list; expect_contains "'list' is empty after wipe" "No profiles saved." + # kill_orphaned_supervisors only sends SIGTERM and returns; the orphan's + # own signal handler needs a moment to shut down, so give it a few + # retries rather than checking the instant `wipe` returns. + wait_for_orphan_gone() { + for _ in $(seq 1 10); do + pgrep -f "__supervise ${NAME}_keep\$" >/dev/null || return 0 + sleep 0.3 + done + return 1 + } + assert_true "the phase-13 orphan is gone too (kill_orphaned_supervisors)" wait_for_orphan_gone + assert_true "port $KEEP_PORT is no longer listening" wait_port_closed "$KEEP_PORT" 6 + fi +fi