Compare commits

..

10 Commits

Author SHA1 Message Date
0aab042859 release build opts 2026-08-13 18:15:38 +02:00
07518f1190 . 2026-08-13 18:12:36 +02:00
1b947fec24 Standardize error messages, formatting, and code style. Refine --help text for consistency. Update comments for clarity and tone alignment. 2026-08-13 17:50:06 +02:00
e36360e18c Normalize user-facing --help text for tone/format consistency
Follow-up to the previous comment pass, which covered source comments -
this one is specifically the doc comments that render as --help output:

- Dropped "Deep-dive" (casual/jargon) from status's description in favor
  of the imperative-verb pattern every other command description uses
  (Save/Start/Stop/Update/List/Delete/Generate).
- Replaced the "SIGTERM + wait" shorthand in close --force's help with a
  plain sentence - "+" as an ad-hoc conjunction doesn't belong in
  user-facing text.
- Unified all positional NAME descriptions to the same "Name of the
  profile to <verb>" shape (add's was "Name for the new profile",
  breaking the pattern); open's picked up the same parenthetical-clarifier
  convention already used elsewhere ("(ignored with --all)", matching
  "(doesn't open it)", "(SOCKS proxy)").
- Trimmed the redundant "SSH" prefix from --via's description (the whole
  tool is SSH-specific, so it added nothing); kept it on --port's, where
  it disambiguates the SSH connection port from the forward's own port
  numbers.
- Fixed the Cli struct's doc comment to match what --command(about)
  actually renders (Cargo.toml's description, not the doc comment
  verbatim) - it carried a stale "porthole - " prefix that never showed.
2026-08-13 17:21:58 +02:00
4170d51cf5 Show real mapping grammar in --help, make --via repeatable, trim comments
- -l/-r/-d now show their actual grammar as the clap value name
  (<[BIND:]PORT:HOST:PORT>, <[BIND:]PORT>) instead of a generic <SPEC>,
  matching vmic's convention of showing real syntax in --help. The
  grammar is no longer also repeated in the flag's help text, since the
  value name already carries it.
- --via changes from a single comma-separated flag to a repeatable one
  (Vec<String> + value_delimiter = ','), so both `--via a --via b` and
  `--via a,b` work and can be mixed. commands/mod.rs's edits_from_mapping
  no longer needs to split the string itself - clap does it.
- Trimmed CLI help text that restated grammar/rationale already covered
  by the value name or by --via being required.
- Normalized code comments for tone/format consistency: dropped
  cross-references to vmic's own internals as justification (this
  codebase should read as self-contained), removed markdown-style
  *emphasis* asterisks that don't render in plain comments, tightened
  several run-on sentences into plain declarative ones, and fixed one
  comment in wipe.rs that inaccurately described close_instance's force
  path (it sends SIGKILL, not SIGTERM).
- spec/porthole-spec.md's --via row updated to document the repeatable
  form alongside comma-separated.

Cargo.toml/Cargo.lock/atomic.rs also carry an external toml crate bump
and formatting pass picked up from the working tree.
2026-08-13 17:18:33 +02:00
4a8faf1131 Match vmic's custom top-level --help renderer
Ports vmic's print_full_help (main.rs) verbatim in spirit: one line per
subcommand with its positional args inline, an indented per-flag block
underneath with globally-aligned columns, a separate Aliases section, and
NO_COLOR-aware coloring - so `porthole`/`porthole -h`/`porthole help` show
every subcommand's flags without drilling into each one's own --help,
matching vmic's actual rendered output exactly in structure.

Also shortened --via's clap value_name from the full grammar
([user@]host[:port][,...]) to HOPS - the long form blew out the column
alignment for every other flag's help text; the full grammar is still in
the flag's help string itself.

Dropped vmic's MULTI_CHAR_ALIASES workaround (for short flags like -sv
that clap can't express natively) since porthole has no multi-char short
flags today - add it back if one shows up.
2026-08-13 16:13:02 +02:00
0c9f0585fa Implement porthole v0.1: profiles, supervised open/close, reconnect
Full CLI per spec v0.2 - add/open/close/edit/status/list/remove/wipe/
completions, plus a hidden `__supervise` subcommand that IS the
supervisor process.

- profile.rs: TOML-backed profiles at ~/.config/porthole/profiles/,
  validated -l/-r/-d mapping + --via grammar, atomic writes.
- instance.rs: JSON runtime state at ~/.local/state/porthole/, an
  flock-based lock file that's the source of truth for "is this open"
  (survives a crash/kill -9 without stale-lock cleanup), pid liveness
  checked against /proc rather than trusted from disk.
- supervisor.rs: the __supervise loop - spawns ssh, traps SIGTERM/SIGINT
  into a flag (rather than inferring intent from ssh's exit status),
  classifies failures as fatal/known-transient/unrecognized, backs off
  with a stability-reset, rotates its log.
- ssh.rs: builds the ssh invocation, including splitting --via into a
  -J jump chain plus the mandatory positional target.
- open.rs: the detach/re-exec dance (setsid via pre_exec) and a bounded
  wait for the supervisor to reach Up/Error before open returns, so an
  immediate failure surfaces as a non-zero exit instead of a false
  "opened" - this took a real bug fix during smoke testing, since the
  instance file's initial state (Reconnecting, meaning "attempt in
  flight") was indistinguishable from "already failed once" by state
  alone.
- close.rs: SIGTERM+wait, or SIGKILL the whole process group with
  --force so ssh can't be left orphaned.

Smoke-tested against invalid/unreachable hosts (no real infrastructure
touched): CLI surface, validation errors, add/edit/list/status/remove,
the reconnect/backoff loop with live state transitions, close mid-retry,
edit-while-running's warning, open --all, and wipe. cargo test: 14/14.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 15:43:49 +02:00
bc8f1cc5d2 Fix --via/-J grammar and --force process-group kill in spec
Found both while translating the spec into code:

- `ssh -J a,b,c` alone isn't valid - ssh -J only carries jump hosts
  before the final hop; it still needs a positional destination. --via's
  last entry has to double as that target, which also makes --via
  mandatory (there's no other field naming "the host ssh connects to"),
  reversing the earlier "no" in the required column.
- A plain single-pid SIGKILL on `close --force` would leave the `ssh`
  child orphaned, since a killed process can't forward anything to it.
  Documents sending SIGKILL to the whole process group instead, relying
  on the supervisor's own setsid() call making it the group leader.
2026-08-13 15:43:36 +02:00
b18ee7405e Address implementation gaps in spec: supervisor lifecycle, reconnect policy, instance state
Bumps to v0.2. Resolves the open questions and design gaps flagged during
review before implementation starts:

- Spells out the supervisor detach/re-exec mechanism (§3), mirroring how
  vmic solves the same one-shot-CLI-can't-host-a-daemon problem.
- Forces BatchMode/ExitOnForwardFailure/ConnectTimeout on every ssh
  invocation so headless failures surface instead of hanging on a TTY
  prompt (§3.1).
- Adds backoff + stderr-based fatal/transient failure classification so
  reconnect doesn't retry forever against a permanently broken profile
  (§4).
- Collapses the Instance `state` enum to up/reconnecting/error, with
  "no instance file" as the sole meaning of closed, removing the prior
  ambiguity around close vs. crash vs. down (§2.2, §3).
- Fixes --via's conflicting comma-list-vs-repeated-flag examples by
  committing to a straight ssh -J passthrough grammar (§3.1/§5.1).
- Resolves the edit --restart open question: no --restart, stays explicit.
- Simplifies exit codes to 0/1/2 (was a 6-code table with its own TODO).
- Adds a lock file for concurrent-open safety and a log rotation policy.
- Calls out reboot/logout survival as an explicit v0.1 non-goal, with
  `open --all` as the only hook left for external autostart wiring (§8).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 15:26:43 +02:00
5675200a3c added .gitignore 2026-08-13 15:21:31 +02:00
24 changed files with 2878 additions and 106 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.*
!.gitignore
target/
*.lock
!Cargo.lock

435
Cargo.lock generated Normal file
View File

@@ -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 = "0.1.0"
dependencies = [
"clap",
"clap_complete",
"dirs",
"libc",
"serde",
"serde_json",
"thiserror",
"toml",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "redox_users"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom",
"libredox",
"thiserror",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_spanned"
version = "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"

32
Cargo.toml Normal file
View File

@@ -0,0 +1,32 @@
[package]
name = "porthole"
version = "0.1.0"
edition = "2021"
description = "Create and manage named SSH port forwards."
license = "AGPL-3+"
[[bin]]
name = "porthole"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
clap_complete = "4"
thiserror = "^2"
dirs = "6"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "^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

View File

@@ -1,4 +1,4 @@
# `porthole` — Spec v0.1 # `porthole` — Spec v0.2
Named, managed SSH port forwards. Wraps `ssh -L/-R/-D` so forwards are Named, managed SSH port forwards. Wraps `ssh -L/-R/-D` so forwards are
addressable by name instead of by PID, terminal tab, or shell history. addressable by name instead of by PID, terminal tab, or shell history.
@@ -20,7 +20,8 @@ open.
**Non-goals:** Not a replacement for a VPN or a full SOCKS/proxy manager. **Non-goals:** Not a replacement for a VPN or a full SOCKS/proxy manager.
Not a secrets manager — SSH auth still comes from your existing SSH config, Not a secrets manager — SSH auth still comes from your existing SSH config,
agent, or identity files. No GUI. agent, or identity files. No GUI. Surviving a full reboot/logout is also
out of scope for v0.1 — see §8.
--- ---
@@ -30,117 +31,261 @@ agent, or identity files. No GUI.
A saved definition. Does not imply anything is running. A saved definition. Does not imply anything is running.
| Field | Type | Notes | | Field | Type | Notes |
|------------------|-----------|------------------------------------------| |------------------|-----------|-----------------------------------------------------------------------|
| `name` | string | Unique key. `[a-z0-9_-]+`. | | `name` | string | Unique key. `[a-z0-9_-]+`, 1-64 chars (matches vmic's naming rule). |
| `kind` | enum | `local` \| `remote` \| `dynamic` | | `kind` | enum | `local` \| `remote` \| `dynamic` |
| `mapping` | string | Raw `ssh -L/-R/-D`-style spec, see §3.1 | | `mapping` | string | Raw `-L/-R/-D` payload, see §5.1 |
| `via` | string[] | Ordered list of hops for multi-hop jumps | | `via` | string[] | Ordered hop list; each entry `[user@]host[:port]` (see §3.1) |
| `user` | string? | Defaults to current user / `ssh_config` | | `user` | string? | Defaults to current user / `ssh_config` |
| `identity` | path? | Identity file override | | `identity` | path? | Identity file override |
| `ssh_port` | int | Default `22` | | `ssh_port` | int | Default `22`; final target only, see §5.1 |
| `reconnect` | bool | Default `true` | | `reconnect` | bool | Default `true` |
| `retry_interval` | int (sec) | Default `5` | | `retry_interval` | int (sec) | Default `5`; base reconnect delay (§4.1) |
| `keepalive` | int (sec) | `ServerAliveInterval`, default `15` | | `backoff_max` | int (sec) | Default `60`; cap on the doubling reconnect delay (§4.1) |
| `created_at` | timestamp | | | `keepalive` | int (sec) | `ServerAliveInterval`, default `15` |
| `updated_at` | timestamp | | | `created_at` | timestamp | |
| `updated_at` | timestamp | |
### 2.2 Instance (runtime state) ### 2.2 Instance (runtime state)
Exists only while a profile is open. Tracked separately from the profile so Exists only while a profile is open. Tracked separately from the profile so
`list`/`status` can report live data without touching the saved definition. `list`/`status` can report live data without touching the saved definition.
**The absence of an instance file is what "closed" means** — there is no
separate `down` state; see §3 for exactly when the file is created/removed.
| Field | Type | Notes | | Field | Type | Notes |
|---------------------|------------|---------------------------------------------| |---------------------|------------|----------------------------------------------------------------|
| `name` | string | FK to profile | | `name` | string | FK to profile |
| `pid` | int | Supervisor process PID, not raw `ssh` PID | | `pid` | int | Supervisor process PID, not raw `ssh` PID |
| `state` | enum | `up` \| `down` \| `reconnecting` \| `error` | | `state` | enum | `up` \| `reconnecting` \| `error` |
| `opened_at` | timestamp | | | `opened_at` | timestamp | Anchor for "session uptime" (§5.5) — set once, at `open` |
| `last_error` | string? | Most recent failure message, if any | | `connected_at` | timestamp? | Start of the *current* unbroken connection; resets each reconnect (§5.5) |
| `reconnect_count` | int | Since last manual `open` | | `last_error` | string? | Most recent failure message, if any |
| `last_reconnect_at` | timestamp? | | | `reconnect_count` | int | Since last manual `open` |
| `last_reconnect_at` | timestamp? | |
### 2.3 Storage ### 2.3 Storage
- Profiles: `~/.config/porthole/profiles/<name>.toml` - Profiles: `~/.config/porthole/profiles/<name>.toml`
- Runtime state: `~/.local/state/porthole/<name>.json` (written by supervisor, not hand-edited) - Runtime state: `~/.local/state/porthole/<name>.json` (written by the
- Logs: `~/.local/state/porthole/<name>.log` supervisor, not hand-edited; absence means the profile is closed)
- Lock: `~/.local/state/porthole/<name>.lock` (advisory `flock`, held for
the supervisor's entire lifetime — see §3)
- Logs: `~/.local/state/porthole/<name>.log`, rotated to a single
`<name>.log.1` backup once it exceeds 10 MiB (checked on each reconnect
attempt, not per line — these are meant to run for months, unlike vmic's
short-lived CLI invocations)
--- ---
## 3. Commands ## 3. Supervisor architecture
### 3.1 `porthole add <name> [flags]` `open` has to hand off to a process that keeps running after the invoking
shell/terminal exits — the same one-shot-CLI-can't-host-a-daemon problem
`vmic` solves by spawning detached `pw-loopback` subprocesses tracked by
pid. porthole has no external long-running helper to shell out to (there's
no `ssh-loopback` equivalent), so it supervises `ssh` itself via a hidden
re-exec of its own binary:
1. `porthole open <name>` validates the profile, tries to acquire
`<name>.lock` (if already held by a live pid: no-op, print status, exit
0 — see §5.2), then spawns *itself* via `std::env::current_exe()` with a
hidden subcommand: `porthole __supervise <name>`.
2. The spawned process detaches before doing anything else: `stdin` from
`/dev/null`, `stdout`/`stderr` appended to `<name>.log`, and
`libc::setsid()` called via `CommandExt::pre_exec` so it leaves the
parent's process group/session — it survives the terminal closing and
doesn't receive the shell's Ctrl-C/SIGHUP.
3. The foregrounding `open` call blocks briefly (bounded, a few seconds)
waiting for the supervisor to write its pid + initial `state` into the
instance file, so callers get an accurate exit code for immediate
failures (bad auth, port conflict) instead of racing a background
process. `-f/--foreground` skips the detach step entirely and runs the
supervisor loop inline, attached to the current session.
4. The supervisor's loop: spawn `ssh` with the flags in §3.1, wait on it,
classify the exit per §4, sleep/backoff or give up accordingly, and
rewrite the instance file after every state change. A signal handler
installed on the supervisor (not exit-code inference on `ssh` itself,
which is unreliable) is what distinguishes an intentional `close` from
a dropped connection: `close` sends SIGTERM to the *supervisor* pid,
whose handler kills its `ssh` child, waits briefly, deletes its lock and
instance file, and exits — any *other* way the `ssh` child ends (any
exit status) is treated as a failure to reconnect from, per §4. Because
`step 2`'s `setsid()` makes the supervisor its own process group leader
and `ssh` inherits that group, `--force` sends SIGKILL to the whole
group (`kill(-pid, SIGKILL)`) rather than just the supervisor pid — a
plain single-pid SIGKILL would leave `ssh` running, orphaned and
untracked, since a killed process can't forward anything to its child.
A supervisor that exits on its own due to a fatal failure (§4.2) leaves
the instance file in place with `state: error` rather than deleting it,
so the failure stays visible to `status`/`list` until the user acts.
### 3.1 Forced `ssh` flags
Every `ssh` invocation porthole spawns gets these, non-configurable,
regardless of the user's own `~/.ssh/config`:
| Flag | Why |
|--------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `-o BatchMode=yes` | A headless supervised process must never block on a password/passphrase/host-key TTY prompt — without this, a first connection to an unknown host or a locked key just hangs forever, indistinguishable from "reconnecting." |
| `-o ExitOnForwardFailure=yes` | Makes `ssh` exit non-zero immediately if the requested forward can't be bound, instead of staying up as a plain (forward-less) session that *looks* healthy. |
| `-o ConnectTimeout=10` | Bounds how long one connection attempt can hang before porthole's own backoff logic (§4.1) gets a turn. |
| `-N` | No remote command — porthole only ever wants the forward, never a shell. |
| `-J <hops>` + positional target | See below — `--via`'s *last* hop is the actual connection target, not another jump. |
`ServerAliveInterval` comes from the profile's `keepalive` field (not
hardcoded), so it stays user-tunable.
**`--via``ssh` argument translation:** `ssh -J a,b,c` is not itself a
valid invocation — `-J` only ever carries jump hosts *before* the final
hop; `ssh` still needs a positional `destination` to actually connect (and
run the forward from). So porthole splits `--via`'s comma list at the last
entry: everything before it becomes `-J`'s value (omitted entirely if
`--via` has only one hop), and the last entry becomes `ssh`'s positional
target argument. `--via jumpbox``ssh ... jumpbox` (no `-J`). `--via
bastion1,bastion2``ssh -J bastion1 ... bastion2` (connect through
bastion1, forward runs from bastion2). This is also why `--via` is
**required**, not optional (see §5.1) — there is no other field
representing "the host `ssh` actually connects to"; `--via`'s last hop
*is* that field.
---
## 4. Reconnect & failure handling
### 4.1 Backoff
- Base delay is the profile's `retry_interval` (default 5s).
- Each consecutive failed attempt doubles the delay, capped at
`backoff_max` (default 60s).
- The backoff counter resets to the base delay once a connection has
stayed up continuously for 60s — an in-memory supervisor detail, not
persisted to the instance file — so one good connection after a flaky
patch doesn't leave the *next* reconnect waiting a full capped delay.
- If `reconnect` is `false`, there is no retry loop at all: a single
failed attempt goes straight to `state: error` and the supervisor exits.
### 4.2 Failure classification
`ssh`'s own exit code doesn't reliably distinguish "retry me" from "stop
retrying," so porthole classifies by matching `ssh`'s stderr:
| Pattern (stderr substring) | Class | Behavior |
|-----------------------------------------------------------------|------------|--------------------------------------------------|
| `Permission denied` | fatal | `state: error`, stop, supervisor exits |
| `Host key verification failed` | fatal | `state: error`, stop, supervisor exits |
| `bind: Address already in use` | fatal | `state: error`, stop, supervisor exits |
| `Connection refused` / `No route to host` / connect timeout | transient | backoff + retry |
| `Could not resolve hostname` | transient | backoff + retry (bounded by `backoff_max`) |
| anything else / unrecognized | transient* | backoff + retry, but see escalation below |
\* After 10 consecutive unrecognized failures in a row, porthole treats the
profile as effectively broken (`state: error`, stop) rather than retrying
under an unrecognized failure forever. `last_error` always holds the raw
`ssh` message either way, for `status` to show.
A profile that lands in `state: error` stays that way — including its
instance file — until the user runs `open` again (fresh attempt, fresh
backoff) or `close` (clears it). It is deliberately *not* self-healing past
a fatal classification.
---
## 5. Commands
### 5.1 `porthole add <name> [flags]`
Aliases: `create`, `new` Aliases: `create`, `new`
Saves a new profile. Does **not** open it. Saves a new profile. Does **not** open it.
| Flag | Arg | Required | Default | Description | | Flag | Arg | Required | Default | Description |
|--------------------|-----------------------------|-------------------|---------------------------|---------------------------------------| |--------------------|-------------------------------|-------------------|---------------------------|----------------------------------------|
| `-l, --local` | `[bind:]port:host:hostport` | one of `-l/-r/-d` | — | Local forward: your machine → remote | | `-l, --local` | `[bind:]port:host:hostport` | one of `-l/-r/-d` | — | Local forward: your machine → remote |
| `-r, --remote` | `[bind:]port:host:hostport` | one of `-l/-r/-d` | — | Remote forward: remote → your machine | | `-r, --remote` | `[bind:]port:host:hostport` | one of `-l/-r/-d` | — | Remote forward: remote → your machine |
| `-d, --dynamic` | `[bind:]port` | one of `-l/-r/-d` | — | Dynamic forward (SOCKS proxy) | | `-d, --dynamic` | `[bind:]port` | one of `-l/-r/-d` | — | Dynamic forward (SOCKS proxy) |
| `--via` | `host` | yes | — | SSH target; multiple for multi-hop | | `--via` | `[user@]host[:port]` | **yes** | | One hop chain entry; repeatable (`--via a --via b`) and/or comma-separated (`--via a,b`) - the last hop is the `ssh` connection target, any before it are `-J` jumps (§3.1) |
| `-u, --user` | `user` | no | current user / ssh_config | | | `-u, --user` | `user` | no | current user / ssh_config | Default user for the final target and any `--via` hop that doesn't specify its own |
| `-i, --identity` | `path` | no | ssh_config default | | | `-i, --identity` | `path` | no | ssh_config default | |
| `-p, --port` | `port` | no | `22` | SSH port on final target | | `-p, --port` | `port` | no | `22` | SSH port on the final target only — a `--via` hop needs its own inline `:port` if it isn't 22 |
| `--reconnect` | `bool` | no | `true` | Auto-reconnect on drop | | `--reconnect` | `bool` | no | `true` | Auto-reconnect on drop (§4) |
| `--retry-interval` | `seconds` | no | `5` | Delay between reconnect attempts | | `--retry-interval` | `seconds` | no | `5` | Base reconnect delay (§4.1) |
| `--keepalive` | `seconds` | no | `15` | `ServerAliveInterval` | | `--backoff-max` | `seconds` | no | `60` | Cap on the doubling reconnect delay (§4.1) |
| `--keepalive` | `seconds` | no | `15` | `ServerAliveInterval` |
Exactly one of `-l`/`-r`/`-d` is required. Providing more than one is an Exactly one of `-l`/`-r`/`-d` is required. Providing more than one is an
error. error.
**Validation:** **Validation:**
- `name` must not already exist (use `edit` to modify). - `name` must not already exist (use `edit` to modify); 1-64 chars,
- `mapping` port syntax validated against the same grammar `ssh` accepts. `[a-z0-9_-]+`.
- `--via` hosts resolved/checked against `~/.ssh/config` if present, but not - `mapping` port syntax validated against the same grammar `ssh` accepts;
required to exist there. stored verbatim as whichever `-l/-r/-d` payload was given (without the
flag itself) — `kind` records which one it was, so re-deriving the right
`-L`/`-R`/`-D` flag at `open` time is a lookup, not a re-parse.
- `--via` requires at least one hop (its last entry is the mandatory
connection target, see §3.1); hosts are resolved/checked against
`~/.ssh/config` if present, but not required to exist there.
**Examples:** **Examples:**
``` ```
porthole add db --local 5432:db.internal:5432 --via jumpbox porthole add db --local 5432:db.internal:5432 --via jumpbox
porthole add admin-ui --local 8080:localhost:8080 --via bastion1,bastion2 --user ops porthole add admin-ui --local 8080:localhost:8080 --via bastion1,bastion2:2222 --user ops
porthole add webhook --remote 9000:localhost:3000 --via public-vps porthole add webhook --remote 9000:localhost:3000 --via public-vps
porthole add proxy --dynamic 1080 --via edge-host porthole add proxy --dynamic 1080 --via edge-host
``` ```
--- ---
### 3.2 `porthole open <name> [flags]` ### 5.2 `porthole open <name> [flags]`
Alias: `start` Alias: `start`
Starts a saved forward as a background-supervised process. Starts a saved forward as a background-supervised process (§3).
| Flag | Description | | Flag | Description |
|--------------------|-------------------------------------------------------------------------| |--------------------|--------------------------------------------------------------------------------------------------------------------|
| `-f, --foreground` | Run attached in current shell instead of daemonizing. Ctrl-C closes it. | | `-f, --foreground` | Run attached in current shell instead of detaching. Ctrl-C closes it cleanly (removes the instance file, same as `close`). |
| `--once` | Open without auto-reconnect, regardless of profile setting. | | `--once` | Open without auto-reconnect, regardless of profile setting (§4.1). |
| `--all` | Ignore `<name>`; open every profile with `reconnect: true` that isn't already running. Per-profile failures are warnings, not a whole-batch failure — this exists specifically as the hook for external autostart mechanisms, see §8. |
**Behavior:** **Behavior:**
- If already open: no-op, print current status, exit 0. - If already open (a live supervisor pid holds `<name>.lock`): no-op,
print current status, exit 0.
- If the instance file exists but its pid is dead (crash, or the machine
rebooted): treated as not-running, proceeds to spawn a fresh supervisor.
- If port bind fails (already in use): exit non-zero with the conflicting - If port bind fails (already in use): exit non-zero with the conflicting
process info if discoverable (`lsof`-style lookup), don't silently retry. process info if discoverable (`lsof`-style lookup — best-effort, degrades
- Spawns a supervisor process that owns the underlying `ssh` subprocess, to a plain "port in use" message if `lsof`/`ss` isn't on `PATH`), don't
watches for exit, and reconnects per profile settings. silently retry.
- `open` blocks briefly (bounded, a few seconds) waiting for the detached
supervisor to confirm it's alive, so an immediate failure (bad auth,
bind conflict) reports a non-zero exit rather than appearing to succeed.
Anything that fails *after* that point (a later reconnect) only shows up
via `status`/`list`, not `open`'s own exit code.
**Examples:** **Examples:**
``` ```
porthole open db porthole open db
porthole open db --foreground porthole open db --foreground
porthole open proxy --once porthole open proxy --once
porthole open --all
``` ```
--- ---
### 3.3 `porthole close <name> [flags]` ### 5.3 `porthole close <name> [flags]`
Alias: `stop` Alias: `stop`
Stops a running forward. Profile definition is untouched. Stops a running forward and removes its instance file. Profile definition
is untouched.
| Flag | Description | | Flag | Description |
|-----------|--------------------------------------------------------| |-----------|----------------------------------------------------------------------------------|
| `--force` | SIGKILL immediately instead of graceful SIGTERM + wait | | `--force` | SIGKILL the supervisor (and its `ssh` child) immediately instead of graceful SIGTERM + wait |
`close` on a profile that's already stopped (no live pid) is a no-op, exit
0 — it still clears a stale instance file left over from a crash, same as
the crash-recovery path in `open`.
**Examples:** **Examples:**
``` ```
@@ -150,11 +295,18 @@ porthole close db --force
--- ---
### 3.4 `porthole edit <name> [flags]` ### 5.4 `porthole edit <name> [flags]`
Updates a saved profile. Accepts the same flags as `add` (all optional — Updates a saved profile. Accepts the same flags as `add` (all optional —
only provided flags are changed). Does not restart a running instance only provided flags are changed).
automatically; changes apply on next `open`.
**Decision:** `edit` never restarts a running instance, and there is no
`--restart` flag. If `<name>` is currently running, `edit` prints a warning
that the change won't take effect until the next `open`/`close` cycle and
exits 0 — consistent with vmic's `edit`, which never auto-migrates a live
topology without telling the user exactly what to run instead. Keeping
this explicit avoids a footgun where editing a profile silently bounces a
tunnel someone else might be relying on.
**Examples:** **Examples:**
``` ```
@@ -162,19 +314,25 @@ porthole edit db --retry-interval 10
porthole edit db --local 5433:db.internal:5432 porthole edit db --local 5433:db.internal:5432
``` ```
If `<name>` is currently running, print a warning that changes won't take
effect until the next `open` (or offer `--restart` — see §6 open questions).
--- ---
### 3.5 `porthole status <name>` ### 5.5 `porthole status <name>`
Deep-dive health for one forward. Deep-dive health for one forward.
| Flag | Description |
|----------|-----------------------------------------------------|
| `--json` | Machine-readable output (matches `list --json`) |
**Output includes:** **Output includes:**
- Profile summary (kind, mapping, via, user) - Profile summary (kind, mapping, via, user)
- Current state (`up` / `down` / `reconnecting` / `error`) - Current state (`up` / `reconnecting` / `error`, or `closed` if no
- Uptime since last successful connect instance file exists at all — §2.2)
- **Session uptime**: elapsed time since `opened_at` (the original `open`
call), regardless of intervening reconnects
- **Connection uptime**: elapsed time since `connected_at` (the current
unbroken connection) — resets on every reconnect, absent while
`reconnecting`/`error`
- Reconnect count and timestamp of last reconnect - Reconnect count and timestamp of last reconnect
- Last error message, if any - Last error message, if any
- Path to log file - Path to log file
@@ -186,7 +344,7 @@ porthole status db
--- ---
### 3.6 `porthole list` ### 5.6 `porthole list`
Alias: `ls` Alias: `ls`
All saved profiles with live status. Fast, scannable — no deep diagnostics All saved profiles with live status. Fast, scannable — no deep diagnostics
@@ -194,12 +352,15 @@ All saved profiles with live status. Fast, scannable — no deep diagnostics
**Columns:** `NAME KIND MAPPING VIA STATE UPTIME` **Columns:** `NAME KIND MAPPING VIA STATE UPTIME`
A profile with no instance file shows `STATE: closed` and an empty
`UPTIME`. `UPTIME` otherwise shows connection uptime (§5.5).
**Flags:** **Flags:**
| Flag | Description | | Flag | Description |
|-------------|-----------------------------------| |-------------|-------------------------------------------------------------------|
| `--running` | Show only currently-open forwards | | `--running` | Show only currently-open forwards (`STATE` in `up`/`reconnecting`) |
| `--json` | Machine-readable output | | `--json` | Machine-readable output |
**Example:** **Example:**
``` ```
@@ -209,67 +370,92 @@ porthole list --running
--- ---
### 3.7 `porthole remove <name>` ### 5.7 `porthole remove <name>`
Aliases: `rm`, `delete` Aliases: `rm`, `delete`
Deletes a saved profile. Closes it first if running. Deletes a saved profile. Closes it first if running.
| Flag | Description | | Flag | Description |
|------------------|-------------------------------------------------------------------| |------------------|--------------------------------------------------------------------|
| `--keep-running` | Delete the profile but leave an active instance running untracked | | `--keep-running` | Delete the profile but leave an active instance running untracked |
An instance left running via `--keep-running` is no longer visible to
`list`/`status` (its profile is gone), but is still caught by `wipe`
(§5.8), which matches by supervisor process signature rather than tracked
state — same as vmic's `wipe`.
--- ---
### 3.8 `porthole wipe` ### 5.8 `porthole wipe`
Alias: `reset` Alias: `reset`
Closes and deletes **every** forward, including any `ssh` processes matching Closes and deletes **every** forward, including any supervisor/`ssh`
porthole's supervisor signature that aren't in the current profile store processes matching porthole's signature that aren't in the current profile
(e.g. orphaned after a crash). Confirmation prompt unless `--yes`. store (e.g. orphaned after a crash). Confirmation prompt unless `--yes`
unlike vmic's `wipe` (no prompt), porthole's tears down active network
tunnels rather than just audio routing, so the extra confirmation is a
deliberate, not accidental, difference.
| Flag | Description | | Flag | Description |
|-------------|--------------------------| |-------------|----------------------------|
| `-y, --yes` | Skip confirmation prompt | | `-y, --yes` | Skip confirmation prompt |
--- ---
### 3.9 `porthole completions <shell>` ### 5.9 `porthole completions <shell>`
Generates a shell completion script. `<shell>``bash`, `zsh`, `fish`. Generates a shell completion script. `<shell>``bash`, `zsh`, `fish`.
--- ---
## 4. Global options ## 6. Global options
| Flag | Description | | Flag | Description |
|-----------------|---------------| |-----------------|----------------|
| `-h, --help` | Print help | | `-h, --help` | Print help |
| `-V, --version` | Print version | | `-V, --version` | Print version |
--- ---
## 5. Exit codes ## 7. Exit codes
| Code | Meaning | | Code | Meaning |
|------|-----------------------------------------------| |------|--------------------------------------------------------------------|
| `0` | Success | | `0` | Success |
| `1` | Generic error | | `1` | Error — see the printed message |
| `2` | Profile not found | | `2` | CLI usage error (bad/missing arguments — clap's own exit code) |
| `3` | Profile already exists (`add` without `edit`) |
| `4` | Port bind conflict on `open` | Kept deliberately flat, matching vmic: granular per-failure codes (not
| `5` | SSH auth/connection failure | found vs. already-exists vs. bind conflict, etc.) only pay for themselves
// TODO: fix these up, just use 0/1/2 and output a good error code once something is actually scripting against them, and nobody's asked for
// ERR_IDENT (ERR_NUM): Err_Msg that yet. Every error still gets a specific, greppable message on stderr.
--- ---
## 6. Open questions for v0.2 ## 8. Surviving reboots
- Should `edit` on a running profile support `--restart` to apply **Explicitly out of scope for v0.1**, and worth calling out since it's
immediately, or stay explicit (`edit` then `close`/`open`)? adjacent to the overview's own motivating problem: the supervisor is a
- Multi-hop `--via` — do we shell out to `ssh -J`, or manage a chain of plain process, not a system service, so a full reboot or logout kills it
supervised hops ourselves for finer-grained per-hop status? along with everything else — reconnect (§4) covers network blips and
sleep/wake, not "the machine came back up."
The only piece porthole commits to now is `open --all` (§5.2), which exists
specifically so an external mechanism can drive it — a systemd `--user`
unit, a login item, a cron `@reboot` line. porthole does not register,
manage, or template any of those itself; that's a v0.2+ decision (§9) once
it's clear which one people actually want.
---
## 9. Open questions for v0.2
- Autostart: if/when to commit to a specific mechanism (systemd user unit
template? login item?) now that `open --all` exists as the hook.
- Multi-hop `--via`: confirmed as a straight passthrough to `ssh -J`
(§3.1/§5.1) for v0.1; revisit only if per-hop supervised status ever
becomes a real ask.
- Templating (`dbtun`-style): saved "kind" templates (e.g. `--template - Templating (`dbtun`-style): saved "kind" templates (e.g. `--template
postgres` implies port 5432) — worth adding as sugar over `add`, or scope postgres` implies port 5432) — worth adding as sugar over `add`, or
creep? scope creep?
- Config export/import for moving profiles between machines. - Config export/import for moving profiles between machines.

25
src/atomic.rs Normal file
View File

@@ -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)
}

184
src/cli.rs Normal file
View File

@@ -0,0 +1,184 @@
use clap::{Args, Parser, Subcommand};
use clap_complete::Shell;
/// 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 = "new")]
Add(AddArgs),
/// Start a saved forward as a supervised background process.
#[command(visible_alias = "start")]
Open(OpenArgs),
/// Stop a running forward.
#[command(visible_alias = "stop")]
Close(CloseArgs),
/// Update a saved profile.
Edit(EditArgs),
/// 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),
/// Generate a shell completion script.
Completions { shell: Shell },
/// Internal: runs the supervisor loop for one profile. Not for direct
/// use - `open` spawns this itself (spec §3).
#[command(hide = true, name = "__supervise")]
Supervise { name: String },
}
/// Shared mapping/connection flags for `add` and `edit` - kept as one
/// struct (`#[command(flatten)]`ed into both) so the two can never drift.
#[derive(Args, Default)]
pub struct MappingArgs {
/// Local forward (current machine -> remote machine).
#[arg(short, long, value_name = "[BIND:]PORT:HOST:PORT")]
pub local: Option<String>,
/// Remote forward (remote machine -> current machine).
#[arg(short, long, value_name = "[BIND:]PORT:HOST:PORT")]
pub remote: Option<String>,
/// Dynamic forward (SOCKS proxy).
#[arg(short, long, value_name = "[BIND:]PORT")]
pub dynamic: Option<String>,
/// Jump-host chain, ending at the connection target.
#[arg(long, value_name = "[USER@]HOST[:PORT]", value_delimiter = ',')]
pub via: Vec<String>,
/// Default user for the target and any hop without one.
#[arg(short, long, value_name = "USER")]
pub user: Option<String>,
/// Identity file override.
#[arg(short, long, value_name = "PATH")]
pub identity: Option<String>,
/// SSH port of the final target.
#[arg(short, long, value_name = "PORT")]
pub port: Option<u16>,
/// Auto-reconnect on connection drop.
#[arg(long, num_args = 0..=1, default_missing_value = "true", value_name = "BOOL")]
pub reconnect: Option<bool>,
/// Base delay between reconnection attempts, in seconds.
#[arg(long = "retry-interval", value_name = "SECONDS")]
pub retry_interval: Option<u32>,
/// Cap on the doubling reconnection delay, in seconds.
#[arg(long = "backoff-max", value_name = "SECONDS")]
pub backoff_max: Option<u32>,
/// SSH ServerAliveInterval, in seconds.
#[arg(long, value_name = "SECONDS")]
pub keepalive: Option<u32>,
}
#[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<String>,
/// Run attached in the current shell instead of detaching.
#[arg(short, long)]
pub foreground: bool,
/// Open without auto-reconnect, regardless of the profile setting.
#[arg(long)]
pub once: bool,
/// Open every profile with reconnect enabled that isn't already open.
#[arg(long)]
pub all: bool,
}
#[derive(Args)]
pub struct CloseArgs {
/// Name of the profile to close.
pub name: String,
/// 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,
}

22
src/commands/add.rs Normal file
View File

@@ -0,0 +1,22 @@
use crate::cli::AddArgs;
use crate::commands::edits_from_mapping;
use crate::error::{PortholeError, Result};
use crate::profile::{self, Profile};
use crate::ui;
pub fn run(args: AddArgs) -> Result<()> {
profile::require_valid_name(&args.name)?;
let name = profile::normalize(&args.name);
if profile::exists(&name) {
return Err(PortholeError::AlreadyExists(name));
}
let edits = edits_from_mapping(&args.mapping);
let new_profile = Profile::new(name.clone(), &edits)?;
profile::save(&new_profile)?;
ui::ok(&format!("Saved profile '{name}' ({} {}).", new_profile.kind.label(), new_profile.mapping));
println!(" Run 'porthole open {name}' to start it.");
Ok(())
}

49
src/commands/close.rs Normal file
View File

@@ -0,0 +1,49 @@
use crate::cli::CloseArgs;
use crate::error::Result;
use crate::{instance, profile, ui};
use std::time::{Duration, Instant};
const GRACEFUL_WAIT: Duration = Duration::from_secs(5);
pub fn run(args: CloseArgs) -> Result<()> {
let name = profile::normalize(&args.name);
profile::load(&name)?; // validate the profile itself exists
if close_instance(&name, args.force)? {
ui::ok(&format!("Closed '{name}'."));
} else {
ui::info(&format!("'{name}' is not open."));
}
Ok(())
}
/// Stops `name`'s supervisor if one is actually running, and clears any
/// stale instance file either way (spec §5.3) - shared with `remove` and
/// `wipe`. Returns whether anything was actually running.
pub fn close_instance(name: &str, force: bool) -> Result<bool> {
let Some(pid) = instance::running_pid(name)? else {
instance::delete(name)?; // clears a stale file left by a crash
return Ok(false);
};
if force {
// spec §3 step 4: SIGKILL the whole process group (the supervisor
// is its own group leader via setsid), not just the supervisor pid
// - a plain single-pid SIGKILL would leave `ssh` orphaned.
unsafe { libc::kill(-pid, libc::SIGKILL) };
} else {
unsafe { libc::kill(pid, libc::SIGTERM) };
let deadline = Instant::now() + GRACEFUL_WAIT;
while instance::process_alive(pid) && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(100));
}
if instance::process_alive(pid) {
unsafe { libc::kill(-pid, libc::SIGKILL) };
}
}
// The supervisor removes its own instance file on a clean SIGTERM
// shutdown; this covers the force-killed case where it never got to.
instance::delete(name)?;
Ok(true)
}

View File

@@ -0,0 +1,9 @@
use crate::cli::Cli;
use clap::CommandFactory;
use clap_complete::{generate, Shell};
pub fn run(shell: Shell) {
let mut cmd = Cli::command();
let name = cmd.get_name().to_string();
generate(shell, &mut cmd, name, &mut std::io::stdout());
}

31
src/commands/edit.rs Normal file
View File

@@ -0,0 +1,31 @@
use crate::cli::EditArgs;
use crate::commands::{edits_from_mapping, mapping_is_empty};
use crate::error::{PortholeError, Result};
use crate::{instance, profile, ui};
pub fn run(args: EditArgs) -> Result<()> {
if mapping_is_empty(&args.mapping) {
return Err(PortholeError::NothingToDo(
"pass at least one of -l/-r/-d, --via, --user, --identity, --port, --reconnect, \
--retry-interval, --backoff-max, --keepalive."
.into(),
));
}
let name = profile::normalize(&args.name);
let mut p = profile::load(&name)?;
let edits = edits_from_mapping(&args.mapping);
p.apply_edits(&edits)?;
profile::save(&p)?;
// Spec §5.4: edit never restarts a running instance - just warn.
if instance::running_pid(&name)?.is_some() {
ui::warn(&format!(
"'{name}' is currently open; this change won't take effect until the next open/close cycle."
));
}
ui::ok(&format!("Updated profile '{name}'."));
Ok(())
}

120
src/commands/list.rs Normal file
View File

@@ -0,0 +1,120 @@
use crate::cli::ListArgs;
use crate::error::Result;
use crate::{instance, profile, timefmt, ui};
use serde::Serialize;
struct Row {
name: String,
kind: String,
mapping: String,
via: String,
state: String,
uptime: String,
}
pub fn run(args: ListArgs) -> Result<()> {
let profiles = profile::list_all()?;
if profiles.is_empty() {
ui::info("No profiles saved.");
return Ok(());
}
let mut rows = Vec::new();
for p in &profiles {
let inst = instance::load(&p.name)?;
let live = inst.as_ref().is_some_and(|i| instance::supervisor_alive(i.pid, &p.name));
let (state, uptime) = match &inst {
None => ("closed".to_string(), String::new()),
Some(_) if !live => ("error".to_string(), String::new()),
Some(i) => (
i.state.label().to_string(),
i.connected_at.map(|c| timefmt::fmt_duration(timefmt::now() - c)).unwrap_or_default(),
),
};
if args.running && !matches!(state.as_str(), "up" | "reconnecting") {
continue;
}
rows.push(Row {
name: p.name.clone(),
kind: p.kind.label().to_string(),
mapping: p.mapping.clone(),
via: p.via.join(","),
state,
uptime,
});
}
if args.json {
print_json(&rows);
return Ok(());
}
if rows.is_empty() {
ui::info("No matching profiles.");
return Ok(());
}
print_table(&rows);
Ok(())
}
fn col(i: usize, r: &Row) -> &str {
match i {
0 => &r.name,
1 => &r.kind,
2 => &r.mapping,
3 => &r.via,
4 => &r.state,
_ => &r.uptime,
}
}
fn print_table(rows: &[Row]) {
let headers = ["NAME", "KIND", "MAPPING", "VIA", "STATE", "UPTIME"];
let widths: Vec<usize> =
(0..6).map(|i| rows.iter().map(|r| col(i, r).len()).max().unwrap_or(0).max(headers[i].len())).collect();
let header_line: Vec<String> = headers.iter().enumerate().map(|(i, h)| format!("{h:<w$}", w = widths[i])).collect();
println!("{}", ui::blue(&header_line.join(" ")));
for r in rows {
let state_colored = match r.state.as_str() {
"up" => ui::green(&r.state),
"reconnecting" => ui::yellow(&r.state),
"error" => ui::red(&r.state),
_ => r.state.clone(),
};
let cells = [
format!("{:<w$}", r.name, w = widths[0]),
format!("{:<w$}", r.kind, w = widths[1]),
format!("{:<w$}", r.mapping, w = widths[2]),
format!("{:<w$}", r.via, w = widths[3]),
// padded on the uncolored text width, then swapped for the
// colored version so ANSI codes don't throw off alignment
format!("{:<w$}", r.state, w = widths[4]).replacen(&r.state, &state_colored, 1),
format!("{:<w$}", r.uptime, w = widths[5]),
];
println!("{}", cells.join(" "));
}
}
#[derive(Serialize)]
struct RowJson<'a> {
name: &'a str,
kind: &'a str,
mapping: &'a str,
via: &'a str,
state: &'a str,
uptime: &'a str,
}
fn print_json(rows: &[Row]) {
let out: Vec<RowJson> = rows
.iter()
.map(|r| RowJson { name: &r.name, kind: &r.kind, mapping: &r.mapping, via: &r.via, state: &r.state, uptime: &r.uptime })
.collect();
if let Ok(text) = serde_json::to_string_pretty(&out) {
println!("{text}");
}
}

50
src/commands/mod.rs Normal file
View File

@@ -0,0 +1,50 @@
pub mod add;
pub mod close;
pub mod completions;
pub mod edit;
pub mod list;
pub mod open;
pub mod remove;
pub mod status;
pub mod wipe;
use crate::cli::MappingArgs;
use crate::profile::ProfileEdits;
/// Turns clap's `MappingArgs` into a `ProfileEdits` (spec §5.1). `--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()
}

131
src/commands/open.rs Normal file
View File

@@ -0,0 +1,131 @@
//! `open` - spec §3/§5.2. Validates, then either runs the supervisor loop
//! inline (`--foreground`) or spawns a detached copy of this binary
//! (`porthole __supervise <name>`) and waits briefly for it to confirm.
use crate::cli::OpenArgs;
use crate::error::{PortholeError, Result};
use crate::{instance, profile, supervisor, ui};
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
const CONFIRM_TIMEOUT: Duration = Duration::from_secs(5);
const CONFIRM_POLL: Duration = Duration::from_millis(150);
pub fn run(args: OpenArgs) -> Result<()> {
if args.all {
return open_all(args.once);
}
let Some(raw_name) = &args.name else {
return Err(PortholeError::NothingToDo("pass a profile name, or --all.".into()));
};
let name = profile::normalize(raw_name);
profile::load(&name)?; // validate the profile exists
open_one(&name, args.foreground, args.once)
}
/// Opens every `reconnect: true` profile that isn't already running - the
/// hook external autostart mechanisms are meant to call (spec §5.2/§8).
/// Per-profile failures are warnings, not a whole-batch failure.
fn open_all(once: bool) -> Result<()> {
let profiles = profile::list_all()?;
let mut opened = 0;
let mut failed = 0;
for p in profiles.iter().filter(|p| p.reconnect) {
if instance::running_pid(&p.name)?.is_some() {
continue;
}
match open_one(&p.name, false, once) {
Ok(()) => opened += 1,
Err(e) => {
failed += 1;
ui::warn(&format!("'{}': {e}", p.name));
}
}
}
if failed > 0 {
ui::ok(&format!("Opened {opened} profile(s), {failed} failed."));
} else {
ui::ok(&format!("Opened {opened} profile(s)."));
}
Ok(())
}
fn open_one(name: &str, foreground: bool, once: bool) -> Result<()> {
if let Some(pid) = instance::running_pid(name)? {
ui::info(&format!("'{name}' is already open (pid {pid})."));
return Ok(());
}
// Clear a stale instance file left by a crash before spawning. The
// lock, not this file, is the authority on "already open" (spec
// §5.2) - 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 <name>` fully detached (spec §3 steps 1-2):
/// stdin from `/dev/null`, stdout/stderr appended to the profile's log, and
/// `setsid()` in the child so it leaves this process's session and survives
/// the terminal closing.
fn spawn_detached(name: &str, once: bool) -> Result<()> {
let exe = std::env::current_exe()?;
let log_path = instance::log_path(name);
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent)?;
}
let log_file = std::fs::OpenOptions::new().create(true).append(true).open(&log_path)?;
let mut cmd = Command::new(exe);
cmd.args(["__supervise", name]).stdin(Stdio::null()).stdout(log_file.try_clone()?).stderr(log_file);
if once {
cmd.env("PORTHOLE_SUPERVISE_ONCE", "1");
}
unsafe {
cmd.pre_exec(|| if libc::setsid() < 0 { Err(std::io::Error::last_os_error()) } else { Ok(()) });
}
cmd.spawn()?;
Ok(())
}
/// Blocks briefly for the detached supervisor to reach a conclusive state,
/// so an immediate failure (bad auth, bind conflict, unresolvable host) is
/// reported with a non-zero exit instead of `open` appearing to succeed
/// (spec §5.2). The instance file's initial write is always
/// `State::Reconnecting`, 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);
}
}

24
src/commands/remove.rs Normal file
View File

@@ -0,0 +1,24 @@
use crate::cli::RemoveArgs;
use crate::commands::close::close_instance;
use crate::error::Result;
use crate::{instance, profile, ui};
pub fn run(args: RemoveArgs) -> Result<()> {
let name = profile::normalize(&args.name);
profile::load(&name)?; // validate existence
if args.keep_running {
if instance::running_pid(&name)?.is_some() {
ui::warn(&format!(
"'{name}' left running untracked - it's no longer visible to 'list'/'status', \
only 'wipe' will still find it."
));
}
} else {
close_instance(&name, false)?;
}
profile::delete(&name)?;
ui::ok(&format!("Removed profile '{name}'."));
Ok(())
}

105
src/commands/status.rs Normal file
View File

@@ -0,0 +1,105 @@
use crate::cli::StatusArgs;
use crate::error::Result;
use crate::instance::{Instance, State};
use crate::profile::{self, Profile};
use crate::{instance, timefmt, ui};
use serde::Serialize;
pub fn run(args: StatusArgs) -> Result<()> {
let name = profile::normalize(&args.name);
let p = profile::load(&name)?;
let inst = instance::load(&name)?;
// An instance file whose pid isn't actually alive means the supervisor
// crashed without cleaning up - report that, rather than trusting a
// state the process table disagrees with (spec §2.2).
let live = inst.as_ref().is_some_and(|i| instance::supervisor_alive(i.pid, &name));
if args.json {
print_json(&p, inst.as_ref(), live);
return Ok(());
}
println!("{}", ui::blue(&p.name));
println!(" kind: {}", p.kind.label());
println!(" mapping: {}", p.mapping);
println!(" via: {}", p.via.join(","));
if let Some(user) = &p.user {
println!(" user: {user}");
}
println!(" reconnect: {}", p.reconnect);
match &inst {
None => println!(" state: {}", ui::yellow("closed")),
Some(i) if !live => {
println!(" state: {}", ui::red("error (supervisor process not found)"));
if let Some(err) = &i.last_error {
println!(" last error: {err}");
}
}
Some(i) => {
let label = match i.state {
State::Up => ui::green(i.state.label()),
State::Reconnecting => ui::yellow(i.state.label()),
State::Error => ui::red(i.state.label()),
};
println!(" state: {label}");
println!(" session uptime: {}", timefmt::fmt_duration(timefmt::now() - i.opened_at));
if let Some(connected_at) = i.connected_at {
println!(" connection uptime: {}", timefmt::fmt_duration(timefmt::now() - connected_at));
}
println!(" reconnect count: {}", i.reconnect_count);
if let Some(t) = i.last_reconnect_at {
println!(" last reconnect: {}", timefmt::fmt_timestamp(t));
}
if let Some(err) = &i.last_error {
println!(" last error: {err}");
}
println!(" log: {}", instance::log_path(&name).display());
}
}
Ok(())
}
#[derive(Serialize)]
struct StatusJson<'a> {
name: &'a str,
kind: &'static str,
mapping: &'a str,
via: &'a [String],
user: Option<&'a str>,
reconnect: bool,
state: &'static str,
session_uptime_secs: Option<i64>,
connection_uptime_secs: Option<i64>,
reconnect_count: Option<u32>,
last_reconnect_at: Option<i64>,
last_error: Option<&'a str>,
log: Option<String>,
}
fn print_json(p: &Profile, inst: Option<&Instance>, live: bool) {
let state = match (inst, live) {
(None, _) => "closed",
(Some(_), false) => "error",
(Some(i), true) => i.state.label(),
};
let now = timefmt::now();
let json = StatusJson {
name: &p.name,
kind: p.kind.label(),
mapping: &p.mapping,
via: &p.via,
user: p.user.as_deref(),
reconnect: p.reconnect,
state,
session_uptime_secs: inst.map(|i| now - i.opened_at),
connection_uptime_secs: inst.and_then(|i| i.connected_at).map(|c| now - c),
reconnect_count: inst.map(|i| i.reconnect_count),
last_reconnect_at: inst.and_then(|i| i.last_reconnect_at),
last_error: inst.and_then(|i| i.last_error.as_deref()),
log: inst.map(|_| instance::log_path(&p.name).display().to_string()),
};
if let Ok(text) = serde_json::to_string_pretty(&json) {
println!("{text}");
}
}

59
src/commands/wipe.rs Normal file
View File

@@ -0,0 +1,59 @@
use crate::cli::WipeArgs;
use crate::commands::close::close_instance;
use crate::error::Result;
use crate::{profile, ui};
use std::io::Write;
pub fn run(args: WipeArgs) -> Result<()> {
let profiles = profile::list_all()?;
if !args.yes {
print!(
"This will close and delete every forward, including any untracked ones. Continue? [y/N] "
);
std::io::stdout().flush().ok();
let mut answer = String::new();
std::io::stdin().read_line(&mut answer).ok();
if !matches!(answer.trim().to_lowercase().as_str(), "y" | "yes") {
ui::info("Aborted.");
return Ok(());
}
}
let mut closed = 0;
for p in &profiles {
if close_instance(&p.name, false)? {
closed += 1;
}
profile::delete(&p.name)?;
}
let orphans = kill_orphaned_supervisors();
if profiles.is_empty() && orphans == 0 {
ui::info("Nothing to wipe: no profiles or forwards found.");
} else {
ui::ok("Wiped all forwards:");
println!(" profiles deleted: {}", profiles.len());
println!(" running forwards closed: {closed}");
println!(" orphaned supervisors killed: {orphans}");
}
Ok(())
}
/// Kills any supervisor process not backed by a tracked profile (e.g. one
/// orphaned after a crash), matched by cmdline rather than tracked state
/// (spec §5.8). 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::<libc::pid_t>() else { continue };
let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else { continue };
if String::from_utf8_lossy(&cmdline).contains("__supervise") && unsafe { libc::kill(-pid, libc::SIGTERM) } == 0 {
killed += 1;
}
}
killed
}

63
src/error.rs Normal file
View File

@@ -0,0 +1,63 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum PortholeError {
#[error(
"invalid name '{0}' (use 1-64 chars: letters, digits, '_' or '-'; \
must start with a letter or digit)"
)]
InvalidName(String),
#[error("no profile named '{0}'")]
NotFound(String),
#[error("profile '{0}' already exists (use 'edit' to modify it)")]
AlreadyExists(String),
#[error("exactly one of -l/--local, -r/--remote, -d/--dynamic is required")]
NoMappingKind,
#[error("only one of -l/--local, -r/--remote, -d/--dynamic may be given")]
MultipleMappingKinds,
#[error("invalid forward spec '{0}': expected [BIND:]PORT:HOST: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("nothing to do: {0}")]
NothingToDo(String),
#[error("'{0}' failed to start: {1}")]
OpenFailed(String, String),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("state file error: {0}")]
Serde(String),
}
pub type Result<T> = std::result::Result<T, PortholeError>;
impl From<toml::de::Error> for PortholeError {
fn from(e: toml::de::Error) -> Self {
PortholeError::Serde(e.to_string())
}
}
impl From<toml::ser::Error> for PortholeError {
fn from(e: toml::ser::Error) -> Self {
PortholeError::Serde(e.to_string())
}
}
impl From<serde_json::Error> for PortholeError {
fn from(e: serde_json::Error) -> Self {
PortholeError::Serde(e.to_string())
}
}

163
src/instance.rs Normal file
View File

@@ -0,0 +1,163 @@
//! Runtime state for one open profile - spec §2.2/§2.3. Written only by the
//! supervisor (`src/supervisor.rs`); everything else here just reads it.
use crate::error::Result;
use crate::{atomic, timefmt};
use serde::{Deserialize, Serialize};
use std::fs::{File, OpenOptions};
use std::os::unix::io::AsRawFd;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum State {
Up,
Reconnecting,
Error,
}
impl State {
pub fn label(self) -> &'static str {
match self {
State::Up => "up",
State::Reconnecting => "reconnecting",
State::Error => "error",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Instance {
pub name: String,
pub pid: i32,
pub state: State,
/// Anchor for "session uptime" (spec §5.5) - set once, when `open` starts.
pub opened_at: i64,
/// Start of the current unbroken connection; resets each reconnect.
pub connected_at: Option<i64>,
pub last_error: Option<String>,
pub reconnect_count: u32,
pub last_reconnect_at: Option<i64>,
}
impl Instance {
pub fn new(name: String, pid: i32) -> Self {
Self {
name,
pid,
state: State::Reconnecting,
opened_at: timefmt::now(),
connected_at: None,
last_error: None,
reconnect_count: 0,
last_reconnect_at: None,
}
}
}
fn state_dir() -> PathBuf {
if let Ok(dir) = std::env::var("PORTHOLE_STATE_DIR_OVERRIDE") {
return PathBuf::from(dir);
}
dirs::state_dir()
.or_else(dirs::data_local_dir)
.expect("could not resolve state dir")
.join("porthole")
}
pub fn instance_path(name: &str) -> PathBuf {
state_dir().join(format!("{name}.json"))
}
pub fn lock_path(name: &str) -> PathBuf {
state_dir().join(format!("{name}.lock"))
}
pub fn log_path(name: &str) -> PathBuf {
state_dir().join(format!("{name}.log"))
}
/// Loads the instance file for `name`, if any. `None` means closed - the
/// absence of this file is the closed state (spec §2.2); there is no
/// separate enum value for it.
pub fn load(name: &str) -> Result<Option<Instance>> {
let path = instance_path(name);
match std::fs::read_to_string(&path) {
Ok(text) => Ok(Some(serde_json::from_str(&text)?)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
pub fn save(instance: &Instance) -> Result<()> {
let dir = state_dir();
std::fs::create_dir_all(&dir)?;
let text = serde_json::to_string_pretty(instance)?;
atomic::write(&instance_path(&instance.name), text.as_bytes())?;
Ok(())
}
pub fn delete(name: &str) -> Result<()> {
match std::fs::remove_file(instance_path(name)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
pub fn process_alive(pid: i32) -> bool {
unsafe { libc::kill(pid, 0) == 0 }
}
/// True only if `pid` is a live process whose cmdline 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 actually running right now
/// (checked against the process table, not just the instance file's
/// last-known value - so a crash or reboot is detected as "not running"
/// rather than trusting stale on-disk state).
pub fn running_pid(name: &str) -> Result<Option<i32>> {
match load(name)? {
Some(inst) if supervisor_alive(inst.pid, name) => Ok(Some(inst.pid)),
_ => Ok(None),
}
}
/// Advisory `flock` held for the supervisor's entire lifetime (spec §2.3).
/// 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-blockingly. `Ok(None)` means another live
/// process already holds it (i.e. this profile is already open).
pub fn try_acquire(name: &str) -> Result<Option<Self>> {
let dir = state_dir();
std::fs::create_dir_all(&dir)?;
let file = OpenOptions::new().create(true).write(true).open(lock_path(name))?;
let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if ret == 0 {
Ok(Some(Self { _file: file }))
} else {
let errno = std::io::Error::last_os_error();
if errno.raw_os_error() == Some(libc::EWOULDBLOCK) {
Ok(None)
} else {
Err(errno.into())
}
}
}
}

204
src/main.rs Normal file
View File

@@ -0,0 +1,204 @@
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<String> = 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::Completions { shell } => {
commands::completions::run(shell);
Ok(())
}
// Internal: `open` re-execs into this. Never invoked by a user
// directly (spec §3) - deliberately not wrapped in any of the
// normal command ceremony (no "profile exists" re-check etc.),
// since by the time we're here `open` has already done that.
Commands::Supervise { name } => supervisor::run(&name),
};
if let Err(e) = result {
ui::err(&e.to_string());
std::process::exit(1);
}
}
/// 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("<COMMAND>"), 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<Vec<(String, String)>> = 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::<Vec<_>>().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}");
}
}
/// `<name>` for each positional arg of `cmd`, space-joined and colored cyan.
fn positional_args(cmd: &clap::Command) -> String {
cmd.get_positionals()
.map(|a| ui::cyan(&format!("<{}>", a.get_id().as_str())))
.collect::<Vec<_>>()
.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<String> {
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))))
}

373
src/profile.rs Normal file
View File

@@ -0,0 +1,373 @@
//! Persisted forward definitions - spec §2.1. One TOML file per profile at
//! `~/.config/porthole/profiles/<name>.toml`.
use crate::error::{PortholeError, Result};
use crate::{atomic, timefmt};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
Local,
Remote,
Dynamic,
}
impl Kind {
/// The `ssh` forward flag this kind maps to (`-L`/`-R`/`-D`).
pub fn ssh_flag(self) -> &'static str {
match self {
Kind::Local => "-L",
Kind::Remote => "-R",
Kind::Dynamic => "-D",
}
}
pub fn label(self) -> &'static str {
match self {
Kind::Local => "local",
Kind::Remote => "remote",
Kind::Dynamic => "dynamic",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Profile {
pub name: String,
pub kind: Kind,
/// Raw `-L/-R/-D` payload, without the flag itself - see spec §5.1.
pub mapping: String,
/// Ordered hop list, each `[user@]host[:port]` - see spec §3.1.
#[serde(default)]
pub via: Vec<String>,
pub user: Option<String>,
pub identity: Option<String>,
#[serde(default = "default_ssh_port")]
pub ssh_port: u16,
#[serde(default = "default_true")]
pub reconnect: bool,
#[serde(default = "default_retry_interval")]
pub retry_interval: u32,
#[serde(default = "default_backoff_max")]
pub backoff_max: u32,
#[serde(default = "default_keepalive")]
pub keepalive: u32,
pub created_at: i64,
pub updated_at: i64,
}
fn default_ssh_port() -> u16 {
22
}
fn default_true() -> bool {
true
}
fn default_retry_interval() -> u32 {
5
}
fn default_backoff_max() -> u32 {
60
}
fn default_keepalive() -> u32 {
15
}
/// Flags shared by `add`/`edit` for building/patching a [`Profile`].
#[derive(Debug, Default)]
pub struct ProfileEdits {
pub local: Option<String>,
pub remote: Option<String>,
pub dynamic: Option<String>,
pub via: Option<Vec<String>>,
pub user: Option<String>,
pub identity: Option<String>,
pub port: Option<u16>,
pub reconnect: Option<bool>,
pub retry_interval: Option<u32>,
pub backoff_max: Option<u32>,
pub keepalive: Option<u32>,
}
impl ProfileEdits {
fn mapping_kind(&self) -> Result<Option<(Kind, &str)>> {
let given: Vec<(Kind, &str)> = [
self.local.as_deref().map(|m| (Kind::Local, m)),
self.remote.as_deref().map(|m| (Kind::Remote, m)),
self.dynamic.as_deref().map(|m| (Kind::Dynamic, m)),
]
.into_iter()
.flatten()
.collect();
match given.len() {
0 => Ok(None),
1 => Ok(Some(given[0])),
_ => Err(PortholeError::MultipleMappingKinds),
}
}
}
fn valid_name(name: &str) -> bool {
let mut chars = name.chars();
let Some(first) = chars.next() else { return false };
if !first.is_ascii_alphanumeric() {
return false;
}
name.len() <= 64 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
pub fn require_valid_name(name: &str) -> Result<()> {
if valid_name(name) {
Ok(())
} else {
Err(PortholeError::InvalidName(name.to_string()))
}
}
/// Validates a `-l/-r` payload (`[bind:]port:host:hostport`) or `-d` payload
/// (`[bind:]port`) against the same shape `ssh` itself expects.
fn validate_mapping(kind: Kind, mapping: &str) -> Result<()> {
let bad = || PortholeError::InvalidMapping(mapping.to_string());
let parts: Vec<&str> = mapping.split(':').collect();
let valid_port = |s: &str| s.parse::<u16>().is_ok() && !s.is_empty();
match kind {
Kind::Dynamic => match parts.as_slice() {
[port] if valid_port(port) => Ok(()),
[_bind, port] if valid_port(port) => Ok(()),
_ => Err(bad()),
},
Kind::Local | Kind::Remote => match parts.as_slice() {
[port, host, hostport] if valid_port(port) && !host.is_empty() && valid_port(hostport) => Ok(()),
[_bind, port, host, hostport] if valid_port(port) && !host.is_empty() && valid_port(hostport) => Ok(()),
_ => Err(bad()),
},
}
}
/// Validates a `--via` hop (`[user@]host[:port]`).
fn validate_via_hop(hop: &str) -> Result<()> {
let bad = || PortholeError::InvalidVia(hop.to_string());
let host_port = hop.rsplit_once('@').map(|(_, rest)| rest).unwrap_or(hop);
if host_port.is_empty() {
return Err(bad());
}
if let Some((host, port)) = host_port.rsplit_once(':') {
if host.is_empty() || port.parse::<u16>().is_err() {
return Err(bad());
}
}
Ok(())
}
impl Profile {
/// Builds a brand-new profile from `add`'s flags.
pub fn new(name: String, edits: &ProfileEdits) -> Result<Self> {
let Some((kind, mapping)) = edits.mapping_kind()? else {
return Err(PortholeError::NoMappingKind);
};
validate_mapping(kind, mapping)?;
let via = edits.via.clone().unwrap_or_default();
if via.is_empty() {
return Err(PortholeError::NoViaHosts);
}
for hop in &via {
validate_via_hop(hop)?;
}
let now = timefmt::now();
Ok(Self {
name,
kind,
mapping: mapping.to_string(),
via,
user: edits.user.clone(),
identity: edits.identity.clone(),
ssh_port: edits.port.unwrap_or_else(default_ssh_port),
reconnect: edits.reconnect.unwrap_or_else(default_true),
retry_interval: edits.retry_interval.unwrap_or_else(default_retry_interval),
backoff_max: edits.backoff_max.unwrap_or_else(default_backoff_max),
keepalive: edits.keepalive.unwrap_or_else(default_keepalive),
created_at: now,
updated_at: now,
})
}
/// Applies `edits` on top of an existing profile (`edit`'s semantics:
/// only provided fields change).
pub fn apply_edits(&mut self, edits: &ProfileEdits) -> Result<()> {
if let Some((kind, mapping)) = edits.mapping_kind()? {
validate_mapping(kind, mapping)?;
self.kind = kind;
self.mapping = mapping.to_string();
}
if let Some(via) = &edits.via {
if via.is_empty() {
return Err(PortholeError::NoViaHosts);
}
for hop in via {
validate_via_hop(hop)?;
}
self.via = via.clone();
}
if let Some(user) = &edits.user {
self.user = Some(user.clone());
}
if let Some(identity) = &edits.identity {
self.identity = Some(identity.clone());
}
if let Some(port) = edits.port {
self.ssh_port = port;
}
if let Some(reconnect) = edits.reconnect {
self.reconnect = reconnect;
}
if let Some(v) = edits.retry_interval {
self.retry_interval = v;
}
if let Some(v) = edits.backoff_max {
self.backoff_max = v;
}
if let Some(v) = edits.keepalive {
self.keepalive = v;
}
self.updated_at = timefmt::now();
Ok(())
}
/// Splits `via` into the `-J` jump-chain value (comma-joined, all but
/// the last hop; `None` for a single-hop `via`) and the final `ssh`
/// connection target - see spec §3.1. Every path that constructs a
/// `Profile` validates `via` as non-empty.
pub fn ssh_target(&self) -> (Option<String>, &str) {
match self.via.split_last() {
Some((target, jumps)) if !jumps.is_empty() => (Some(jumps.join(",")), target.as_str()),
Some((target, _)) => (None, target.as_str()),
None => (None, ""),
}
}
}
pub fn normalize(name: &str) -> String {
name.to_lowercase()
}
fn profiles_dir() -> PathBuf {
if let Ok(dir) = std::env::var("PORTHOLE_STATE_DIR_OVERRIDE") {
return PathBuf::from(dir).join("profiles");
}
dirs::config_dir().expect("could not resolve config dir").join("porthole").join("profiles")
}
fn profile_path(name: &str) -> PathBuf {
profiles_dir().join(format!("{name}.toml"))
}
pub fn exists(name: &str) -> bool {
profile_path(name).is_file()
}
pub fn load(name: &str) -> Result<Profile> {
require_valid_name(name)?;
let path = profile_path(name);
let text = std::fs::read_to_string(&path).map_err(|_| PortholeError::NotFound(name.to_string()))?;
Ok(toml::from_str(&text)?)
}
pub fn save(profile: &Profile) -> Result<()> {
let dir = profiles_dir();
std::fs::create_dir_all(&dir)?;
let text = toml::to_string_pretty(profile)?;
atomic::write(&profile_path(&profile.name), text.as_bytes())?;
Ok(())
}
pub fn delete(name: &str) -> Result<()> {
let path = profile_path(name);
std::fs::remove_file(&path).map_err(|_| PortholeError::NotFound(name.to_string()))?;
Ok(())
}
/// Lists every saved profile, sorted by name.
pub fn list_all() -> Result<Vec<Profile>> {
let dir = profiles_dir();
if !dir.is_dir() {
return Ok(Vec::new());
}
let mut names: Vec<String> = std::fs::read_dir(&dir)?
.flatten()
.filter_map(|e| {
let path = e.path();
(path.extension().and_then(|x| x.to_str()) == Some("toml"))
.then(|| path.file_stem().and_then(|s| s.to_str()).map(str::to_string))
.flatten()
})
.collect();
names.sort();
let mut out = Vec::with_capacity(names.len());
for name in names {
out.push(load(&name)?);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_local_mapping() {
assert!(validate_mapping(Kind::Local, "5432:db.internal:5432").is_ok());
assert!(validate_mapping(Kind::Local, "127.0.0.1:5432:db.internal:5432").is_ok());
assert!(validate_mapping(Kind::Local, "not-a-port:db.internal:5432").is_err());
assert!(validate_mapping(Kind::Local, "5432:db.internal").is_err());
}
#[test]
fn validates_dynamic_mapping() {
assert!(validate_mapping(Kind::Dynamic, "1080").is_ok());
assert!(validate_mapping(Kind::Dynamic, "0.0.0.0:1080").is_ok());
assert!(validate_mapping(Kind::Dynamic, "abc").is_err());
}
#[test]
fn validates_via_hops() {
assert!(validate_via_hop("jumpbox").is_ok());
assert!(validate_via_hop("ops@jumpbox").is_ok());
assert!(validate_via_hop("jumpbox:2222").is_ok());
assert!(validate_via_hop("ops@jumpbox:2222").is_ok());
assert!(validate_via_hop("jumpbox:notaport").is_err());
assert!(validate_via_hop("").is_err());
}
#[test]
fn splits_via_into_jumps_and_target() {
let mut p = Profile::new(
"t".into(),
&ProfileEdits { local: Some("80:h:80".into()), via: Some(vec!["jumpbox".into()]), ..Default::default() },
)
.unwrap();
assert_eq!(p.ssh_target(), (None, "jumpbox"));
p.via = vec!["bastion1".into(), "bastion2:2222".into()];
assert_eq!(p.ssh_target(), (Some("bastion1".into()), "bastion2:2222"));
}
#[test]
fn rejects_empty_via() {
let edits = ProfileEdits { local: Some("80:h:80".into()), via: Some(vec![]), ..Default::default() };
assert!(matches!(Profile::new("t".into(), &edits), Err(PortholeError::NoViaHosts)));
}
#[test]
fn rejects_multiple_mapping_kinds() {
let edits = ProfileEdits {
local: Some("8080:localhost:8080".into()),
remote: Some("9000:localhost:9000".into()),
..Default::default()
};
assert!(matches!(edits.mapping_kind(), Err(PortholeError::MultipleMappingKinds)));
}
}

89
src/ssh.rs Normal file
View File

@@ -0,0 +1,89 @@
//! Builds the `ssh` invocation for a profile - spec §3.1.
use crate::profile::Profile;
use std::process::{Command, Stdio};
/// Builds the `ssh` command for `profile`, stdio wired for the supervisor
/// to capture (stdout/stderr piped so failure text can be classified per
/// spec §4.2; stdin from `/dev/null` since porthole never wants a shell).
pub fn build(profile: &Profile) -> Command {
let mut cmd = Command::new("ssh");
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
// Forced flags, see spec §3.1.
cmd.args([
"-o",
"BatchMode=yes",
"-o",
"ExitOnForwardFailure=yes",
"-o",
"ConnectTimeout=10",
"-o",
&format!("ServerAliveInterval={}", profile.keepalive),
"-N",
]);
let (jumps, target) = profile.ssh_target();
if let Some(jumps) = jumps {
cmd.args(["-J", &jumps]);
}
cmd.arg("-p").arg(profile.ssh_port.to_string());
if let Some(identity) = &profile.identity {
cmd.arg("-i").arg(identity);
}
if let Some(user) = &profile.user {
cmd.arg("-l").arg(user);
}
cmd.arg(profile.kind.ssh_flag()).arg(&profile.mapping);
cmd.arg(target);
cmd
}
#[cfg(test)]
mod tests {
use super::*;
use crate::profile::{Kind, ProfileEdits};
fn profile_with(via: Vec<&str>) -> Profile
{
Profile::new(
"t".into(),
&ProfileEdits {
local: Some("5432:db.internal:5432".into()),
via: Some(via.into_iter().map(String::from).collect()),
..Default::default()
},
)
.unwrap()
}
#[test]
fn single_hop_has_no_dash_j() {
let cmd = build(&profile_with(vec!["jumpbox"]));
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
assert!(!args.contains(&"-J".to_string()));
assert_eq!(args.last(), Some(&"jumpbox".to_string()));
}
#[test]
fn multi_hop_splits_jumps_from_target() {
let cmd = build(&profile_with(vec!["bastion1", "bastion2:2222"]));
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
let j_idx = args.iter().position(|a| a == "-J").expect("-J present");
assert_eq!(args[j_idx + 1], "bastion1");
assert_eq!(args.last(), Some(&"bastion2:2222".to_string()));
}
#[test]
fn includes_forward_flag_and_mapping() {
let p = profile_with(vec!["jumpbox"]);
assert_eq!(p.kind, Kind::Local);
let cmd = build(&p);
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
let l_idx = args.iter().position(|a| a == "-L").expect("-L present");
assert_eq!(args[l_idx + 1], "5432:db.internal:5432");
}
}

306
src/supervisor.rs Normal file
View File

@@ -0,0 +1,306 @@
//! The `__supervise` loop - spec §3/§4. Runs as a detached, re-exec'd copy
//! of this same binary (`porthole __supervise <name>`, see `main.rs`); owns
//! the `ssh` child process for one profile's entire supervised lifetime.
use crate::error::Result;
use crate::instance::{self, Instance, Lock, State};
use crate::profile::{self, Profile};
use crate::{ssh, timefmt};
use std::fs::OpenOptions;
use std::io::{BufRead, BufReader, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
/// How long a connection must survive before its uptime resets the backoff
/// counter back to the base delay - spec §4.1.
const STABLE_THRESHOLD_SECS: i64 = 60;
/// Consecutive unrecognized (not pattern-matched) failures before porthole
/// gives up on an apparently-permanently-broken profile - spec §4.2.
const MAX_UNRECOGNIZED_STREAK: u32 = 10;
/// How long `ssh` must stay alive before porthole 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, spec §5.2) 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 <name>`. This function is the
/// supervisor process: it runs until told to stop (SIGTERM/SIGINT) or
/// gives up per §4.
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` (§3.1), 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);
let mut cmd = ssh::build(profile);
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => return Outcome::Failed { class: Class::Unrecognized, message: format!("failed to spawn ssh: {e}") },
};
let stderr_tail = Arc::new(Mutex::new(String::new()));
let stdout_thread = child.stdout.take().map(|out| spawn_log_drain(name, out));
let stderr_thread = child.stderr.take().map(|err| spawn_stderr_drain(name, err, stderr_tail.clone()));
let grace_deadline = Instant::now() + CONNECT_GRACE;
let mut marked_up = false;
loop
{
if SHUTDOWN.load(Ordering::SeqCst) {
let _ = child.kill();
let _ = child.wait();
join_all([stdout_thread, stderr_thread]);
return Outcome::ShutdownRequested;
}
match child.try_wait()
{
Ok(Some(_status)) => break,
Ok(None) =>
{
if !marked_up && Instant::now() >= grace_deadline
{
marked_up = true;
inst.state = State::Up;
inst.connected_at = Some(timefmt::now());
let _ = instance::save(inst);
}
std::thread::sleep(POLL_INTERVAL);
}
Err(_) => break, // process table race (should not happen on unix); treat as exited
}
}
join_all([stdout_thread, stderr_thread]);
let tail = stderr_tail.lock().map(|s| s.clone()).unwrap_or_default();
let (class, message) = classify(&tail);
Outcome::Failed { class, message }
}
fn join_all<const N: usize>(handles: [Option<JoinHandle<()>>; N]) {
for h in handles.into_iter().flatten() {
let _ = h.join();
}
}
/// Classifies `ssh`'s captured stderr per spec §4.2. Fatal patterns stop
/// the reconnect loop outright; known-transient patterns retry without
/// counting toward the unrecognized-failure escalation; anything else
/// still retries, but does count toward it.
fn classify(stderr_tail: &str) -> (Class, String)
{
const FATAL: &[&str] = &["Permission denied", "Host key verification failed", "bind: Address already in use"];
const KNOWN_TRANSIENT: &[&str] = &[
"Connection refused",
"No route to host",
"Could not resolve hostname",
"Connection timed out",
"Operation timed out",
];
let message = stderr_tail.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("").trim().to_string();
if FATAL.iter().any(|p| stderr_tail.contains(p)) { (Class::Fatal, message) }
else if KNOWN_TRANSIENT.iter().any(|p| stderr_tail.contains(p)) { (Class::KnownTransient, message) }
else { (Class::Unrecognized, message) }
}
fn rotate_log_if_large(name: &str)
{
let path = instance::log_path(name);
if let Ok(meta) = std::fs::metadata(&path) {
if meta.len() > LOG_ROTATE_BYTES {
let _ = std::fs::rename(&path, path.with_extension("log.1"));
}
}
}
fn append_log(name: &str, line: &str) {
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(instance::log_path(name)) {
let _ = writeln!(f, "{line}");
}
}
fn spawn_log_drain(name: &str, out: std::process::ChildStdout) -> JoinHandle<()>
{
let name = name.to_string();
std::thread::spawn(move || {
for line in BufReader::new(out).lines().map_while(std::result::Result::ok) {
append_log(&name, &line);
}
})
}
fn spawn_stderr_drain(name: &str, err: std::process::ChildStderr, tail: Arc<Mutex<String>>) -> JoinHandle<()>
{
let name = name.to_string();
std::thread::spawn(move || {
for line in BufReader::new(err).lines().map_while(std::result::Result::ok)
{
append_log(&name, &line);
if let Ok(mut t) = tail.lock() {
if !t.is_empty() {
t.push('\n');
}
t.push_str(&line);
}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_fatal_patterns() {
assert!(matches!(classify("foo\nPermission denied (publickey).").0, Class::Fatal));
assert!(matches!(classify("bind: Address already in use").0, Class::Fatal));
}
#[test]
fn classifies_known_transient_patterns() {
assert!(matches!(classify("ssh: connect to host x port 22: Connection refused").0, Class::KnownTransient));
}
#[test]
fn classifies_unrecognized_as_transient() {
assert!(matches!(classify("something completely unexpected").0, Class::Unrecognized));
assert!(matches!(classify("").0, Class::Unrecognized));
}
}

73
src/timefmt.rs Normal file
View File

@@ -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");
}
}

32
src/ui.rs Normal file
View File

@@ -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<bool> = OnceLock::new();
*ENABLED.get_or_init(|| {
std::env::var_os("NO_COLOR").is_none()
&& std::env::var("TERM").map(|t| t != "dumb").unwrap_or(true)
&& std::io::stdout().is_terminal()
&& std::io::stderr().is_terminal()
})
}
fn paint(code: &str, s: &str) -> String
{
if color_enabled() { format!("\x1b[{code}m{s}\x1b[0m") }
else { s.to_string() }
}
pub fn red(s: &str) -> String { paint("31", s) }
pub fn yellow(s: &str) -> String { paint("33", s) }
pub fn green(s: &str) -> String { paint("32", s) }
pub fn blue(s: &str) -> String { paint("34", s) }
pub fn 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)); }