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