Compare commits
5 Commits
9a86a6293a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fdcccf864 | |||
| 6660717177 | |||
| 4d4a83f1e9 | |||
| 94d0c35e6b | |||
| 1ee8b122f3 |
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
|
||||||
|
.*
|
||||||
|
!.gitignore
|
||||||
|
|
||||||
|
target/
|
||||||
|
*.lock
|
||||||
|
!Cargo.lock
|
||||||
435
Cargo.lock
generated
Normal file
435
Cargo.lock
generated
Normal 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 = "1.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
"clap_complete",
|
||||||
|
"dirs",
|
||||||
|
"libc",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror",
|
||||||
|
"toml",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_users"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom",
|
||||||
|
"libredox",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.151"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "2.0.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "2.0.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "1.1.4+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde_core",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_parser",
|
||||||
|
"toml_writer",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "1.1.1+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_parser"
|
||||||
|
version = "1.1.3+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
||||||
|
dependencies = [
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_writer"
|
||||||
|
version = "1.1.2+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
32
Cargo.toml
Normal file
32
Cargo.toml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
[package]
|
||||||
|
name = "porthole"
|
||||||
|
version = "1.0.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Create and manage named SSH port forwards."
|
||||||
|
license = "AGPL-3+"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "porthole"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
clap_complete = "4"
|
||||||
|
|
||||||
|
thiserror = "^2"
|
||||||
|
dirs = "6"
|
||||||
|
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
toml = "^1"
|
||||||
|
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
|
# Keep panics from corrupting a partially-written state file - abort is
|
||||||
|
# fine for a CLI/supervisor as there's no long-lived in-process state to
|
||||||
|
# unwind (state lives on disk and is written atomically, see src/atomic.rs).
|
||||||
|
[profile.release]
|
||||||
|
panic = "abort"
|
||||||
|
lto = true
|
||||||
|
opt-level = 3
|
||||||
|
codegen-units = 1
|
||||||
165
README.md
165
README.md
@@ -1,3 +1,166 @@
|
|||||||
# porthole
|
# porthole
|
||||||
|
|
||||||
Create and manage named SSH port fowards easily.
|
Create and manage named SSH port forwards easily.
|
||||||
|
|
||||||
|
Porthole wraps `ssh` to turn tunnel commands into named profiles: define a
|
||||||
|
forward once, then open, close, and inspect it by name. Each open forward
|
||||||
|
runs under a small supervisor process that keeps it alive and reconnects
|
||||||
|
automatically if the connection drops.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Named profiles** for local, remote, and dynamic/SOCKS forwards, saved
|
||||||
|
to disk instead of retyped each time.
|
||||||
|
- **Multi-hop jump chains**, built on `ssh -J`.
|
||||||
|
- **Auto-reconnect** with exponential backoff, unless a failure looks
|
||||||
|
permanent (bad auth, host key mismatch, port already bound).
|
||||||
|
- **Status and listing** with live state, uptime, and reconnect counts, as
|
||||||
|
text or JSON.
|
||||||
|
- **Import/export** of profiles as a single TOML file, for moving a setup
|
||||||
|
to another machine.
|
||||||
|
- **Shell completions** for bash, zsh, fish, and others.
|
||||||
|
- **Hardened by default**: every spawned `ssh` call ignores the caller's
|
||||||
|
own config, runs in batch mode with no interactive prompts, and applies
|
||||||
|
the same hardening to every hop in a jump chain, not just the final
|
||||||
|
target.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Requires a Rust toolchain and an `ssh` binary on `PATH`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
The binary is written to `target/release/porthole`. Place it on `PATH`,
|
||||||
|
e.g.:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
install -Dm755 target/release/porthole ~/.local/bin/porthole
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Aliases | Description |
|
||||||
|
|---------------|----------------|-----------------------------------------------------------|
|
||||||
|
| `add` | `mk`, `create` | Save a new forward profile. |
|
||||||
|
| `open` | `start` | Start a saved forward as a supervised background process. |
|
||||||
|
| `close` | `stop` | Stop a running forward. |
|
||||||
|
| `edit` | `change` | Update a saved profile. |
|
||||||
|
| `status` | | Show detailed status for one forward. |
|
||||||
|
| `list` | `ls` | List all saved profiles with live status. |
|
||||||
|
| `remove` | `rm`, `delete` | Delete a saved profile. |
|
||||||
|
| `wipe` | `reset` | Close and delete every forward, tracked or not. |
|
||||||
|
| `transfer` | `data` | Export saved profiles to a file, or import them from one. |
|
||||||
|
| `completions` | | Generate a shell completion script. |
|
||||||
|
|
||||||
|
Run `porthole` or `porthole --help` for the full flag reference.
|
||||||
|
|
||||||
|
### `add` / `edit` flags
|
||||||
|
|
||||||
|
One of `-l/--local`, `-r/--remote`, or `-d/--dynamic` selects the forward
|
||||||
|
kind (required for `add`, optional for `edit`):
|
||||||
|
|
||||||
|
| Flag | Value | Meaning | Default |
|
||||||
|
|--------------------|-------------------------|-------------------------------------------------------------|:-------:|
|
||||||
|
| `-l, --local` | `[BIND:]PORT:HOST:PORT` | Local forward (this machine -> remote machine). | - |
|
||||||
|
| `-r, --remote` | `[BIND:]PORT:HOST:PORT` | Remote forward (remote machine -> this machine). | - |
|
||||||
|
| `-d, --dynamic` | `[BIND:]PORT` | Dynamic forward (SOCKS proxy). | - |
|
||||||
|
| `--via` | `[USER@]HOST[:PORT]` | Jump-host chain, ending at the connection target. Required. | - |
|
||||||
|
| `-u, --user` | `USER` | Default user for the target and any hop without one. | - |
|
||||||
|
| `-i, --identity` | `PATH` | Identity file override. | - |
|
||||||
|
| `-p, --port` | `PORT` | SSH port of the final target. | `22` |
|
||||||
|
| `--reconnect` | `BOOL` | Auto-reconnect on connection drop. | `true` |
|
||||||
|
| `--retry-interval` | `SECONDS` | Base delay between reconnection attempts. | `5` |
|
||||||
|
| `--backoff-max` | `SECONDS` | Cap on the doubling reconnection delay. | `60` |
|
||||||
|
| `--keepalive` | `SECONDS` | SSH `ServerAliveInterval`. | `15` |
|
||||||
|
|
||||||
|
`edit` only touches the fields given on the command line; everything else
|
||||||
|
stays as-is.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Remote forward, exposing this machine's port 3000 to the remote host.
|
||||||
|
porthole add expose-app -r 3000:localhost:3000 --via ops@server.example.com
|
||||||
|
|
||||||
|
# Dynamic SOCKS proxy.
|
||||||
|
porthole add socks -d 1080 --via user@gateway
|
||||||
|
|
||||||
|
# Two-hop jump chain: bastion1, then bastion2, ending at db-host.
|
||||||
|
porthole add mydb -l 5432:internal-db:5432 --via bastion1 --via bastion2 -u ops
|
||||||
|
|
||||||
|
# Open every profile with reconnect enabled that isn't already running.
|
||||||
|
porthole open --all
|
||||||
|
|
||||||
|
# Run attached in the current shell instead of detaching.
|
||||||
|
porthole open mydb --foreground
|
||||||
|
|
||||||
|
# Force-kill instead of a graceful SIGTERM-then-wait.
|
||||||
|
porthole close mydb --force
|
||||||
|
|
||||||
|
# Machine-readable status/listing.
|
||||||
|
porthole status mydb --json
|
||||||
|
porthole list --json
|
||||||
|
|
||||||
|
# Back up all profiles, then restore them elsewhere.
|
||||||
|
porthole transfer --export backup.toml
|
||||||
|
porthole transfer --import backup.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
> **ⓘ** Note:<br>
|
||||||
|
> `transfer --export` does not include identity file contents, only
|
||||||
|
> their configured paths; key files need to be copied to the target machine
|
||||||
|
> separately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How a forward stays open
|
||||||
|
|
||||||
|
`open` spawns a detached copy of the `porthole` binary running an internal
|
||||||
|
supervisor loop for that one profile. The supervisor:
|
||||||
|
|
||||||
|
- Builds and runs the `ssh` command for the profile (`-N -T` plus the
|
||||||
|
right `-L`/`-R`/`-D` flag), holding an advisory file lock for its whole
|
||||||
|
lifetime so `status`/`list` can reliably tell if it's still alive.
|
||||||
|
- Watches the connection; once it survives a short grace period it's
|
||||||
|
reported as `up`.
|
||||||
|
- On a dropped or failed connection, classifies the failure:
|
||||||
|
- **Fatal** (bad auth, host key mismatch, port already in use): gives up
|
||||||
|
immediately, state becomes `error`.
|
||||||
|
- **Known transient** (connection refused, DNS failure, timeout):
|
||||||
|
reconnects with exponential backoff.
|
||||||
|
- **Unrecognized**: also retries, but gives up after too many
|
||||||
|
consecutive unrecognized failures in a row.
|
||||||
|
- Resets the backoff delay once a connection has stayed up long enough to
|
||||||
|
be considered stable again.
|
||||||
|
- Logs `ssh` output to a per-profile log file, rotating it once it grows
|
||||||
|
past 10 MB.
|
||||||
|
- Exits cleanly and removes its own state on `close` (SIGTERM) or Ctrl-C
|
||||||
|
in foreground mode (SIGINT).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State on disk
|
||||||
|
|
||||||
|
- Profiles: `$XDG_CONFIG_HOME/porthole/profiles/<name>.toml`
|
||||||
|
- Runtime state, lock, and log for each open forward:
|
||||||
|
`$XDG_STATE_HOME/porthole/<name>.{json,lock,log}`.
|
||||||
|
|
||||||
|
State locations can be overridden with the `PORTHOLE_STATE_DIR_OVERRIDE` environment variable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shell completions
|
||||||
|
|
||||||
|
```sh
|
||||||
|
porthole completions bash > /etc/bash_completion.d/porthole
|
||||||
|
porthole completions zsh > "${fpath[1]}/_porthole"
|
||||||
|
porthole completions fish > ~/.config/fish/completions/porthole.fish
|
||||||
|
```
|
||||||
|
|||||||
25
src/atomic.rs
Normal file
25
src/atomic.rs
Normal 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)
|
||||||
|
}
|
||||||
202
src/cli.rs
Normal file
202
src/cli.rs
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
use clap_complete::Shell;
|
||||||
|
use clap::{ Args, Parser, Subcommand };
|
||||||
|
|
||||||
|
/// Create and manage named SSH port forwards.
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "porthole", version, about)]
|
||||||
|
pub struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub enum Commands {
|
||||||
|
/// Save a new forward profile.
|
||||||
|
#[command(visible_alias = "create", visible_alias = "mk")]
|
||||||
|
Add(AddArgs),
|
||||||
|
|
||||||
|
/// Start a saved forward as a supervised background process.
|
||||||
|
#[command(visible_alias = "start")]
|
||||||
|
Open(OpenArgs),
|
||||||
|
|
||||||
|
/// Stop a running forward.
|
||||||
|
#[command(visible_alias = "stop")]
|
||||||
|
Close(CloseArgs),
|
||||||
|
|
||||||
|
/// Update a saved profile.
|
||||||
|
Edit(EditArgs),
|
||||||
|
|
||||||
|
/// Show detailed status for one forward.
|
||||||
|
Status(StatusArgs),
|
||||||
|
|
||||||
|
/// List all saved profiles with live status.
|
||||||
|
#[command(visible_alias = "ls")]
|
||||||
|
List(ListArgs),
|
||||||
|
|
||||||
|
/// Delete a saved profile.
|
||||||
|
#[command(visible_alias = "rm", visible_alias = "delete")]
|
||||||
|
Remove(RemoveArgs),
|
||||||
|
|
||||||
|
/// Close and delete every forward, tracked or not.
|
||||||
|
#[command(visible_alias = "reset")]
|
||||||
|
Wipe(WipeArgs),
|
||||||
|
|
||||||
|
/// Export saved profiles to a file, or import them from one.
|
||||||
|
#[command(visible_alias = "data")]
|
||||||
|
Transfer(TransferArgs),
|
||||||
|
|
||||||
|
/// Generate a shell completion script.
|
||||||
|
Completions { shell: Shell },
|
||||||
|
|
||||||
|
/// Internal: runs the supervisor loop for one profile. Not for direct
|
||||||
|
/// use; `open` spawns this itself.
|
||||||
|
#[command(hide = true, name = "__supervise")]
|
||||||
|
Supervise { name: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared mapping/connection flags for `add` and `edit`, kept as one
|
||||||
|
/// struct (`#[command(flatten)]`ed into both) so the two can never drift.
|
||||||
|
#[derive(Args, Default)]
|
||||||
|
pub struct MappingArgs {
|
||||||
|
/// Local forward (current machine -> remote machine).
|
||||||
|
#[arg(short, long, value_name = "[BIND:]PORT:HOST:PORT")]
|
||||||
|
pub local: Option<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,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct TransferArgs {
|
||||||
|
/// Restrict export/import to just this profile; every profile in scope if omitted.
|
||||||
|
pub name: Option<String>,
|
||||||
|
|
||||||
|
/// Export saved profiles to a file.
|
||||||
|
#[arg(short, long, value_name = "PATH.toml")]
|
||||||
|
pub export: Option<String>,
|
||||||
|
|
||||||
|
/// Import profiles from a file.
|
||||||
|
#[arg(short, long, value_name = "PATH.toml")]
|
||||||
|
pub import: Option<String>,
|
||||||
|
}
|
||||||
22
src/commands/add.rs
Normal file
22
src/commands/add.rs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
use crate::cli::AddArgs;
|
||||||
|
use crate::commands::edits_from_mapping;
|
||||||
|
use crate::error::{PortholeError, Result};
|
||||||
|
use crate::profile::{self, Profile};
|
||||||
|
use crate::ui;
|
||||||
|
|
||||||
|
pub fn run(args: AddArgs) -> Result<()> {
|
||||||
|
profile::require_valid_name(&args.name)?;
|
||||||
|
let name = profile::normalize(&args.name);
|
||||||
|
|
||||||
|
if profile::exists(&name) {
|
||||||
|
return Err(PortholeError::AlreadyExists(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
let edits = edits_from_mapping(&args.mapping);
|
||||||
|
let new_profile = Profile::new(name.clone(), &edits)?;
|
||||||
|
profile::save(&new_profile)?;
|
||||||
|
|
||||||
|
ui::ok(&format!("Saved profile '{name}' ({} {}).", new_profile.kind.label(), new_profile.mapping));
|
||||||
|
println!(" Run 'porthole open {name}' to start it.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
49
src/commands/close.rs
Normal file
49
src/commands/close.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
use crate::cli::CloseArgs;
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::{instance, profile, ui};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
const GRACEFUL_WAIT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
pub fn run(args: CloseArgs) -> Result<()> {
|
||||||
|
let name = profile::normalize(&args.name);
|
||||||
|
profile::load(&name)?; // validate the profile itself exists
|
||||||
|
|
||||||
|
if close_instance(&name, args.force)? {
|
||||||
|
ui::ok(&format!("Closed '{name}'."));
|
||||||
|
} else {
|
||||||
|
ui::info(&format!("'{name}' is not open."));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops `name`'s supervisor if one is actually running, and clears any
|
||||||
|
/// stale instance file either way, 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 {
|
||||||
|
// SIGKILL the whole process group (the supervisor is its own
|
||||||
|
// group leader via setsid), not just the supervisor pid; a plain
|
||||||
|
// single-pid SIGKILL would leave `ssh` orphaned.
|
||||||
|
unsafe { libc::kill(-pid, libc::SIGKILL) };
|
||||||
|
} else {
|
||||||
|
unsafe { libc::kill(pid, libc::SIGTERM) };
|
||||||
|
let deadline = Instant::now() + GRACEFUL_WAIT;
|
||||||
|
while instance::process_alive(pid) && Instant::now() < deadline {
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
if instance::process_alive(pid) {
|
||||||
|
unsafe { libc::kill(-pid, libc::SIGKILL) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The supervisor removes its own instance file on a clean SIGTERM
|
||||||
|
// shutdown; this covers the force-killed case where it never got to.
|
||||||
|
instance::delete(name)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
9
src/commands/completions.rs
Normal file
9
src/commands/completions.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
use crate::cli::Cli;
|
||||||
|
use clap::CommandFactory;
|
||||||
|
use clap_complete::{generate, Shell};
|
||||||
|
|
||||||
|
pub fn run(shell: Shell) {
|
||||||
|
let mut cmd = Cli::command();
|
||||||
|
let name = cmd.get_name().to_string();
|
||||||
|
generate(shell, &mut cmd, name, &mut std::io::stdout());
|
||||||
|
}
|
||||||
31
src/commands/edit.rs
Normal file
31
src/commands/edit.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
use crate::cli::EditArgs;
|
||||||
|
use crate::commands::{edits_from_mapping, mapping_is_empty};
|
||||||
|
use crate::error::{PortholeError, Result};
|
||||||
|
use crate::{instance, profile, ui};
|
||||||
|
|
||||||
|
pub fn run(args: EditArgs) -> Result<()> {
|
||||||
|
if mapping_is_empty(&args.mapping) {
|
||||||
|
return Err(PortholeError::NothingToDo(
|
||||||
|
"pass at least one of -l/-r/-d, --via, --user, --identity, --port, --reconnect, \
|
||||||
|
--retry-interval, --backoff-max, --keepalive."
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = profile::normalize(&args.name);
|
||||||
|
let mut p = profile::load(&name)?;
|
||||||
|
|
||||||
|
let edits = edits_from_mapping(&args.mapping);
|
||||||
|
p.apply_edits(&edits)?;
|
||||||
|
profile::save(&p)?;
|
||||||
|
|
||||||
|
// edit never restarts a running instance, just warn.
|
||||||
|
if instance::running_pid(&name)?.is_some() {
|
||||||
|
ui::warn(&format!(
|
||||||
|
"'{name}' is currently open; this change won't take effect until the next open/close cycle."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
ui::ok(&format!("Updated profile '{name}'."));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
120
src/commands/list.rs
Normal file
120
src/commands/list.rs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
use crate::cli::ListArgs;
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::{instance, profile, timefmt, ui};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
struct Row {
|
||||||
|
name: String,
|
||||||
|
kind: String,
|
||||||
|
mapping: String,
|
||||||
|
via: String,
|
||||||
|
state: String,
|
||||||
|
uptime: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(args: ListArgs) -> Result<()> {
|
||||||
|
let profiles = profile::list_all()?;
|
||||||
|
if profiles.is_empty() {
|
||||||
|
ui::info("No profiles saved.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for p in &profiles {
|
||||||
|
let inst = instance::load(&p.name)?;
|
||||||
|
let live = inst.as_ref().is_some_and(|i| instance::supervisor_alive(i.pid, &p.name));
|
||||||
|
let (state, uptime) = match &inst {
|
||||||
|
None => ("closed".to_string(), String::new()),
|
||||||
|
Some(_) if !live => ("error".to_string(), String::new()),
|
||||||
|
Some(i) => (
|
||||||
|
i.state.label().to_string(),
|
||||||
|
i.connected_at.map(|c| timefmt::fmt_duration(timefmt::now() - c)).unwrap_or_default(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
if args.running && !matches!(state.as_str(), "up" | "reconnecting") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push(Row {
|
||||||
|
name: p.name.clone(),
|
||||||
|
kind: p.kind.label().to_string(),
|
||||||
|
mapping: p.mapping.clone(),
|
||||||
|
via: p.via.join(","),
|
||||||
|
state,
|
||||||
|
uptime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.json {
|
||||||
|
print_json(&rows);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows.is_empty() {
|
||||||
|
ui::info("No matching profiles.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
print_table(&rows);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn col(i: usize, r: &Row) -> &str {
|
||||||
|
match i {
|
||||||
|
0 => &r.name,
|
||||||
|
1 => &r.kind,
|
||||||
|
2 => &r.mapping,
|
||||||
|
3 => &r.via,
|
||||||
|
4 => &r.state,
|
||||||
|
_ => &r.uptime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_table(rows: &[Row]) {
|
||||||
|
let headers = ["NAME", "KIND", "MAPPING", "VIA", "STATE", "UPTIME"];
|
||||||
|
let widths: Vec<usize> =
|
||||||
|
(0..6).map(|i| rows.iter().map(|r| col(i, r).len()).max().unwrap_or(0).max(headers[i].len())).collect();
|
||||||
|
|
||||||
|
let header_line: Vec<String> = headers.iter().enumerate().map(|(i, h)| format!("{h:<w$}", w = widths[i])).collect();
|
||||||
|
println!("{}", ui::blue(&header_line.join(" ")));
|
||||||
|
|
||||||
|
for r in rows {
|
||||||
|
let state_colored = match r.state.as_str() {
|
||||||
|
"up" => ui::green(&r.state),
|
||||||
|
"reconnecting" => ui::yellow(&r.state),
|
||||||
|
"error" => ui::red(&r.state),
|
||||||
|
_ => r.state.clone(),
|
||||||
|
};
|
||||||
|
let cells = [
|
||||||
|
format!("{:<w$}", r.name, w = widths[0]),
|
||||||
|
format!("{:<w$}", r.kind, w = widths[1]),
|
||||||
|
format!("{:<w$}", r.mapping, w = widths[2]),
|
||||||
|
format!("{:<w$}", r.via, w = widths[3]),
|
||||||
|
// padded on the uncolored text width, then swapped for the
|
||||||
|
// colored version so ANSI codes don't throw off alignment
|
||||||
|
format!("{:<w$}", r.state, w = widths[4]).replacen(&r.state, &state_colored, 1),
|
||||||
|
format!("{:<w$}", r.uptime, w = widths[5]),
|
||||||
|
];
|
||||||
|
println!("{}", cells.join(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct RowJson<'a> {
|
||||||
|
name: &'a str,
|
||||||
|
kind: &'a str,
|
||||||
|
mapping: &'a str,
|
||||||
|
via: &'a str,
|
||||||
|
state: &'a str,
|
||||||
|
uptime: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_json(rows: &[Row]) {
|
||||||
|
let out: Vec<RowJson> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| RowJson { name: &r.name, kind: &r.kind, mapping: &r.mapping, via: &r.via, state: &r.state, uptime: &r.uptime })
|
||||||
|
.collect();
|
||||||
|
if let Ok(text) = serde_json::to_string_pretty(&out) {
|
||||||
|
println!("{text}");
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/commands/mod.rs
Normal file
51
src/commands/mod.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
pub mod add;
|
||||||
|
pub mod close;
|
||||||
|
pub mod completions;
|
||||||
|
pub mod edit;
|
||||||
|
pub mod list;
|
||||||
|
pub mod open;
|
||||||
|
pub mod remove;
|
||||||
|
pub mod status;
|
||||||
|
pub mod transfer;
|
||||||
|
pub mod wipe;
|
||||||
|
|
||||||
|
use crate::cli::MappingArgs;
|
||||||
|
use crate::profile::ProfileEdits;
|
||||||
|
|
||||||
|
/// Turns clap's `MappingArgs` into a `ProfileEdits`. `--via` is
|
||||||
|
/// collected by clap itself: `value_delimiter = ','` splits each
|
||||||
|
/// occurrence on commas, and the field being a `Vec` allows repeated
|
||||||
|
/// `--via` flags, so both `--via a,b` and `--via a --via b` reach here as
|
||||||
|
/// `["a", "b"]`.
|
||||||
|
pub fn edits_from_mapping(m: &MappingArgs) -> ProfileEdits {
|
||||||
|
let via = (!m.via.is_empty()).then(|| m.via.clone());
|
||||||
|
ProfileEdits {
|
||||||
|
local: m.local.clone(),
|
||||||
|
remote: m.remote.clone(),
|
||||||
|
dynamic: m.dynamic.clone(),
|
||||||
|
via,
|
||||||
|
user: m.user.clone(),
|
||||||
|
identity: m.identity.clone(),
|
||||||
|
port: m.port,
|
||||||
|
reconnect: m.reconnect,
|
||||||
|
retry_interval: m.retry_interval,
|
||||||
|
backoff_max: m.backoff_max,
|
||||||
|
keepalive: m.keepalive,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` if `MappingArgs` carries no edits at all, used by `edit` to
|
||||||
|
/// reject a no-op invocation.
|
||||||
|
pub fn mapping_is_empty(m: &MappingArgs) -> bool {
|
||||||
|
m.local.is_none()
|
||||||
|
&& m.remote.is_none()
|
||||||
|
&& m.dynamic.is_none()
|
||||||
|
&& m.via.is_empty()
|
||||||
|
&& m.user.is_none()
|
||||||
|
&& m.identity.is_none()
|
||||||
|
&& m.port.is_none()
|
||||||
|
&& m.reconnect.is_none()
|
||||||
|
&& m.retry_interval.is_none()
|
||||||
|
&& m.backoff_max.is_none()
|
||||||
|
&& m.keepalive.is_none()
|
||||||
|
}
|
||||||
131
src/commands/open.rs
Normal file
131
src/commands/open.rs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
//! `open` 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. Per-profile
|
||||||
|
/// failures are warnings, not a whole-batch failure.
|
||||||
|
fn open_all(once: bool) -> Result<()> {
|
||||||
|
let profiles = profile::list_all()?;
|
||||||
|
let mut opened = 0;
|
||||||
|
let mut failed = 0;
|
||||||
|
for p in profiles.iter().filter(|p| p.reconnect) {
|
||||||
|
if instance::running_pid(&p.name)?.is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match open_one(&p.name, false, once) {
|
||||||
|
Ok(()) => opened += 1,
|
||||||
|
Err(e) => {
|
||||||
|
failed += 1;
|
||||||
|
ui::warn(&format!("'{}': {e}", p.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if failed > 0 {
|
||||||
|
ui::ok(&format!("Opened {opened} profile(s), {failed} failed."));
|
||||||
|
} else {
|
||||||
|
ui::ok(&format!("Opened {opened} profile(s)."));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_one(name: &str, foreground: bool, once: bool) -> Result<()> {
|
||||||
|
if let Some(pid) = instance::running_pid(name)? {
|
||||||
|
ui::info(&format!("'{name}' is already open (pid {pid})."));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Clear a stale instance file left by a crash before spawning. The
|
||||||
|
// lock, not this file, is the authority on "already open"; this just
|
||||||
|
// keeps `status` from reading stale state mid-spawn.
|
||||||
|
instance::delete(name)?;
|
||||||
|
|
||||||
|
if foreground {
|
||||||
|
ui::info(&format!("Opening '{name}' in the foreground - Ctrl-C to close."));
|
||||||
|
if once {
|
||||||
|
std::env::set_var("PORTHOLE_SUPERVISE_ONCE", "1");
|
||||||
|
}
|
||||||
|
return supervisor::run(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn_detached(name, once)?;
|
||||||
|
wait_for_confirmation(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns `porthole __supervise <name>` fully detached: stdin from
|
||||||
|
/// `/dev/null`, stdout/stderr appended to the profile's log, and
|
||||||
|
/// `setsid()` in the child so it leaves this process's session and
|
||||||
|
/// survives the terminal closing.
|
||||||
|
fn spawn_detached(name: &str, once: bool) -> Result<()> {
|
||||||
|
let exe = std::env::current_exe()?;
|
||||||
|
let log_path = instance::log_path(name);
|
||||||
|
if let Some(parent) = log_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let log_file = std::fs::OpenOptions::new().create(true).append(true).open(&log_path)?;
|
||||||
|
|
||||||
|
let mut cmd = Command::new(exe);
|
||||||
|
cmd.args(["__supervise", name]).stdin(Stdio::null()).stdout(log_file.try_clone()?).stderr(log_file);
|
||||||
|
if once {
|
||||||
|
cmd.env("PORTHOLE_SUPERVISE_ONCE", "1");
|
||||||
|
}
|
||||||
|
unsafe {
|
||||||
|
cmd.pre_exec(|| if libc::setsid() < 0 { Err(std::io::Error::last_os_error()) } else { Ok(()) });
|
||||||
|
}
|
||||||
|
cmd.spawn()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blocks briefly for the detached supervisor to reach a conclusive state,
|
||||||
|
/// so an immediate failure (bad auth, bind conflict, unresolvable host) is
|
||||||
|
/// reported with a non-zero exit instead of `open` appearing to succeed.
|
||||||
|
/// The instance file's initial write is always
|
||||||
|
/// `State::Reconnecting`, since the first attempt has not concluded yet;
|
||||||
|
/// that value is indistinguishable from "already failed once, backing
|
||||||
|
/// off". This function waits specifically for `Up` or `Error`, not merely
|
||||||
|
/// for a state other than `Error`, so it does not report success before
|
||||||
|
/// the first connection attempt has run.
|
||||||
|
fn wait_for_confirmation(name: &str) -> Result<()> {
|
||||||
|
let deadline = Instant::now() + CONFIRM_TIMEOUT;
|
||||||
|
loop {
|
||||||
|
if let Some(inst) = instance::load(name)? {
|
||||||
|
match inst.state {
|
||||||
|
instance::State::Error => {
|
||||||
|
let reason = inst.last_error.unwrap_or_else(|| "see the log for details".into());
|
||||||
|
return Err(PortholeError::OpenFailed(name.to_string(), reason));
|
||||||
|
}
|
||||||
|
instance::State::Up => {
|
||||||
|
ui::ok(&format!("Opened '{name}' (pid {}).", inst.pid));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
instance::State::Reconnecting => {} // first attempt still in flight; keep polling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
ui::ok(&format!("Opened '{name}' - still connecting, check 'porthole status {name}'."));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
std::thread::sleep(CONFIRM_POLL);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/commands/remove.rs
Normal file
24
src/commands/remove.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
use crate::cli::RemoveArgs;
|
||||||
|
use crate::commands::close::close_instance;
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::{instance, profile, ui};
|
||||||
|
|
||||||
|
pub fn run(args: RemoveArgs) -> Result<()> {
|
||||||
|
let name = profile::normalize(&args.name);
|
||||||
|
profile::load(&name)?; // validate existence
|
||||||
|
|
||||||
|
if args.keep_running {
|
||||||
|
if instance::running_pid(&name)?.is_some() {
|
||||||
|
ui::warn(&format!(
|
||||||
|
"'{name}' left running untracked - it's no longer visible to 'list'/'status', \
|
||||||
|
only 'wipe' will still find it."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
close_instance(&name, false)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
profile::delete(&name)?;
|
||||||
|
ui::ok(&format!("Removed profile '{name}'."));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
105
src/commands/status.rs
Normal file
105
src/commands/status.rs
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
use crate::cli::StatusArgs;
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::instance::{Instance, State};
|
||||||
|
use crate::profile::{self, Profile};
|
||||||
|
use crate::{instance, timefmt, ui};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
pub fn run(args: StatusArgs) -> Result<()> {
|
||||||
|
let name = profile::normalize(&args.name);
|
||||||
|
let p = profile::load(&name)?;
|
||||||
|
let inst = instance::load(&name)?;
|
||||||
|
// An instance file whose pid isn't actually alive means the supervisor
|
||||||
|
// crashed without cleaning up; report that, rather than trusting a
|
||||||
|
// state the process table disagrees with.
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
133
src/commands/transfer.rs
Normal file
133
src/commands/transfer.rs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
//! Bulk profile backup/restore, a flat TOML array of the same `Profile`
|
||||||
|
//! records `profile::save`/`load` already read and write, so it round-trips
|
||||||
|
//! through the exact same serialization with nothing profile-specific here.
|
||||||
|
|
||||||
|
use crate::cli::TransferArgs;
|
||||||
|
use crate::error::{PortholeError, Result};
|
||||||
|
use crate::profile::{self, Profile};
|
||||||
|
use crate::{atomic, ui};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Default, Serialize, Deserialize)]
|
||||||
|
struct TransferFile {
|
||||||
|
#[serde(rename = "profile", default)]
|
||||||
|
profiles: Vec<Profile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(args: TransferArgs) -> Result<()> {
|
||||||
|
match (&args.export, &args.import) {
|
||||||
|
(Some(_), Some(_)) => Err(PortholeError::TransferConflictingMode),
|
||||||
|
(None, None) => Err(PortholeError::TransferNoMode),
|
||||||
|
(Some(path), None) => export(path, args.name.as_deref()),
|
||||||
|
(None, Some(path)) => import(path, args.name.as_deref()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export(path: &str, name: Option<&str>) -> Result<()> {
|
||||||
|
let profiles = match name {
|
||||||
|
Some(n) => {
|
||||||
|
let n = profile::normalize(n);
|
||||||
|
vec![profile::load(&n)?]
|
||||||
|
}
|
||||||
|
None => profile::list_all()?,
|
||||||
|
};
|
||||||
|
|
||||||
|
warn_about_identities(&profiles);
|
||||||
|
|
||||||
|
let count = profiles.len();
|
||||||
|
let file = TransferFile { profiles };
|
||||||
|
let text = toml::to_string_pretty(&file)?;
|
||||||
|
atomic::write(Path::new(path), text.as_bytes())?;
|
||||||
|
|
||||||
|
match name {
|
||||||
|
Some(n) => ui::ok(&format!("Exported profile '{n}' to '{path}'.")),
|
||||||
|
None => ui::ok(&format!("Exported {count} profile(s) to '{path}'.")),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn import(path: &str, name: Option<&str>) -> Result<()> {
|
||||||
|
let text = std::fs::read_to_string(path)?;
|
||||||
|
let file: TransferFile = toml::from_str(&text)?;
|
||||||
|
|
||||||
|
let selected = match name {
|
||||||
|
Some(n) => {
|
||||||
|
let n = profile::normalize(n);
|
||||||
|
let found = file.profiles.into_iter().find(|p| p.name == n);
|
||||||
|
vec![found.ok_or(PortholeError::TransferProfileNotFound(n))?]
|
||||||
|
}
|
||||||
|
None => file.profiles,
|
||||||
|
};
|
||||||
|
|
||||||
|
for p in &selected {
|
||||||
|
profile::require_valid_name(&p.name)?;
|
||||||
|
if profile::exists(&p.name) {
|
||||||
|
return Err(PortholeError::AlreadyExists(p.name.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for p in &selected {
|
||||||
|
profile::save(p)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
match name {
|
||||||
|
Some(n) => ui::ok(&format!("Imported profile '{n}' from '{path}'.")),
|
||||||
|
None => ui::ok(&format!("Imported {} profile(s) from '{path}'.", selected.len())),
|
||||||
|
}
|
||||||
|
warn_about_missing_identities(&selected);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identity files are never included in the export, only the path; warn so
|
||||||
|
/// that doesn't come as a surprise on the importing end.
|
||||||
|
fn warn_about_identities(profiles: &[Profile]) {
|
||||||
|
let names: Vec<&str> = profiles.iter().filter(|p| p.identity.is_some()).map(|p| p.name.as_str()).collect();
|
||||||
|
if !names.is_empty() {
|
||||||
|
ui::warn(&format!(
|
||||||
|
"identity files are not included in the export ({}) - copy them to the importing machine yourself",
|
||||||
|
names.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After import, flag any profile whose identity path doesn't resolve on
|
||||||
|
/// this machine, the most likely sign of a not-yet-copied key file.
|
||||||
|
fn warn_about_missing_identities(profiles: &[Profile]) {
|
||||||
|
for p in profiles {
|
||||||
|
if let Some(identity) = &p.identity {
|
||||||
|
if !expand_home(identity).is_file() {
|
||||||
|
ui::warn(&format!(
|
||||||
|
"'{}': identity file '{identity}' not found on this machine - fix it with \
|
||||||
|
'porthole edit {} -i <path>' before opening",
|
||||||
|
p.name, p.name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expands a leading `~/` the same way `ssh` itself does at spawn time
|
||||||
|
/// (`ssh.rs`); without this, a valid `~/...` identity path would be
|
||||||
|
/// misreported as missing since `Path::is_file` never expands `~` on its own.
|
||||||
|
fn expand_home(path: &str) -> PathBuf {
|
||||||
|
match path.strip_prefix("~/").zip(dirs::home_dir()) {
|
||||||
|
Some((rest, home)) => home.join(rest),
|
||||||
|
None => PathBuf::from(path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expands_leading_tilde() {
|
||||||
|
let home = dirs::home_dir().unwrap();
|
||||||
|
assert_eq!(expand_home("~/.ssh/id"), home.join(".ssh/id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leaves_absolute_path_untouched() {
|
||||||
|
assert_eq!(expand_home("/etc/ssh/id"), PathBuf::from("/etc/ssh/id"));
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/commands/wipe.rs
Normal file
59
src/commands/wipe.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
use crate::cli::WipeArgs;
|
||||||
|
use crate::commands::close::close_instance;
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::{profile, ui};
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
pub fn run(args: WipeArgs) -> Result<()> {
|
||||||
|
let profiles = profile::list_all()?;
|
||||||
|
|
||||||
|
if !args.yes {
|
||||||
|
print!(
|
||||||
|
"This will close and delete every forward, including any untracked ones. Continue? [y/N] "
|
||||||
|
);
|
||||||
|
std::io::stdout().flush().ok();
|
||||||
|
let mut answer = String::new();
|
||||||
|
std::io::stdin().read_line(&mut answer).ok();
|
||||||
|
if !matches!(answer.trim().to_lowercase().as_str(), "y" | "yes") {
|
||||||
|
ui::info("Aborted.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut closed = 0;
|
||||||
|
for p in &profiles {
|
||||||
|
if close_instance(&p.name, false)? {
|
||||||
|
closed += 1;
|
||||||
|
}
|
||||||
|
profile::delete(&p.name)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let orphans = kill_orphaned_supervisors();
|
||||||
|
|
||||||
|
if profiles.is_empty() && orphans == 0 {
|
||||||
|
ui::info("Nothing to wipe: no profiles or forwards found.");
|
||||||
|
} else {
|
||||||
|
ui::ok("Wiped all forwards:");
|
||||||
|
println!(" profiles deleted: {}", profiles.len());
|
||||||
|
println!(" running forwards closed: {closed}");
|
||||||
|
println!(" orphaned supervisors killed: {orphans}");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kills any supervisor process not backed by a tracked profile (e.g. one
|
||||||
|
/// orphaned after a crash), matched by cmdline rather than tracked state.
|
||||||
|
/// Sends SIGTERM to each supervisor's process group so its `ssh` child is
|
||||||
|
/// included.
|
||||||
|
fn kill_orphaned_supervisors() -> u32 {
|
||||||
|
let mut killed = 0;
|
||||||
|
let Ok(entries) = std::fs::read_dir("/proc") else { return 0 };
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let Ok(pid) = entry.file_name().to_string_lossy().parse::<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
|
||||||
|
}
|
||||||
72
src/error.rs
Normal file
72
src/error.rs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum PortholeError {
|
||||||
|
#[error(
|
||||||
|
"invalid name '{0}' (use 1-64 chars: letters, digits, '_' or '-'; \
|
||||||
|
must start with a letter or digit)"
|
||||||
|
)]
|
||||||
|
InvalidName(String),
|
||||||
|
|
||||||
|
#[error("no profile named '{0}'")]
|
||||||
|
NotFound(String),
|
||||||
|
|
||||||
|
#[error("profile '{0}' already exists (use 'edit' to modify it)")]
|
||||||
|
AlreadyExists(String),
|
||||||
|
|
||||||
|
#[error("exactly one of -l/--local, -r/--remote, -d/--dynamic is required")]
|
||||||
|
NoMappingKind,
|
||||||
|
|
||||||
|
#[error("only one of -l/--local, -r/--remote, -d/--dynamic may be given")]
|
||||||
|
MultipleMappingKinds,
|
||||||
|
|
||||||
|
#[error("invalid forward spec '{0}': expected [BIND:]PORT:HOST:PORT (or [BIND:]PORT for -d)")]
|
||||||
|
InvalidMapping(String),
|
||||||
|
|
||||||
|
#[error("invalid --via hop '{0}': expected [USER@]HOST[:PORT]")]
|
||||||
|
InvalidVia(String),
|
||||||
|
|
||||||
|
#[error("--via is required: at least one hop (connection target)")]
|
||||||
|
NoViaHosts,
|
||||||
|
|
||||||
|
#[error("exactly one of -i/--import, -e/--export is required")]
|
||||||
|
TransferNoMode,
|
||||||
|
|
||||||
|
#[error("only one of -i/--import, -e/--export may be given")]
|
||||||
|
TransferConflictingMode,
|
||||||
|
|
||||||
|
#[error("'{0}' not found in the transfer file")]
|
||||||
|
TransferProfileNotFound(String),
|
||||||
|
|
||||||
|
#[error("nothing to do: {0}")]
|
||||||
|
NothingToDo(String),
|
||||||
|
|
||||||
|
#[error("'{0}' failed to start: {1}")]
|
||||||
|
OpenFailed(String, String),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("state file error: {0}")]
|
||||||
|
Serde(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<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())
|
||||||
|
}
|
||||||
|
}
|
||||||
170
src/instance.rs
Normal file
170
src/instance.rs
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
//! Runtime state for one open profile. Written only by the
|
||||||
|
//! supervisor (`src/supervisor.rs`); everything else here just reads it.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::os::unix::io::AsRawFd;
|
||||||
|
use std::fs::{ File, OpenOptions };
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::{ atomic, timefmt };
|
||||||
|
|
||||||
|
use serde::{ Serialize, Deserialize };
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum State {
|
||||||
|
Up,
|
||||||
|
Reconnecting,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State
|
||||||
|
{
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
State::Up => "up",
|
||||||
|
State::Reconnecting => "reconnecting",
|
||||||
|
State::Error => "error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Instance {
|
||||||
|
pub name: String,
|
||||||
|
pub pid: i32,
|
||||||
|
pub state: State,
|
||||||
|
/// Anchor for "session uptime"; set once, when `open` starts.
|
||||||
|
pub opened_at: i64,
|
||||||
|
/// Start of the current unbroken connection; resets each reconnect.
|
||||||
|
pub connected_at: Option<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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
/// Advisory `flock` held for the supervisor's entire lifetime.
|
||||||
|
/// The OS releases it the instant the holding process's file descriptors
|
||||||
|
/// close, including on a crash or SIGKILL, so it needs no stale-lock
|
||||||
|
/// cleanup and reliably answers "is a supervisor running for this profile."
|
||||||
|
pub struct Lock {
|
||||||
|
_file: File,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Lock
|
||||||
|
{
|
||||||
|
/// Tries to take the lock non-blocking. `Ok(None)` means another live
|
||||||
|
/// process already acquired it.
|
||||||
|
pub fn try_acquire(name: &str) -> Result<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()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
fn state_dir() -> PathBuf
|
||||||
|
{
|
||||||
|
if let Ok(dir) = std::env::var("PORTHOLE_STATE_DIR_OVERRIDE") { return PathBuf::from(dir); };
|
||||||
|
|
||||||
|
dirs::state_dir()
|
||||||
|
.or_else(dirs::data_local_dir)
|
||||||
|
.expect("could not resolve state dir")
|
||||||
|
.join("porthole")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn instance_path(name: &str) -> PathBuf { state_dir().join(format!("{name}.json")) }
|
||||||
|
pub fn lock_path(name: &str) -> PathBuf { state_dir().join(format!("{name}.lock")) }
|
||||||
|
pub fn log_path(name: &str) -> PathBuf { state_dir().join(format!("{name}.log")) }
|
||||||
|
|
||||||
|
/// Loads the instance file for `name`, if any. `None` means closed; its
|
||||||
|
/// absence *is* the closed state; there is no separate enum value for it.
|
||||||
|
pub fn load(name: &str) -> Result<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 currently running
|
||||||
|
/// (checked against the process table, not just the instance file's
|
||||||
|
/// last-known value, so a crash or reboot is detected as "not running"
|
||||||
|
/// rather than trusting stale on-disk state).
|
||||||
|
pub fn running_pid(name: &str) -> Result<Option<i32>>
|
||||||
|
{
|
||||||
|
match load(name)? {
|
||||||
|
Some(inst) if supervisor_alive(inst.pid, name) => Ok(Some(inst.pid)),
|
||||||
|
_ => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
208
src/main.rs
Normal file
208
src/main.rs
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
mod atomic;
|
||||||
|
mod cli;
|
||||||
|
mod commands;
|
||||||
|
mod error;
|
||||||
|
mod instance;
|
||||||
|
mod profile;
|
||||||
|
mod ssh;
|
||||||
|
mod supervisor;
|
||||||
|
mod timefmt;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use clap::{CommandFactory, Parser};
|
||||||
|
use cli::{Cli, Commands};
|
||||||
|
|
||||||
|
fn main()
|
||||||
|
{
|
||||||
|
let args: Vec<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::Transfer(args) => commands::transfer::run(args),
|
||||||
|
Commands::Completions { shell } => {
|
||||||
|
commands::completions::run(shell);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
// Internal
|
||||||
|
Commands::Supervise { name } => supervisor::run(&name),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
ui::err(&e.to_string());
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True for a bare `porthole` invocation, or top-level `-h`/`--help`/`help`,
|
||||||
|
/// i.e. anything that should show the expanded help rather than being
|
||||||
|
/// handled (or rejected) by a specific subcommand.
|
||||||
|
fn wants_top_level_help(args: &[String]) -> bool {
|
||||||
|
match args.get(1..) {
|
||||||
|
Some([]) => true,
|
||||||
|
Some([a]) => a == "-h" || a == "--help" || a == "help",
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prints one screen of help: one summary line per subcommand (name,
|
||||||
|
/// positional args, description), followed by an indented line per flag
|
||||||
|
/// with its own help text.
|
||||||
|
fn print_full_help() {
|
||||||
|
let mut cmd = Cli::command();
|
||||||
|
cmd.build(); // resolve default value names etc. before introspecting
|
||||||
|
let bin = cmd.get_name().to_string();
|
||||||
|
|
||||||
|
if let Some(about) = cmd.get_about() {
|
||||||
|
println!("{about}");
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
println!("{} {bin} {} {}", ui::blue("Usage:"), ui::cyan("<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 required positional arg of `cmd`, `[name]` for an
|
||||||
|
/// optional one, space-joined and colored cyan.
|
||||||
|
fn positional_args(cmd: &clap::Command) -> String {
|
||||||
|
cmd.get_positionals()
|
||||||
|
.map(|a| {
|
||||||
|
let id = a.get_id().as_str();
|
||||||
|
let text = if a.is_required_set() { format!("<{id}>") } else { format!("[{id}]") };
|
||||||
|
ui::cyan(&text)
|
||||||
|
})
|
||||||
|
.collect::<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))))
|
||||||
|
}
|
||||||
372
src/profile.rs
Normal file
372
src/profile.rs
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
//! Persisted forward definitions: One TOML file per profile at
|
||||||
|
//! `~/.config/porthole/profiles/<name>.toml`.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::{ atomic, timefmt };
|
||||||
|
use crate::error::{ Result, PortholeError };
|
||||||
|
|
||||||
|
use serde::{ Serialize, Deserialize };
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Kind {
|
||||||
|
Local,
|
||||||
|
Remote,
|
||||||
|
Dynamic,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Kind
|
||||||
|
{
|
||||||
|
/// The `ssh` forward flag this kind maps to (`-L`/`-R`/`-D`).
|
||||||
|
pub fn ssh_flag(self) -> &'static str
|
||||||
|
{
|
||||||
|
match self {
|
||||||
|
Kind::Local => "-L",
|
||||||
|
Kind::Remote => "-R",
|
||||||
|
Kind::Dynamic => "-D",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(self) -> &'static str
|
||||||
|
{
|
||||||
|
match self {
|
||||||
|
Kind::Local => "local",
|
||||||
|
Kind::Remote => "remote",
|
||||||
|
Kind::Dynamic => "dynamic",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Profile {
|
||||||
|
pub name: String,
|
||||||
|
pub kind: Kind,
|
||||||
|
/// Raw `-L/-R/-D` payload, without the flag itself.
|
||||||
|
pub mapping: String,
|
||||||
|
/// Ordered hop list, each `[user@]host[:port]`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub via: Vec<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,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Profile
|
||||||
|
{
|
||||||
|
/// Builds a brand-new profile from `add`'s flags.
|
||||||
|
//noinspection RsFieldInitShorthand
|
||||||
|
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: name,
|
||||||
|
kind: kind,
|
||||||
|
mapping: mapping.to_string(),
|
||||||
|
via: via,
|
||||||
|
user: edits.user.clone(),
|
||||||
|
identity: edits.identity.clone(),
|
||||||
|
ssh_port: edits.port.unwrap_or_else(default_ssh_port),
|
||||||
|
reconnect: edits.reconnect.unwrap_or_else(default_true),
|
||||||
|
retry_interval: edits.retry_interval.unwrap_or_else(default_retry_interval),
|
||||||
|
backoff_max: edits.backoff_max.unwrap_or_else(default_backoff_max),
|
||||||
|
keepalive: edits.keepalive.unwrap_or_else(default_keepalive),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies `edits` on top of an existing profile (`edit`'s semantics:
|
||||||
|
/// only provided fields change).
|
||||||
|
pub fn apply_edits(&mut self, edits: &ProfileEdits) -> Result<()>
|
||||||
|
{
|
||||||
|
if let Some((kind, mapping)) = edits.mapping_kind()?
|
||||||
|
{
|
||||||
|
validate_mapping(kind, mapping)?;
|
||||||
|
|
||||||
|
self.kind = kind;
|
||||||
|
self.mapping = mapping.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(via) = &edits.via
|
||||||
|
{
|
||||||
|
if via.is_empty() { return Err(PortholeError::NoViaHosts); }
|
||||||
|
|
||||||
|
for hop in via { validate_via_hop(hop)?; }
|
||||||
|
self.via = via.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(user) = &edits.user { self.user = Some(user.clone()); }
|
||||||
|
if let Some(identity) = &edits.identity { self.identity = Some(identity.clone()); }
|
||||||
|
if let Some(port) = edits.port { self.ssh_port = port; }
|
||||||
|
if let Some(reconnect) = edits.reconnect { self.reconnect = reconnect; }
|
||||||
|
if let Some(v) = edits.retry_interval { self.retry_interval = v; }
|
||||||
|
if let Some(v) = edits.backoff_max { self.backoff_max = v; }
|
||||||
|
if let Some(v) = edits.keepalive { self.keepalive = v; }
|
||||||
|
self.updated_at = timefmt::now();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits `via` into the `-J` jump-chain value (comma-joined, all but
|
||||||
|
/// the last hop; `None` for a single-hop `via`) and the final `ssh`
|
||||||
|
/// connection target. Every path that constructs a `Profile` validates
|
||||||
|
/// `via` as non-empty.
|
||||||
|
pub fn ssh_target(&self) -> (Option<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, ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
/// 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 default_ssh_port() -> u16 { 22 }
|
||||||
|
fn default_true() -> bool { true }
|
||||||
|
fn default_retry_interval() -> u32 { 5 }
|
||||||
|
fn default_backoff_max() -> u32 { 60 }
|
||||||
|
fn default_keepalive() -> u32 { 15 }
|
||||||
|
|
||||||
|
fn valid_name(name: &str) -> bool
|
||||||
|
{
|
||||||
|
let mut chars = name.chars();
|
||||||
|
let Some(first) = chars.next() else { return false };
|
||||||
|
|
||||||
|
if !first.is_ascii_alphanumeric() { return false; }
|
||||||
|
name.len() <= 64 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn require_valid_name(name: &str) -> Result<()>
|
||||||
|
{
|
||||||
|
if valid_name(name) { Ok(()) }
|
||||||
|
else { Err(PortholeError::InvalidName(name.to_string())) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates a `-l/-r` payload (`[bind:]port:host:hostport`) or `-d` payload
|
||||||
|
/// (`[bind:]port`) against the same shape `ssh` itself expects.
|
||||||
|
fn validate_mapping(kind: Kind, mapping: &str) -> Result<()>
|
||||||
|
{
|
||||||
|
let bad = || PortholeError::InvalidMapping(mapping.to_string());
|
||||||
|
let parts: Vec<&str> = mapping.split(':').collect();
|
||||||
|
let valid_port = |s: &str| s.parse::<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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
210
src/ssh.rs
Normal file
210
src/ssh.rs
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
//! Builds the `ssh` invocation for a profile.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
use std::process::{ Stdio, Command };
|
||||||
|
|
||||||
|
use crate::profile::Profile;
|
||||||
|
|
||||||
|
/// A throwaway `ssh_config` applying the same hardening (and identity, if
|
||||||
|
/// any) to every host, so `-J`'s inner proxy connection picks it up too
|
||||||
|
/// instead of falling back to default identities and prompting for
|
||||||
|
/// host-key confirmation on a `/dev/null`-less stdin. Deleted on drop, so
|
||||||
|
/// callers just need to keep this alive for as long as the `ssh` process
|
||||||
|
/// that reads it runs.
|
||||||
|
pub struct JumpConfig(PathBuf);
|
||||||
|
|
||||||
|
impl JumpConfig
|
||||||
|
{
|
||||||
|
/// Named after the profile and this process's pid rather than
|
||||||
|
/// something unique per call, so a long-lived supervisor's repeated
|
||||||
|
/// reconnect attempts overwrite the same file instead of littering a
|
||||||
|
/// new one on every retry.
|
||||||
|
fn write(profile: &Profile) -> Option<Self>
|
||||||
|
{
|
||||||
|
let mut body = String::from("Host *\n");
|
||||||
|
for (key, value) in forced_options(profile) { writeln!(body, "\t{key} {value}").ok()?; }
|
||||||
|
if let Some(identity) = &profile.identity
|
||||||
|
{
|
||||||
|
writeln!(body, "\tIdentityFile {identity}").ok()?;
|
||||||
|
writeln!(body, "\tIdentitiesOnly yes").ok()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = std::env::temp_dir().join(format!("porthole-{}-{}.sshconfig", profile.name, std::process::id()));
|
||||||
|
std::fs::write(&path, body).ok()?;
|
||||||
|
Some(Self(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arg(&self) -> &str { self.0.to_str().unwrap_or("/dev/null") }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for JumpConfig
|
||||||
|
{
|
||||||
|
fn drop(&mut self) { let _ = std::fs::remove_file(&self.0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
/// The `-o` options forced on every invocation, not user-configurable.
|
||||||
|
/// Shared by `build()` (as command-line `-o key=value` args) and
|
||||||
|
/// `JumpConfig::write` (as `ssh_config` lines), so the two representations
|
||||||
|
/// of "what's forced" can't drift apart.
|
||||||
|
fn forced_options(profile: &Profile) -> [(&'static str, String); 9]
|
||||||
|
{
|
||||||
|
[
|
||||||
|
("BatchMode", "yes".into()),
|
||||||
|
("StrictHostKeyChecking", "accept-new".into()),
|
||||||
|
("LogLevel", "ERROR".into()),
|
||||||
|
("ExitOnForwardFailure", "yes".into()),
|
||||||
|
("ConnectTimeout", "10".into()),
|
||||||
|
("ServerAliveCountMax", "3".into()),
|
||||||
|
("ControlMaster", "no".into()),
|
||||||
|
("ControlPath", "none".into()),
|
||||||
|
("ServerAliveInterval", profile.keepalive.to_string()),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the `ssh` command for `profile`, stdio wired for the supervisor
|
||||||
|
/// to capture (stdout/stderr piped so failure text can be classified;
|
||||||
|
/// stdin from `/dev/null` since porthole never wants a shell). The second
|
||||||
|
/// return value, when present, must outlive the spawned process: it owns
|
||||||
|
/// the config file `-F` points at and deletes it on drop.
|
||||||
|
pub fn build(profile: &Profile) -> (Command, Option<JumpConfig>)
|
||||||
|
{
|
||||||
|
let mut cmd = Command::new("ssh");
|
||||||
|
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||||
|
|
||||||
|
let (jumps, target) = profile.ssh_target();
|
||||||
|
|
||||||
|
let jump_config = jumps.is_some().then(|| JumpConfig::write(profile)).flatten();
|
||||||
|
let config_arg = jump_config.as_ref().map_or("/dev/null", |c| c.arg());
|
||||||
|
|
||||||
|
cmd.args(["-F", config_arg]);
|
||||||
|
for (key, value) in forced_options(profile) { cmd.args(["-o", &format!("{key}={value}")]); }
|
||||||
|
cmd.args(["-N", "-T"]);
|
||||||
|
|
||||||
|
if let Some(jumps) = jumps { cmd.args(["-J", &jumps]); }
|
||||||
|
|
||||||
|
cmd.arg("-p").arg(profile.ssh_port.to_string());
|
||||||
|
if let Some(identity) = &profile.identity
|
||||||
|
{
|
||||||
|
cmd.arg("-i").arg(identity);
|
||||||
|
cmd.args(["-o", "IdentitiesOnly=yes"]);
|
||||||
|
}
|
||||||
|
if let Some(user) = &profile.user { cmd.arg("-l").arg(user); }
|
||||||
|
|
||||||
|
cmd.arg(profile.kind.ssh_flag()).arg(&profile.mapping);
|
||||||
|
cmd.arg(target);
|
||||||
|
|
||||||
|
(cmd, jump_config)
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::profile::{Kind, ProfileEdits};
|
||||||
|
|
||||||
|
fn profile_with(via: Vec<&str>) -> Profile
|
||||||
|
{
|
||||||
|
Profile::new(
|
||||||
|
"t".into(),
|
||||||
|
&ProfileEdits {
|
||||||
|
local: Some("5432:db.internal:5432".into()),
|
||||||
|
via: Some(via.into_iter().map(String::from).collect()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_the_callers_own_ssh_config() {
|
||||||
|
let (cmd, _guard) = build(&profile_with(vec!["jumpbox"]));
|
||||||
|
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||||
|
assert!(args.windows(2).any(|w| w == ["-F", "/dev/null"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_hop_has_no_dash_j() {
|
||||||
|
let (cmd, _guard) = build(&profile_with(vec!["jumpbox"]));
|
||||||
|
let args: Vec<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_writes_a_config_carrying_hardening_and_identity_to_every_hop() {
|
||||||
|
let p = Profile::new(
|
||||||
|
"t2".into(),
|
||||||
|
&ProfileEdits {
|
||||||
|
local: Some("5432:db.internal:5432".into()),
|
||||||
|
via: Some(vec!["bastion1".into(), "bastion2".into()]),
|
||||||
|
identity: Some("/home/me/.ssh/id_ed25519".into()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let (cmd, guard) = build(&p);
|
||||||
|
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||||
|
let f_idx = args.iter().position(|a| a == "-F").expect("-F present");
|
||||||
|
let config_path = &args[f_idx + 1];
|
||||||
|
assert_ne!(config_path, "/dev/null");
|
||||||
|
let contents = std::fs::read_to_string(config_path).expect("config file should exist");
|
||||||
|
assert!(contents.contains("BatchMode yes"));
|
||||||
|
assert!(contents.contains("StrictHostKeyChecking accept-new"));
|
||||||
|
assert!(contents.contains("IdentityFile /home/me/.ssh/id_ed25519"));
|
||||||
|
assert!(contents.contains("IdentitiesOnly yes"));
|
||||||
|
drop(guard);
|
||||||
|
assert!(!std::path::Path::new(config_path).exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_hop_splits_jumps_from_target() {
|
||||||
|
let (cmd, _guard) = build(&profile_with(vec!["bastion1", "bastion2:2222"]));
|
||||||
|
let args: Vec<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, _guard) = 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identities_only_set_when_identity_given() {
|
||||||
|
let p = Profile::new(
|
||||||
|
"t".into(),
|
||||||
|
&ProfileEdits {
|
||||||
|
local: Some("5432:db.internal:5432".into()),
|
||||||
|
via: Some(vec!["jumpbox".into()]),
|
||||||
|
identity: Some("/home/me/.ssh/id_ed25519".into()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let (cmd, _guard) = build(&p);
|
||||||
|
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||||
|
let i_idx = args.iter().position(|a| a == "-i").expect("-i present");
|
||||||
|
assert_eq!(args[i_idx + 1], "/home/me/.ssh/id_ed25519");
|
||||||
|
assert!(args.windows(2).any(|w| w == ["-o", "IdentitiesOnly=yes"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identities_only_absent_without_identity() {
|
||||||
|
let p = profile_with(vec!["jumpbox"]);
|
||||||
|
assert!(p.identity.is_none());
|
||||||
|
let (cmd, _guard) = build(&p);
|
||||||
|
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||||
|
assert!(!args.windows(2).any(|w| w == ["-o", "IdentitiesOnly=yes"]));
|
||||||
|
}
|
||||||
|
}
|
||||||
311
src/supervisor.rs
Normal file
311
src/supervisor.rs
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
//! The `__supervise` loop 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 std::fs::OpenOptions;
|
||||||
|
use std::sync::{ Arc, Mutex };
|
||||||
|
use std::thread::JoinHandle;
|
||||||
|
use std::time::{ Instant, Duration };
|
||||||
|
use std::io::{ Write, BufRead, BufReader };
|
||||||
|
use std::sync::atomic::{ Ordering, AtomicBool };
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::{ ssh, timefmt };
|
||||||
|
use crate::profile::{ self, Profile };
|
||||||
|
use crate::instance::{ self, Lock, State, Instance };
|
||||||
|
|
||||||
|
/// How long a connection must survive before its uptime resets the backoff
|
||||||
|
/// counter back to the base delay.
|
||||||
|
const STABLE_THRESHOLD_SECS: i64 = 60;
|
||||||
|
/// Consecutive unrecognized (not pattern-matched) failures before porthole
|
||||||
|
/// gives up on an apparently-permanently-broken profile.
|
||||||
|
const MAX_UNRECOGNIZED_STREAK: u32 = 10;
|
||||||
|
/// How long `ssh` must stay alive before porthole treats it as connected;
|
||||||
|
/// see `run_ssh_once` for the heuristic this backs.
|
||||||
|
const CONNECT_GRACE: Duration = Duration::from_secs(2);
|
||||||
|
const POLL_INTERVAL: Duration = Duration::from_millis(200);
|
||||||
|
const LOG_ROTATE_BYTES: u64 = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
static SHUTDOWN: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
extern "C" fn handle_sigterm(_sig: libc::c_int) { SHUTDOWN.store(true, Ordering::SeqCst); }
|
||||||
|
|
||||||
|
/// Traps SIGTERM and SIGINT into a flag instead of the default
|
||||||
|
/// terminate-immediately behavior. This is how `close` (SIGTERM) and
|
||||||
|
/// `-f/--foreground`'s Ctrl-C (SIGINT) are distinguished from a
|
||||||
|
/// dropped `ssh` connection: by which signal arrived, not by inferring
|
||||||
|
/// intent from `ssh`'s exit status. In foreground mode this function runs
|
||||||
|
/// in the process the terminal sends Ctrl-C to directly, since
|
||||||
|
/// `commands::open` calls `supervisor::run` inline rather than detaching.
|
||||||
|
fn install_signal_handler() {
|
||||||
|
unsafe {
|
||||||
|
libc::signal(libc::SIGTERM, handle_sigterm as *const () as usize);
|
||||||
|
libc::signal(libc::SIGINT, handle_sigterm as *const () as usize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Class {
|
||||||
|
Fatal,
|
||||||
|
KnownTransient,
|
||||||
|
Unrecognized,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Outcome {
|
||||||
|
ShutdownRequested,
|
||||||
|
Failed { class: Class, message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entry point for `porthole __supervise <name>`. This function is the
|
||||||
|
/// supervisor process: it runs until told to stop (SIGTERM/SIGINT) or
|
||||||
|
/// gives up.
|
||||||
|
pub fn run(name: &str) -> Result<()>
|
||||||
|
{
|
||||||
|
install_signal_handler();
|
||||||
|
|
||||||
|
let profile = profile::load(name)?;
|
||||||
|
|
||||||
|
// Holding this for our entire lifetime is what makes
|
||||||
|
// for a reliable, race-free check for `open`.
|
||||||
|
let Some(_lock) = Lock::try_acquire(name)? else {
|
||||||
|
return Ok(()); // another supervisor; nothing to do
|
||||||
|
};
|
||||||
|
|
||||||
|
let pid = std::process::id() as i32;
|
||||||
|
let mut inst = Instance::new(name.to_string(), pid);
|
||||||
|
instance::save(&inst)?;
|
||||||
|
|
||||||
|
let once = std::env::var_os("PORTHOLE_SUPERVISE_ONCE").is_some();
|
||||||
|
let base_delay = profile.retry_interval.max(1) as u64;
|
||||||
|
let max_delay = (profile.backoff_max as u64).max(base_delay);
|
||||||
|
|
||||||
|
let mut delay: u64 = base_delay;
|
||||||
|
let mut unrecognized_streak: u32 = 0;
|
||||||
|
|
||||||
|
loop
|
||||||
|
{
|
||||||
|
let attempt_started = timefmt::now();
|
||||||
|
|
||||||
|
match run_ssh_once(name, &profile, &mut inst)
|
||||||
|
{
|
||||||
|
Outcome::ShutdownRequested => {
|
||||||
|
instance::delete(name)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Outcome::Failed { class, message } =>
|
||||||
|
{
|
||||||
|
let uptime = timefmt::now() - attempt_started;
|
||||||
|
|
||||||
|
if uptime >= STABLE_THRESHOLD_SECS {
|
||||||
|
delay = base_delay;
|
||||||
|
unrecognized_streak = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
match class {
|
||||||
|
Class::Unrecognized => unrecognized_streak += 1,
|
||||||
|
Class::KnownTransient => unrecognized_streak = 0,
|
||||||
|
Class::Fatal => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
inst.last_error = Some(if message.is_empty() { "ssh exited unexpectedly (no output captured)".to_string() } else { message });
|
||||||
|
|
||||||
|
let fatal = matches!(class, Class::Fatal);
|
||||||
|
let give_up = fatal || !profile.reconnect || once || unrecognized_streak > MAX_UNRECOGNIZED_STREAK;
|
||||||
|
|
||||||
|
if give_up {
|
||||||
|
inst.state = State::Error;
|
||||||
|
instance::save(&inst)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
inst.state = State::Reconnecting;
|
||||||
|
inst.reconnect_count += 1;
|
||||||
|
inst.last_reconnect_at = Some(timefmt::now());
|
||||||
|
inst.connected_at = None;
|
||||||
|
instance::save(&inst)?;
|
||||||
|
|
||||||
|
if sleep_or_shutdown(Duration::from_secs(delay)) {
|
||||||
|
instance::delete(name)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
delay = (delay * 2).min(max_delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sleeps for `dur`, polling `SHUTDOWN` periodically so a `close` that
|
||||||
|
/// arrives during a reconnect backoff window is honored promptly instead
|
||||||
|
/// of waiting out the full delay. Returns `true` if shutdown was requested.
|
||||||
|
fn sleep_or_shutdown(dur: Duration) -> bool
|
||||||
|
{
|
||||||
|
let deadline = Instant::now() + dur;
|
||||||
|
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
if SHUTDOWN.load(Ordering::SeqCst) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
std::thread::sleep(POLL_INTERVAL.min(dur));
|
||||||
|
}
|
||||||
|
|
||||||
|
SHUTDOWN.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns one `ssh` attempt and supervises it until it exits or shutdown is
|
||||||
|
/// requested. Marks `inst` as `State::Up` once the process has survived
|
||||||
|
/// `CONNECT_GRACE`. `ssh` does not report "the forward is bound" directly
|
||||||
|
/// without parsing `-v` debug output; a real failure exits near-instantly
|
||||||
|
/// under `ExitOnForwardFailure=yes`, so staying alive past the grace
|
||||||
|
/// window is used as a proxy for connected.
|
||||||
|
fn run_ssh_once(name: &str, profile: &Profile, inst: &mut Instance) -> Outcome
|
||||||
|
{
|
||||||
|
rotate_log_if_large(name);
|
||||||
|
|
||||||
|
// `_jump_config`, when present, must stay alive for this whole
|
||||||
|
// function: it owns the config file `-F` points ssh at, and every
|
||||||
|
// return path below runs the ssh process to completion first.
|
||||||
|
let (mut cmd, _jump_config) = ssh::build(profile);
|
||||||
|
let mut child = match cmd.spawn() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => return Outcome::Failed { class: Class::Unrecognized, message: format!("failed to spawn ssh: {e}") },
|
||||||
|
};
|
||||||
|
|
||||||
|
let stderr_tail = Arc::new(Mutex::new(String::new()));
|
||||||
|
let stdout_thread = child.stdout.take().map(|out| spawn_log_drain(name, out));
|
||||||
|
let stderr_thread = child.stderr.take().map(|err| spawn_stderr_drain(name, err, stderr_tail.clone()));
|
||||||
|
|
||||||
|
let grace_deadline = Instant::now() + CONNECT_GRACE;
|
||||||
|
let mut marked_up = false;
|
||||||
|
|
||||||
|
loop
|
||||||
|
{
|
||||||
|
if SHUTDOWN.load(Ordering::SeqCst) {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
join_all([stdout_thread, stderr_thread]);
|
||||||
|
return Outcome::ShutdownRequested;
|
||||||
|
}
|
||||||
|
|
||||||
|
match child.try_wait()
|
||||||
|
{
|
||||||
|
Ok(Some(_status)) => break,
|
||||||
|
Ok(None) =>
|
||||||
|
{
|
||||||
|
if !marked_up && Instant::now() >= grace_deadline
|
||||||
|
{
|
||||||
|
marked_up = true;
|
||||||
|
inst.state = State::Up;
|
||||||
|
inst.connected_at = Some(timefmt::now());
|
||||||
|
let _ = instance::save(inst);
|
||||||
|
}
|
||||||
|
std::thread::sleep(POLL_INTERVAL);
|
||||||
|
}
|
||||||
|
Err(_) => break, // process table race (should not happen on unix); treat as exited
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
join_all([stdout_thread, stderr_thread]);
|
||||||
|
|
||||||
|
let tail = stderr_tail.lock().map(|s| s.clone()).unwrap_or_default();
|
||||||
|
|
||||||
|
let (class, message) = classify(&tail);
|
||||||
|
|
||||||
|
Outcome::Failed { class, message }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn join_all<const N: usize>(handles: [Option<JoinHandle<()>>; N]) {
|
||||||
|
for h in handles.into_iter().flatten() {
|
||||||
|
let _ = h.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classifies `ssh`'s captured stderr. Fatal patterns stop
|
||||||
|
/// the reconnect loop outright; known-transient patterns retry without
|
||||||
|
/// counting toward the unrecognized-failure escalation; anything else
|
||||||
|
/// still retries, but does count toward it.
|
||||||
|
fn classify(stderr_tail: &str) -> (Class, String)
|
||||||
|
{
|
||||||
|
const FATAL: &[&str] = &["Permission denied", "Host key verification failed", "bind: Address already in use"];
|
||||||
|
const KNOWN_TRANSIENT: &[&str] = &[
|
||||||
|
"Connection refused",
|
||||||
|
"No route to host",
|
||||||
|
"Could not resolve hostname",
|
||||||
|
"Connection timed out",
|
||||||
|
"Operation timed out",
|
||||||
|
];
|
||||||
|
|
||||||
|
let message = stderr_tail.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("").trim().to_string();
|
||||||
|
|
||||||
|
if FATAL.iter().any(|p| stderr_tail.contains(p)) { (Class::Fatal, message) }
|
||||||
|
else if KNOWN_TRANSIENT.iter().any(|p| stderr_tail.contains(p)) { (Class::KnownTransient, message) }
|
||||||
|
else { (Class::Unrecognized, message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rotate_log_if_large(name: &str)
|
||||||
|
{
|
||||||
|
let path = instance::log_path(name);
|
||||||
|
|
||||||
|
if let Ok(meta) = std::fs::metadata(&path) {
|
||||||
|
if meta.len() > LOG_ROTATE_BYTES {
|
||||||
|
let _ = std::fs::rename(&path, path.with_extension("log.1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_log(name: &str, line: &str) {
|
||||||
|
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(instance::log_path(name)) {
|
||||||
|
let _ = writeln!(f, "{line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_log_drain(name: &str, out: std::process::ChildStdout) -> JoinHandle<()>
|
||||||
|
{
|
||||||
|
let name = name.to_string();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
for line in BufReader::new(out).lines().map_while(std::result::Result::ok) {
|
||||||
|
append_log(&name, &line);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_stderr_drain(name: &str, err: std::process::ChildStderr, tail: Arc<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
73
src/timefmt.rs
Normal 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
32
src/ui.rs
Normal 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)); }
|
||||||
504
tests/live_test.sh
Executable file
504
tests/live_test.sh
Executable file
@@ -0,0 +1,504 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Live integration test suite for porthole.
|
||||||
|
#
|
||||||
|
# Exercises every command against a REAL SSH server: real tunnels, real
|
||||||
|
# auth (success and failure), a real two-hop ProxyJump, a real SOCKS proxy
|
||||||
|
# carrying real traffic. Deliberately NOT part of `cargo test` (same reason
|
||||||
|
# as vmic's tests/live_test.sh: this needs a real remote server, not a CI
|
||||||
|
# sandbox), live-only, opt-in, run by hand.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# tests/live_test.sh [options]
|
||||||
|
#
|
||||||
|
# Options (env var or flag; flag wins if both given):
|
||||||
|
# --bin PATH PORTHOLE_TEST_BIN porthole binary to test (default: target/debug/porthole)
|
||||||
|
# --host HOST PORTHOLE_TEST_HOST test server hostname (default: vpn.security-command.org)
|
||||||
|
# --user USER PORTHOLE_TEST_USER test server login user (default: overlord)
|
||||||
|
# --identity PATH PORTHOLE_TEST_IDENTITY identity file (default: ~/.ssh/id_ed25519_vpn)
|
||||||
|
# --name PREFIX PORTHOLE_TEST_NAME profile name prefix (default: porttestsuite)
|
||||||
|
# --skip-network PORTHOLE_TEST_SKIP_NETWORK=1 skip the SOCKS/icanhazip.com phase
|
||||||
|
# --skip-wipe PORTHOLE_TEST_SKIP_WIPE=1 skip the destructive `wipe` phase
|
||||||
|
# --no-build skip the `cargo build` preflight
|
||||||
|
# -h, --help
|
||||||
|
#
|
||||||
|
# Known limitation: only one real server is available, so multi-hop (`-J`)
|
||||||
|
# is tested by chaining the server through itself (--via user@host,user@host),
|
||||||
|
# a real two-hop ProxyJump handshake, just with both hops the same box.
|
||||||
|
# There is no way to test a genuine distinct-host chain without a second
|
||||||
|
# server.
|
||||||
|
#
|
||||||
|
# Safety:
|
||||||
|
# - Profiles/instances/locks/logs are sandboxed for the whole run under
|
||||||
|
# one `PORTHOLE_STATE_DIR_OVERRIDE` temp dir (profile.rs/instance.rs
|
||||||
|
# both honor it); this suite NEVER touches the real
|
||||||
|
# ~/.config/porthole or ~/.local/state/porthole.
|
||||||
|
# - Every profile created is named "$NAME_..." (default prefix
|
||||||
|
# porttestsuite); no operation targets anything outside that prefix.
|
||||||
|
# - `wipe` (src/commands/wipe.rs) kills ANY process on the
|
||||||
|
# whole system whose cmdline contains "__supervise", regardless of
|
||||||
|
# which state dir it belongs to; it is NOT scoped by the sandboxing
|
||||||
|
# above. Before running it, this script scans the real process table
|
||||||
|
# and skips the wipe phase entirely (not "wipe only the safe parts")
|
||||||
|
# if it finds a live __supervise process that isn't one of this run's
|
||||||
|
# own test profiles, so a real tunnel you have open elsewhere is never
|
||||||
|
# killed as a side effect of running this suite.
|
||||||
|
# - Never runs a bare ssh with interactive prompting: every verification
|
||||||
|
# ssh call this script itself makes uses BatchMode=yes plus a
|
||||||
|
# throwaway UserKnownHostsFile=/dev/null, so it never prompts and
|
||||||
|
# never writes to your real ~/.ssh/known_hosts (only porthole's own
|
||||||
|
# spawned ssh does, against the real file, using the accept-new policy
|
||||||
|
# already forced in src/ssh.rs).
|
||||||
|
# - An EXIT trap always attempts full cleanup, closes/removes every
|
||||||
|
# test-prefixed profile in every sandbox dir used, force-kills any
|
||||||
|
# stray matching __supervise process, deletes the throwaway bad-auth
|
||||||
|
# key, even on failure or Ctrl-C.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||||
|
|
||||||
|
BIN="${PORTHOLE_TEST_BIN:-target/debug/porthole}"
|
||||||
|
HOST="${PORTHOLE_TEST_HOST:-vpn.security-command.org}"
|
||||||
|
USER_="${PORTHOLE_TEST_USER:-overlord}"
|
||||||
|
IDENTITY="${PORTHOLE_TEST_IDENTITY:-$HOME/.ssh/id_ed25519_vpn}"
|
||||||
|
NAME="${PORTHOLE_TEST_NAME:-porttestsuite}"
|
||||||
|
SKIP_NETWORK="${PORTHOLE_TEST_SKIP_NETWORK:-0}"
|
||||||
|
SKIP_WIPE="${PORTHOLE_TEST_SKIP_WIPE:-0}"
|
||||||
|
DO_BUILD=1
|
||||||
|
|
||||||
|
usage() { sed -n '2,/^set -uo/p' "$0" | sed '$d; s/^# \{0,1\}//'; }
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--bin) BIN="$2"; shift 2 ;;
|
||||||
|
--host) HOST="$2"; shift 2 ;;
|
||||||
|
--user) USER_="$2"; shift 2 ;;
|
||||||
|
--identity) IDENTITY="$2"; shift 2 ;;
|
||||||
|
--name) NAME="$2"; shift 2 ;;
|
||||||
|
--skip-network) SKIP_NETWORK=1; shift ;;
|
||||||
|
--skip-wipe) SKIP_WIPE=1; shift ;;
|
||||||
|
--no-build) DO_BUILD=0; shift ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
NAME="$(tr '[:upper:]' '[:lower:]' <<<"$NAME")"
|
||||||
|
|
||||||
|
RED=$'\e[31m'; GREEN=$'\e[32m'; YELLOW=$'\e[33m'; BLUE=$'\e[34m'; RESET=$'\e[0m'
|
||||||
|
[[ -t 1 ]] || { RED=""; GREEN=""; YELLOW=""; BLUE=""; RESET=""; }
|
||||||
|
|
||||||
|
PASS=0; FAIL=0; SKIP=0
|
||||||
|
section() { echo; echo "${BLUE}== $1 ==${RESET}"; }
|
||||||
|
pass() { PASS=$((PASS+1)); echo " ${GREEN}PASS${RESET} $1"; }
|
||||||
|
fail() { FAIL=$((FAIL+1)); echo " ${RED}FAIL${RESET} $1"; [[ -n "${2:-}" ]] && echo " ${2//$'\n'/$'\n '}"; }
|
||||||
|
skip() { SKIP=$((SKIP+1)); echo " ${YELLOW}SKIP${RESET} $1"; }
|
||||||
|
|
||||||
|
LAST_OUT=""; LAST_CODE=0
|
||||||
|
porthole_run() { LAST_OUT="$("$BIN" "$@" 2>&1)"; LAST_CODE=$?; }
|
||||||
|
|
||||||
|
expect_exit() { # expect_exit <desc> <expected_code>
|
||||||
|
if [[ "$LAST_CODE" == "$2" ]]; then pass "$1 (exit $LAST_CODE)"
|
||||||
|
else fail "$1 (expected exit $2, got $LAST_CODE)" "$LAST_OUT"; fi
|
||||||
|
}
|
||||||
|
expect_contains() { # expect_contains <desc> <needle>
|
||||||
|
if [[ "$LAST_OUT" == *"$2"* ]]; then pass "$1"
|
||||||
|
else fail "$1 (expected output to contain: $2)" "$LAST_OUT"; fi
|
||||||
|
}
|
||||||
|
expect_not_contains() {
|
||||||
|
if [[ "$LAST_OUT" != *"$2"* ]]; then pass "$1"
|
||||||
|
else fail "$1 (expected output NOT to contain: $2)" "$LAST_OUT"; fi
|
||||||
|
}
|
||||||
|
assert_eq() { if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1" "expected '$3', got '$2'"; fi; }
|
||||||
|
assert_true() { if "${@:2}" >/dev/null 2>&1; then pass "$1"; else fail "$1"; fi; }
|
||||||
|
|
||||||
|
# Every use below passes an explicit PORTHOLE_STATE_DIR_OVERRIDE, so this
|
||||||
|
# never touches ~/.config/porthole or ~/.local/state/porthole.
|
||||||
|
p() { PORTHOLE_STATE_DIR_OVERRIDE="$1" "$BIN" "${@:2}"; }
|
||||||
|
p_run() { LAST_OUT="$(PORTHOLE_STATE_DIR_OVERRIDE="$1" "$BIN" "${@:2}" 2>&1)"; LAST_CODE=$?; }
|
||||||
|
|
||||||
|
# status --json's "state" field is always one of closed/up/reconnecting/error
|
||||||
|
# (print_json normalizes a dead-supervisor instance file to "error" too, see
|
||||||
|
# status.rs); polling that key is far more robust than scraping the padded
|
||||||
|
# human-readable field.
|
||||||
|
json_field() { # json_field <state-dir> <name> <field> -> value, empty if absent/no instance
|
||||||
|
local out v
|
||||||
|
out="$(p "$1" status "$2" --json 2>/dev/null)"
|
||||||
|
# Quoted string value first: the closing quote is mandatory here (unlike
|
||||||
|
# a bare [^",]* class) so a value with an embedded comma - a real ssh
|
||||||
|
# "Permission denied (publickey,password)." error has one - isn't
|
||||||
|
# truncated at the first comma instead of its actual end.
|
||||||
|
v="$(sed -n "s/.*\"$3\": \"\\(.*\\)\",\\{0,1\\}\$/\\1/p" <<<"$out" | head -1)"
|
||||||
|
if [[ -n "$v" ]]; then echo "$v"; return; fi
|
||||||
|
# Bare (unquoted) value: number, bool, or null.
|
||||||
|
sed -n "s/.*\"$3\": \\([^\",]*\\),\\{0,1\\}\$/\\1/p" <<<"$out" | head -1
|
||||||
|
}
|
||||||
|
wait_for_state() { # wait_for_state <state-dir> <name> <want-state> [tries, x0.5s]
|
||||||
|
local dir="$1" name="$2" want="$3" tries="${4:-20}"
|
||||||
|
for _ in $(seq 1 "$tries"); do
|
||||||
|
[[ "$(json_field "$dir" "$name" state)" == "$want" ]] && return 0
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
port_open() { timeout 1 bash -c "exec 3<>/dev/tcp/127.0.0.1/$1" 2>/dev/null; } # port_open <port>
|
||||||
|
wait_port_closed() { # wait_port_closed <port> [tries, x0.5s]
|
||||||
|
for _ in $(seq 1 "${2:-10}"); do
|
||||||
|
port_open "$1" || return 0
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
SSH_PROBE_OPTS=(-o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null)
|
||||||
|
|
||||||
|
# mktemp's output is captured via $(...), which forks a subshell; any
|
||||||
|
# array append done *inside* a function called that way would be lost when
|
||||||
|
# the subshell exits, so state dirs are appended here at the call site
|
||||||
|
# instead of through a helper function.
|
||||||
|
STATE_DIRS=()
|
||||||
|
STATE_DIR="$(mktemp -d)"; STATE_DIRS+=("$STATE_DIR")
|
||||||
|
BADKEY="$(mktemp -u)"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
section "Cleanup"
|
||||||
|
for d in "${STATE_DIRS[@]:-}"; do
|
||||||
|
[[ -z "$d" ]] && continue
|
||||||
|
for prof in $(p "$d" list --json 2>/dev/null | sed -n 's/.*"name": "\([^"]*\)".*/\1/p'); do
|
||||||
|
p "$d" close --force "$prof" >/dev/null 2>&1 || true
|
||||||
|
p "$d" remove "$prof" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
rm -rf "$d"
|
||||||
|
done
|
||||||
|
pkill -f "__supervise ${NAME}_" 2>/dev/null || true
|
||||||
|
rm -f "$BADKEY" "$BADKEY.pub" 2>/dev/null || true
|
||||||
|
echo " done."
|
||||||
|
echo
|
||||||
|
echo "${BLUE}== Results ==${RESET} ${GREEN}$PASS passed${RESET}, ${RED}$FAIL failed${RESET}, ${YELLOW}$SKIP skipped${RESET}"
|
||||||
|
[[ "$FAIL" -eq 0 ]]
|
||||||
|
}
|
||||||
|
trap 'cleanup; exit $(( $? ))' EXIT
|
||||||
|
|
||||||
|
echo "porthole: $BIN"
|
||||||
|
echo "server: $USER_@$HOST (identity: $IDENTITY)"
|
||||||
|
echo "test name: $NAME (+ suffixes) in $STATE_DIR"
|
||||||
|
|
||||||
|
if [[ "$DO_BUILD" == "1" ]]; then
|
||||||
|
section "Build"
|
||||||
|
if cargo build 2>&1 | tee /dev/stderr | grep -q '^error'; then
|
||||||
|
echo "build failed, aborting." >&2; exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[[ -x "$BIN" ]] || { echo "binary not found/executable: $BIN" >&2; exit 1; }
|
||||||
|
[[ -f "$IDENTITY" ]] || { echo "identity file not found: $IDENTITY" >&2; exit 1; }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
section "Phase 1: CLI surface"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
porthole_run; expect_exit "bare 'porthole' shows help" 2
|
||||||
|
expect_contains "bare 'porthole' mentions Usage" "Usage:"
|
||||||
|
porthole_run -h; expect_exit "'porthole -h'" 0
|
||||||
|
porthole_run help; expect_exit "'porthole help'" 0
|
||||||
|
porthole_run --version; expect_exit "'porthole --version'" 0
|
||||||
|
expect_contains "'--version' mentions porthole" "porthole"
|
||||||
|
for cmd in add open close edit status list remove wipe transfer; do
|
||||||
|
porthole_run "$cmd" --help; expect_exit "'porthole $cmd --help'" 0
|
||||||
|
done
|
||||||
|
for shell in bash zsh fish; do
|
||||||
|
porthole_run completions "$shell"
|
||||||
|
assert_eq "'porthole completions $shell' exits 0" "$LAST_CODE" "0"
|
||||||
|
[[ -n "$LAST_OUT" ]] && pass "'porthole completions $shell' produces output" || fail "'porthole completions $shell' produces output" "(empty)"
|
||||||
|
done
|
||||||
|
|
||||||
|
porthole_run --help
|
||||||
|
expect_contains "value-name shows real mapping grammar" "<[BIND:]PORT:HOST:PORT>"
|
||||||
|
expect_contains "value-name shows PATH.toml for transfer" "<PATH.toml>"
|
||||||
|
expect_contains "required positional renders as <name>" "add <name>"
|
||||||
|
expect_contains "optional positional renders as [name]" "open [name]"
|
||||||
|
expect_contains "optional positional renders as [name] (transfer)" "transfer [name]"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
section "Phase 2: error paths (pre-creation)"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
p_run "$STATE_DIR" add; expect_exit "'add' with no name fails" 2
|
||||||
|
p_run "$STATE_DIR" add "bad name!"; assert_eq "'add' with an invalid name exits 1" "$LAST_CODE" "1"
|
||||||
|
expect_contains "invalid name error message" "invalid name"
|
||||||
|
p_run "$STATE_DIR" add "${NAME}_x"; assert_eq "'add' with no mapping kind exits 1" "$LAST_CODE" "1"
|
||||||
|
expect_contains "no-mapping-kind error message" "exactly one of -l/--local"
|
||||||
|
p_run "$STATE_DIR" add "${NAME}_x" -l 1:h:1 -r 2:h:2; assert_eq "'add' with conflicting mapping kinds exits 1" "$LAST_CODE" "1"
|
||||||
|
expect_contains "conflicting-mapping error message" "only one of -l/--local"
|
||||||
|
p_run "$STATE_DIR" add "${NAME}_x" -l 1:h:1; assert_eq "'add' with no --via exits 1" "$LAST_CODE" "1"
|
||||||
|
expect_contains "no-via error message" "--via is required"
|
||||||
|
p_run "$STATE_DIR" status "${NAME}_nope"; assert_eq "'status' on nonexistent profile exits 1" "$LAST_CODE" "1"
|
||||||
|
expect_contains "nonexistent-profile error (status)" "no profile named"
|
||||||
|
p_run "$STATE_DIR" close "${NAME}_nope"; assert_eq "'close' on nonexistent profile exits 1" "$LAST_CODE" "1"
|
||||||
|
p_run "$STATE_DIR" edit "${NAME}_nope" -l 1:h:1; assert_eq "'edit' on nonexistent profile exits 1" "$LAST_CODE" "1"
|
||||||
|
p_run "$STATE_DIR" remove "${NAME}_nope"; assert_eq "'remove' on nonexistent profile exits 1" "$LAST_CODE" "1"
|
||||||
|
p_run "$STATE_DIR" transfer; assert_eq "'transfer' with no mode exits 1" "$LAST_CODE" "1"
|
||||||
|
expect_contains "transfer no-mode error message" "exactly one of -i/--import"
|
||||||
|
p_run "$STATE_DIR" transfer -e /tmp/x.toml -i /tmp/x.toml; assert_eq "'transfer' with both modes exits 1" "$LAST_CODE" "1"
|
||||||
|
expect_contains "transfer conflicting-mode error message" "only one of -i/--import"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
section "Phase 3: local forward (-l), single hop - the baseline path"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
LOCAL_PORT=28221
|
||||||
|
p_run "$STATE_DIR" add "${NAME}_local" -l "$LOCAL_PORT:localhost:22" -i "$IDENTITY" --via "$USER_@$HOST"
|
||||||
|
expect_exit "'add ${NAME}_local'" 0
|
||||||
|
p_run "$STATE_DIR" status "${NAME}_local"; expect_contains "fresh profile is closed" "closed"
|
||||||
|
|
||||||
|
p_run "$STATE_DIR" open "${NAME}_local"; expect_exit "'open ${NAME}_local'" 0
|
||||||
|
assert_true "'${NAME}_local' reaches state: up" wait_for_state "$STATE_DIR" "${NAME}_local" up 10
|
||||||
|
|
||||||
|
if ssh "${SSH_PROBE_OPTS[@]}" -i "$IDENTITY" -o IdentitiesOnly=yes -l "$USER_" -p "$LOCAL_PORT" localhost true 2>/tmp/porthole_test_probe.$$; then
|
||||||
|
pass "forwarded port $LOCAL_PORT actually round-trips to the real sshd"
|
||||||
|
else
|
||||||
|
fail "forwarded port $LOCAL_PORT actually round-trips to the real sshd" "$(cat /tmp/porthole_test_probe.$$ 2>/dev/null)"
|
||||||
|
fi
|
||||||
|
rm -f "/tmp/porthole_test_probe.$$" 2>/dev/null
|
||||||
|
|
||||||
|
p_run "$STATE_DIR" close "${NAME}_local"; expect_exit "'close ${NAME}_local'" 0
|
||||||
|
assert_true "port $LOCAL_PORT stops listening after close" wait_port_closed "$LOCAL_PORT"
|
||||||
|
p_run "$STATE_DIR" close "${NAME}_local"; expect_exit "re-'close' on an already-closed profile still exits 0" 0
|
||||||
|
expect_contains "idempotent-close message" "is not open"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
section "Phase 4: remote forward (-r) - binds on the server, dials back out locally"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
REMOTE_PORT=28225
|
||||||
|
p_run "$STATE_DIR" add "${NAME}_remote" -r "$REMOTE_PORT:$HOST:22" -i "$IDENTITY" --via "$USER_@$HOST"
|
||||||
|
expect_exit "'add ${NAME}_remote'" 0
|
||||||
|
p_run "$STATE_DIR" open "${NAME}_remote"; expect_exit "'open ${NAME}_remote'" 0
|
||||||
|
assert_true "'${NAME}_remote' reaches state: up" wait_for_state "$STATE_DIR" "${NAME}_remote" up 10
|
||||||
|
|
||||||
|
# The inner ssh below runs on the remote box itself (reached through the
|
||||||
|
# outer session), then loops back out through the -R forward to a real
|
||||||
|
# sshd on this same server - $IDENTITY is a local path and won't exist
|
||||||
|
# there, so it has no credentials for that final hop. Reaching the real
|
||||||
|
# sshd and being rejected already proves the forward round-trips; a
|
||||||
|
# refused/timed-out connection is what would indicate it's actually broken.
|
||||||
|
remote_check="$(ssh "${SSH_PROBE_OPTS[@]}" -i "$IDENTITY" -o IdentitiesOnly=yes "$USER_@$HOST" \
|
||||||
|
"ssh -p $REMOTE_PORT -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null localhost true; echo EXIT:\$?" 2>&1)"
|
||||||
|
if [[ "$remote_check" == *"EXIT:0"* || "$remote_check" == *"Permission denied"* ]]; then
|
||||||
|
pass "remote-bound port $REMOTE_PORT round-trips back out through the tunnel"
|
||||||
|
else
|
||||||
|
fail "remote-bound port $REMOTE_PORT round-trips back out through the tunnel" "$remote_check"
|
||||||
|
fi
|
||||||
|
|
||||||
|
p_run "$STATE_DIR" close "${NAME}_remote"; expect_exit "'close ${NAME}_remote'" 0
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
section "Phase 5: dynamic forward (-d, SOCKS) - proves traffic actually transits"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if [[ "$SKIP_NETWORK" == "1" ]]; then
|
||||||
|
skip "SOCKS traffic-routing check (--skip-network passed)"
|
||||||
|
elif ! command -v curl >/dev/null; then
|
||||||
|
skip "SOCKS traffic-routing check (curl not installed)"
|
||||||
|
else
|
||||||
|
SOCKS_PORT=28226
|
||||||
|
p_run "$STATE_DIR" add "${NAME}_dynamic" -d "$SOCKS_PORT" -i "$IDENTITY" --via "$USER_@$HOST"
|
||||||
|
expect_exit "'add ${NAME}_dynamic'" 0
|
||||||
|
p_run "$STATE_DIR" open "${NAME}_dynamic"; expect_exit "'open ${NAME}_dynamic'" 0
|
||||||
|
assert_true "'${NAME}_dynamic' reaches state: up" wait_for_state "$STATE_DIR" "${NAME}_dynamic" up 10
|
||||||
|
|
||||||
|
direct_ip="$(curl -s --max-time 8 https://icanhazip.com | tr -d '[:space:]')"
|
||||||
|
proxied_ip="$(curl -s --max-time 8 -x "socks5h://localhost:$SOCKS_PORT" https://icanhazip.com | tr -d '[:space:]')"
|
||||||
|
if [[ -z "$direct_ip" || -z "$proxied_ip" ]]; then
|
||||||
|
skip "SOCKS traffic-routing check (icanhazip.com unreachable right now)"
|
||||||
|
elif [[ "$proxied_ip" != "$direct_ip" ]]; then
|
||||||
|
pass "SOCKS proxy traffic exits via the remote server ($proxied_ip != local $direct_ip)"
|
||||||
|
else
|
||||||
|
fail "SOCKS proxy traffic exits via the remote server" "proxied IP ($proxied_ip) matched direct IP - traffic didn't actually route through the tunnel"
|
||||||
|
fi
|
||||||
|
p_run "$STATE_DIR" close "${NAME}_dynamic"; expect_exit "'close ${NAME}_dynamic'" 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
section "Phase 6: multi-hop --via (self-jump - see header comment for why)"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# A real ssh refuses a ProxyJump hop that's textually identical to the
|
||||||
|
# final target ("jumphost loop via ..."), so USER@HOST,USER@HOST can never
|
||||||
|
# work no matter how porthole builds the command - it's rejected before a
|
||||||
|
# connection is even attempted. Using the server's IP for the first hop and
|
||||||
|
# its hostname for the final target is the same physical box (still a real
|
||||||
|
# 2-hop handshake) but sidesteps that string-identity check.
|
||||||
|
HOP_PORT=28223
|
||||||
|
HOST_IP="$(getent ahostsv4 "$HOST" 2>/dev/null | awk '{print $1; exit}')"
|
||||||
|
if [[ -z "$HOST_IP" ]]; then
|
||||||
|
skip "multi-hop phase (could not resolve $HOST to an IP for the loop workaround)"
|
||||||
|
else
|
||||||
|
p_run "$STATE_DIR" add "${NAME}_multihop" -l "$HOP_PORT:localhost:22" -i "$IDENTITY" --via "$USER_@$HOST_IP,$USER_@$HOST"
|
||||||
|
expect_exit "'add ${NAME}_multihop' with a 2-hop --via" 0
|
||||||
|
p_run "$STATE_DIR" open "${NAME}_multihop"; expect_exit "'open ${NAME}_multihop'" 0
|
||||||
|
assert_true "'${NAME}_multihop' reaches state: up (real -J handshake, twice)" wait_for_state "$STATE_DIR" "${NAME}_multihop" up 30
|
||||||
|
|
||||||
|
if ssh "${SSH_PROBE_OPTS[@]}" -i "$IDENTITY" -o IdentitiesOnly=yes -l "$USER_" -p "$HOP_PORT" localhost true 2>/tmp/porthole_test_probe.$$; then
|
||||||
|
pass "forwarded port round-trips through the 2-hop chain"
|
||||||
|
else
|
||||||
|
fail "forwarded port round-trips through the 2-hop chain" "$(cat /tmp/porthole_test_probe.$$ 2>/dev/null)"
|
||||||
|
fi
|
||||||
|
rm -f "/tmp/porthole_test_probe.$$" 2>/dev/null
|
||||||
|
p_run "$STATE_DIR" close "${NAME}_multihop"; expect_exit "'close ${NAME}_multihop'" 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
section "Phase 7: -u/--user without an embedded via user - real -l flag auth"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
ALTUSER_PORT=28224
|
||||||
|
p_run "$STATE_DIR" add "${NAME}_altuser" -l "$ALTUSER_PORT:localhost:22" -i "$IDENTITY" --via "$HOST" -u "$USER_"
|
||||||
|
expect_exit "'add ${NAME}_altuser' (--via with no embedded user, -u instead)" 0
|
||||||
|
p_run "$STATE_DIR" open "${NAME}_altuser"; expect_exit "'open ${NAME}_altuser'" 0
|
||||||
|
assert_true "'${NAME}_altuser' authenticates via -u/-l, reaches state: up" wait_for_state "$STATE_DIR" "${NAME}_altuser" up 10
|
||||||
|
p_run "$STATE_DIR" close "${NAME}_altuser"; expect_exit "'close ${NAME}_altuser'" 0
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
section "Phase 8: real auth failure - Fatal classification"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if ! command -v ssh-keygen >/dev/null; then
|
||||||
|
skip "real Fatal-classification check (ssh-keygen not installed)"
|
||||||
|
else
|
||||||
|
ssh-keygen -q -t ed25519 -N '' -f "$BADKEY" >/dev/null
|
||||||
|
p_run "$STATE_DIR" add "${NAME}_badauth" -l 28230:localhost:22 -i "$BADKEY" --via "$USER_@$HOST"
|
||||||
|
expect_exit "'add ${NAME}_badauth' with a never-authorized key" 0
|
||||||
|
p_run "$STATE_DIR" open --once "${NAME}_badauth"
|
||||||
|
assert_eq "'open --once' with bad auth exits 1" "$LAST_CODE" "1"
|
||||||
|
assert_eq "'${NAME}_badauth' lands in state: error" "$(json_field "$STATE_DIR" "${NAME}_badauth" state)" "error"
|
||||||
|
last_err="$(json_field "$STATE_DIR" "${NAME}_badauth" last_error)"
|
||||||
|
[[ "$last_err" == *"Permission denied"* ]] && pass "last_error reports a real Permission-denied rejection" \
|
||||||
|
|| fail "last_error reports a real Permission-denied rejection" "got: $last_err"
|
||||||
|
p_run "$STATE_DIR" remove "${NAME}_badauth"; expect_exit "'remove ${NAME}_badauth'" 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
section "Phase 9: real connection-refused - KnownTransient classification"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
p_run "$STATE_DIR" add "${NAME}_deadport" -l 28231:localhost:22 -i "$IDENTITY" --via "$USER_@$HOST" \
|
||||||
|
-p 9 --retry-interval 2 --backoff-max 4
|
||||||
|
expect_exit "'add ${NAME}_deadport' targeting a closed port on the real host" 0
|
||||||
|
p_run "$STATE_DIR" open "${NAME}_deadport"; expect_exit "'open ${NAME}_deadport' (backgrounds even though the first attempt fails)" 0
|
||||||
|
assert_true "'${NAME}_deadport' keeps reconnecting rather than giving up" wait_for_state "$STATE_DIR" "${NAME}_deadport" reconnecting 20
|
||||||
|
rc1="$(json_field "$STATE_DIR" "${NAME}_deadport" reconnect_count)"
|
||||||
|
# Port 9 may be silently dropped rather than actively refused, so a single
|
||||||
|
# attempt can burn the full ConnectTimeout=10s; poll well past worst-case
|
||||||
|
# instead of a fixed sleep that assumes an instant refusal.
|
||||||
|
rc2="$rc1"
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
rc2="$(json_field "$STATE_DIR" "${NAME}_deadport" reconnect_count)"
|
||||||
|
[[ -n "$rc2" && "$rc2" -gt "${rc1:-0}" ]] && break
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
if [[ -n "$rc2" && "$rc2" -gt "${rc1:-0}" ]]; then
|
||||||
|
pass "reconnect_count keeps increasing on a real refused connection ($rc1 -> $rc2)"
|
||||||
|
else
|
||||||
|
fail "reconnect_count keeps increasing on a real refused connection" "rc1=$rc1 rc2=$rc2"
|
||||||
|
fi
|
||||||
|
p_run "$STATE_DIR" close --force "${NAME}_deadport"; expect_exit "'close --force ${NAME}_deadport'" 0
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
section "Phase 10: open --all only starts reconnect-enabled profiles"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
p_run "$STATE_DIR" add "${NAME}_all1" -l 28232:localhost:22 -i "$IDENTITY" --via "$USER_@$HOST"
|
||||||
|
expect_exit "'add ${NAME}_all1' (reconnect: true, the default)" 0
|
||||||
|
p_run "$STATE_DIR" add "${NAME}_all2" -l 28233:localhost:22 -i "$IDENTITY" --via "$USER_@$HOST" --reconnect false
|
||||||
|
expect_exit "'add ${NAME}_all2' (reconnect: false)" 0
|
||||||
|
p_run "$STATE_DIR" open --all; expect_exit "'open --all'" 0
|
||||||
|
assert_true "'${NAME}_all1' was started by --all" wait_for_state "$STATE_DIR" "${NAME}_all1" up 10
|
||||||
|
assert_eq "'${NAME}_all2' was NOT started by --all (reconnect: false)" "$(json_field "$STATE_DIR" "${NAME}_all2" state)" "closed"
|
||||||
|
p_run "$STATE_DIR" close "${NAME}_all1"; expect_exit "'close ${NAME}_all1'" 0
|
||||||
|
p_run "$STATE_DIR" remove "${NAME}_all2"; expect_exit "'remove ${NAME}_all2' (was never opened)" 0
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
section "Phase 11: close --force skips the graceful wait"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FORCE_PORT=28234
|
||||||
|
p_run "$STATE_DIR" add "${NAME}_force" -l "$FORCE_PORT:localhost:22" -i "$IDENTITY" --via "$USER_@$HOST"
|
||||||
|
expect_exit "'add ${NAME}_force'" 0
|
||||||
|
p_run "$STATE_DIR" open "${NAME}_force"; expect_exit "'open ${NAME}_force'" 0
|
||||||
|
t0=$(date +%s)
|
||||||
|
p_run "$STATE_DIR" close --force "${NAME}_force"; expect_exit "'close --force ${NAME}_force'" 0
|
||||||
|
t1=$(date +%s)
|
||||||
|
assert_true "'--force' returns fast, without the 5s graceful-wait" bash -c "[[ $((t1 - t0)) -lt 4 ]]"
|
||||||
|
assert_true "port $FORCE_PORT stops listening after force-close" wait_port_closed "$FORCE_PORT" 6
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
section "Phase 12: edit while running warns instead of restarting"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
p_run "$STATE_DIR" add "${NAME}_edit" -l 28235:localhost:22 -i "$IDENTITY" --via "$USER_@$HOST"
|
||||||
|
expect_exit "'add ${NAME}_edit'" 0
|
||||||
|
p_run "$STATE_DIR" open "${NAME}_edit"; expect_exit "'open ${NAME}_edit'" 0
|
||||||
|
p_run "$STATE_DIR" edit "${NAME}_edit" --keepalive 20
|
||||||
|
expect_exit "'edit ${NAME}_edit --keepalive 20' while open" 0
|
||||||
|
expect_contains "warns the change won't apply until reopened" "won't take effect until"
|
||||||
|
p_run "$STATE_DIR" close "${NAME}_edit"; expect_exit "'close ${NAME}_edit'" 0
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
section "Phase 13: remove --keep-running leaves a genuine orphan"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
KEEP_PORT=28236
|
||||||
|
p_run "$STATE_DIR" add "${NAME}_keep" -l "$KEEP_PORT:localhost:22" -i "$IDENTITY" --via "$USER_@$HOST"
|
||||||
|
expect_exit "'add ${NAME}_keep'" 0
|
||||||
|
p_run "$STATE_DIR" open "${NAME}_keep"; expect_exit "'open ${NAME}_keep'" 0
|
||||||
|
p_run "$STATE_DIR" remove "${NAME}_keep" --keep-running
|
||||||
|
expect_exit "'remove ${NAME}_keep --keep-running'" 0
|
||||||
|
expect_contains "warns it's left running untracked" "left running untracked"
|
||||||
|
p_run "$STATE_DIR" status "${NAME}_keep"; assert_eq "profile is gone from tracking" "$LAST_CODE" "1"
|
||||||
|
assert_true "the untracked process is still actually alive" pgrep -f "__supervise ${NAME}_keep\$"
|
||||||
|
assert_true "port $KEEP_PORT is still live, untracked" port_open "$KEEP_PORT"
|
||||||
|
# left running on purpose; phase 15's wipe is what's being tested against it
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
section "Phase 14: transfer round trip against a live profile"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
XFER_PORT=28237
|
||||||
|
XFER_FILE="$(mktemp -u)"
|
||||||
|
STATE_DIR2="$(mktemp -d)"; STATE_DIRS+=("$STATE_DIR2")
|
||||||
|
p_run "$STATE_DIR" add "${NAME}_xfer" -l "$XFER_PORT:localhost:22" -i "$IDENTITY" --via "$USER_@$HOST"
|
||||||
|
expect_exit "'add ${NAME}_xfer'" 0
|
||||||
|
p_run "$STATE_DIR" transfer -e "$XFER_FILE" "${NAME}_xfer"
|
||||||
|
expect_exit "'transfer -e ... ${NAME}_xfer'" 0
|
||||||
|
expect_contains "export warns the identity file isn't included" "identity files are not included"
|
||||||
|
|
||||||
|
p_run "$STATE_DIR2" transfer -i "$XFER_FILE"
|
||||||
|
expect_exit "'transfer -i ...' into a fresh state dir" 0
|
||||||
|
p_run "$STATE_DIR2" open "${NAME}_xfer"; expect_exit "'open' the imported profile" 0
|
||||||
|
assert_true "the imported profile actually connects, not just parses" wait_for_state "$STATE_DIR2" "${NAME}_xfer" up 10
|
||||||
|
p_run "$STATE_DIR2" close "${NAME}_xfer"; expect_exit "'close' the imported profile" 0
|
||||||
|
rm -f "$XFER_FILE" 2>/dev/null
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
section "Phase 15: wipe (guarded - kills every __supervise process system-wide)"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if [[ "$SKIP_WIPE" == "1" ]]; then
|
||||||
|
skip "wipe phase (--skip-wipe passed)"
|
||||||
|
else
|
||||||
|
foreign=""
|
||||||
|
while read -r pid; do
|
||||||
|
[[ -z "$pid" ]] && continue
|
||||||
|
cmd="$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null)"
|
||||||
|
[[ "$cmd" == *"__supervise ${NAME}_"* ]] || foreign="$foreign $pid"
|
||||||
|
done < <(pgrep -f '__supervise' 2>/dev/null)
|
||||||
|
|
||||||
|
if [[ -n "$foreign" ]]; then
|
||||||
|
skip "wipe phase (found __supervise process(es) not from this run: pid$foreign - not safe to run a system-wide wipe)"
|
||||||
|
else
|
||||||
|
p_run "$STATE_DIR" add "${NAME}_wa" -l 28238:localhost:22 -i "$IDENTITY" --via "$USER_@$HOST"
|
||||||
|
expect_exit "create throwaway closed profile for wipe test" 0
|
||||||
|
p_run "$STATE_DIR" add "${NAME}_wb" -l 28239:localhost:22 -i "$IDENTITY" --via "$USER_@$HOST"
|
||||||
|
expect_exit "create throwaway open profile for wipe test" 0
|
||||||
|
p_run "$STATE_DIR" open "${NAME}_wb"; expect_exit "open it" 0
|
||||||
|
|
||||||
|
p_run "$STATE_DIR" wipe --yes; expect_exit "'wipe --yes'" 0
|
||||||
|
expect_contains "wipe reports what it did" "Wiped all forwards"
|
||||||
|
|
||||||
|
p_run "$STATE_DIR" list; expect_contains "'list' is empty after wipe" "No profiles saved."
|
||||||
|
# kill_orphaned_supervisors only sends SIGTERM and returns; the orphan's
|
||||||
|
# own signal handler needs a moment to shut down, so give it a few
|
||||||
|
# retries rather than checking the instant `wipe` returns.
|
||||||
|
wait_for_orphan_gone() {
|
||||||
|
for _ in $(seq 1 10); do
|
||||||
|
pgrep -f "__supervise ${NAME}_keep\$" >/dev/null || return 0
|
||||||
|
sleep 0.3
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
assert_true "the phase-13 orphan is gone too (kill_orphaned_supervisors)" wait_for_orphan_gone
|
||||||
|
assert_true "port $KEEP_PORT is no longer listening" wait_port_closed "$KEEP_PORT" 6
|
||||||
|
fi
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user