Compare commits

...

4 Commits

Author SHA1 Message Date
6e6b5c1393 Replace mid-sentence dashes in comments with commas/semicolons
Swapped " - " for a semicolon where it joined two independent clauses,
or a comma where the following text was an appositive/dependent phrase
with no subject of its own. Markdown-style list bullets in
tests/live_test.sh's header (leading "- Item") are unaffected - those
are structural, not sentence punctuation.

Also dropped a "Spec §5.4:" citation in edit.rs missed by the earlier
spec-citation cleanup (case-sensitive grep at the time didn't match the
capitalized "Spec").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 12:36:59 +02:00
40437bf78c Remove spec §N.N citations from comments, keep them self-contained
Comments citing the spec document instead of just stating the fact
directly made their usefulness depend on cross-referencing a separate
file. Reworded each one to stand alone - same content, citation dropped,
folded into a normal sentence where it was mid-clause rather than
trailing. spec/porthole-spec.md itself is untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 12:32:56 +02:00
3d927e25d1 (initial) Align formatting and code style to project standards. 2026-08-14 12:28:27 +02:00
2dd61206a9 Add live_test.sh: full automated integration suite against a real SSH server
Mirrors vmic's tests/live_test.sh convention (colored PASS/FAIL/SKIP,
section headers, an EXIT trap that always cleans up, safety scoped to
test-prefixed names) but adapted for porthole: whole run sandboxed under
a temp PORTHOLE_STATE_DIR_OVERRIDE so it never touches the real
~/.config/porthole or ~/.local/state/porthole.

15 phases covering every mapping kind (-l/-r/-d) against the real
server, a real 2-hop -J ProxyJump (self-jump - only one server is
available), real auth success/failure classification (not just the
synthetic DNS-failure cases cargo test covers), open --all/--once,
close --force vs. graceful vs. idempotent, edit-while-running, the
remove --keep-running orphan path, a transfer export/import round trip
that actually reopens the imported profile, and a guarded wipe phase
that verifies kill_orphaned_supervisors for real.

wipe kills any __supervise process system-wide by design (not scoped to
the sandboxed state dir), so before running it the script scans the
real process table and skips the phase entirely if it finds a live
supervisor that isn't one of its own test profiles - a real tunnel
left open elsewhere is never killed as a side effect of running this
suite.

Verified the CLI-surface and error-path phases (no server needed)
directly against the built binary; phases requiring the real server
are left for a manual run, per the established pattern in this project
of live-server tests being run by hand rather than automated in CI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 12:02:04 +02:00
16 changed files with 827 additions and 359 deletions

View File

@@ -1,7 +1,7 @@
//! 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
//! 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;

View File

@@ -1,5 +1,5 @@
use clap::{Args, Parser, Subcommand};
use clap_complete::Shell;
use clap::{ Args, Parser, Subcommand };
/// Create and manage named SSH port forwards.
#[derive(Parser)]
@@ -48,12 +48,12 @@ pub enum Commands {
Completions { shell: Shell },
/// Internal: runs the supervisor loop for one profile. Not for direct
/// use - `open` spawns this itself (spec §3).
/// use; `open` spawns this itself.
#[command(hide = true, name = "__supervise")]
Supervise { name: String },
}
/// Shared mapping/connection flags for `add` and `edit` - kept as one
/// 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 {

View File

@@ -18,8 +18,8 @@ pub fn run(args: CloseArgs) -> Result<()> {
}
/// Stops `name`'s supervisor if one is actually running, and clears any
/// stale instance file either way (spec §5.3) - shared with `remove` and
/// `wipe`. Returns whether anything was actually running.
/// 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
@@ -27,9 +27,9 @@ pub fn close_instance(name: &str, force: bool) -> Result<bool> {
};
if force {
// spec §3 step 4: SIGKILL the whole process group (the supervisor
// is its own group leader via setsid), not just the supervisor pid
// - a plain single-pid SIGKILL would leave `ssh` orphaned.
// 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) };

View File

@@ -19,7 +19,7 @@ pub fn run(args: EditArgs) -> Result<()> {
p.apply_edits(&edits)?;
profile::save(&p)?;
// Spec §5.4: edit never restarts a running instance - just warn.
// 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."

View File

@@ -12,7 +12,7 @@ pub mod wipe;
use crate::cli::MappingArgs;
use crate::profile::ProfileEdits;
/// Turns clap's `MappingArgs` into a `ProfileEdits` (spec §5.1). `--via` is
/// 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
@@ -34,7 +34,7 @@ pub fn edits_from_mapping(m: &MappingArgs) -> ProfileEdits {
}
}
/// `true` if `MappingArgs` carries no edits at all - used by `edit` to
/// `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()

View File

@@ -1,5 +1,5 @@
//! `open` - spec §3/§5.2. Validates, then either runs the supervisor loop
//! inline (`--foreground`) or spawns a detached copy of this binary
//! `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;
@@ -24,9 +24,9 @@ pub fn run(args: OpenArgs) -> Result<()> {
open_one(&name, args.foreground, args.once)
}
/// Opens every `reconnect: true` profile that isn't already running - the
/// hook external autostart mechanisms are meant to call (spec §5.2/§8).
/// Per-profile failures are warnings, not a whole-batch failure.
/// 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;
@@ -57,8 +57,8 @@ fn open_one(name: &str, foreground: bool, once: bool) -> Result<()> {
return Ok(());
}
// Clear a stale instance file left by a crash before spawning. The
// lock, not this file, is the authority on "already open" (spec
// §5.2) - this just keeps `status` from reading stale state mid-spawn.
// 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 {
@@ -73,10 +73,10 @@ fn open_one(name: &str, foreground: bool, once: bool) -> Result<()> {
wait_for_confirmation(name)
}
/// Spawns `porthole __supervise <name>` fully detached (spec §3 steps 1-2):
/// stdin from `/dev/null`, stdout/stderr appended to the profile's log, and
/// `setsid()` in the child so it leaves this process's session and survives
/// the terminal closing.
/// 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);
@@ -99,8 +99,8 @@ fn spawn_detached(name: &str, once: bool) -> Result<()> {
/// Blocks briefly for the detached supervisor to reach a conclusive state,
/// so an immediate failure (bad auth, bind conflict, unresolvable host) is
/// reported with a non-zero exit instead of `open` appearing to succeed
/// (spec §5.2). The instance file's initial write is always
/// 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

View File

@@ -10,8 +10,8 @@ pub fn run(args: StatusArgs) -> Result<()> {
let p = profile::load(&name)?;
let inst = instance::load(&name)?;
// An instance file whose pid isn't actually alive means the supervisor
// crashed without cleaning up - report that, rather than trusting a
// state the process table disagrees with (spec §2.2).
// 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 {

View File

@@ -1,4 +1,4 @@
//! Bulk profile backup/restore - a flat TOML array of the same `Profile`
//! 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.
@@ -78,7 +78,7 @@ fn import(path: &str, name: Option<&str>) -> Result<()> {
Ok(())
}
/// Identity files are never included in the export, only the path - warn so
/// 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();
@@ -91,7 +91,7 @@ fn warn_about_identities(profiles: &[Profile]) {
}
/// 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.
/// 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 {
@@ -107,7 +107,7 @@ fn warn_about_missing_identities(profiles: &[Profile]) {
}
/// Expands a leading `~/` the same way `ssh` itself does at spawn time
/// (`ssh.rs`) - without this, a valid `~/...` identity path would be
/// (`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()) {

View File

@@ -42,9 +42,9 @@ pub fn run(args: WipeArgs) -> Result<()> {
}
/// Kills any supervisor process not backed by a tracked profile (e.g. one
/// orphaned after a crash), matched by cmdline rather than tracked state
/// (spec §5.8). Sends SIGTERM to each supervisor's process group so its
/// `ssh` child is included.
/// 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 };

View File

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

View File

@@ -12,7 +12,8 @@ mod ui;
use clap::{CommandFactory, Parser};
use cli::{Cli, Commands};
fn main() {
fn main()
{
let args: Vec<String> = std::env::args().collect();
// Plain `porthole`, `-h`/`--help`, or `help` at the top level: show
@@ -42,10 +43,7 @@ fn main() {
commands::completions::run(shell);
Ok(())
}
// Internal: `open` re-execs into this. Never invoked by a user
// directly (spec §3) - deliberately not wrapped in any of the
// normal command ceremony (no "profile exists" re-check etc.),
// since by the time we're here `open` has already done that.
// Internal
Commands::Supervise { name } => supervisor::run(&name),
};
@@ -55,8 +53,8 @@ fn main() {
}
}
/// True for a bare `porthole` invocation, or top-level `-h`/`--help`/`help`
/// - i.e. anything that should show the expanded help rather than being
/// 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..) {

View File

@@ -1,11 +1,13 @@
//! Persisted forward definitions - spec §2.1. One TOML file per profile at
//! Persisted forward definitions: One TOML file per profile at
//! `~/.config/porthole/profiles/<name>.toml`.
use crate::error::{PortholeError, Result};
use crate::{atomic, timefmt};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
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 {
@@ -14,20 +16,23 @@ pub enum Kind {
Dynamic,
}
impl Kind {
impl Kind
{
/// The `ssh` forward flag this kind maps to (`-L`/`-R`/`-D`).
pub fn ssh_flag(self) -> &'static str {
pub fn ssh_flag(self) -> &'static str
{
match self {
Kind::Local => "-L",
Kind::Remote => "-R",
Kind::Local => "-L",
Kind::Remote => "-R",
Kind::Dynamic => "-D",
}
}
pub fn label(self) -> &'static str {
pub fn label(self) -> &'static str
{
match self {
Kind::Local => "local",
Kind::Remote => "remote",
Kind::Local => "local",
Kind::Remote => "remote",
Kind::Dynamic => "dynamic",
}
}
@@ -35,71 +40,137 @@ impl Kind {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Profile {
pub name: String,
pub kind: Kind,
/// Raw `-L/-R/-D` payload, without the flag itself - see spec §5.1.
pub mapping: String,
/// Ordered hop list, each `[user@]host[:port]` - see spec §3.1.
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>,
pub via: Vec<String>,
pub user: Option<String>,
pub identity: Option<String>,
#[serde(default = "default_ssh_port")]
pub ssh_port: u16,
pub ssh_port: u16,
#[serde(default = "default_true")]
pub reconnect: bool,
pub reconnect: bool,
#[serde(default = "default_retry_interval")]
pub retry_interval: u32,
#[serde(default = "default_backoff_max")]
pub backoff_max: u32,
pub backoff_max: u32,
#[serde(default = "default_keepalive")]
pub keepalive: u32,
pub created_at: i64,
pub updated_at: i64,
pub keepalive: u32,
pub created_at: i64,
pub updated_at: i64,
}
fn default_ssh_port() -> u16 {
22
}
fn default_true() -> bool {
true
}
fn default_retry_interval() -> u32 {
5
}
fn default_backoff_max() -> u32 {
60
}
fn default_keepalive() -> u32 {
15
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 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>,
pub backoff_max: Option<u32>,
pub keepalive: Option<u32>,
}
impl ProfileEdits {
fn mapping_kind(&self) -> Result<Option<(Kind, &str)>> {
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();
].into_iter().flatten().collect();
match given.len() {
0 => Ok(None),
@@ -109,192 +180,120 @@ impl ProfileEdits {
}
}
fn valid_name(name: &str) -> bool {
//
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;
}
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()))
}
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());
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();
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(()),
match kind
{
Kind::Dynamic => match parts.as_slice()
{
[port] if valid_port(port) => Ok(()),
[_bind, port] if valid_port(port) => Ok(()),
_ => Err(bad()),
_ => Err(bad()),
},
Kind::Local | Kind::Remote => match parts.as_slice() {
[port, host, hostport] if valid_port(port) && !host.is_empty() && valid_port(hostport) => Ok(()),
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()),
_ => Err(bad()),
},
}
}
/// Validates a `--via` hop (`[user@]host[:port]`).
fn validate_via_hop(hop: &str) -> Result<()> {
let bad = || PortholeError::InvalidVia(hop.to_string());
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_port.is_empty() { return Err(bad()); }
if let Some((host, port)) = host_port.rsplit_once(':')
{
if host.is_empty() || port.parse::<u16>().is_err() {
return Err(bad());
}
}
Ok(())
}
impl Profile {
/// Builds a brand-new profile from `add`'s flags.
pub fn new(name: String, edits: &ProfileEdits) -> Result<Self> {
let Some((kind, mapping)) = edits.mapping_kind()? else {
return Err(PortholeError::NoMappingKind);
};
validate_mapping(kind, mapping)?;
let via = edits.via.clone().unwrap_or_default();
if via.is_empty() {
return Err(PortholeError::NoViaHosts);
}
for hop in &via {
validate_via_hop(hop)?;
}
let now = timefmt::now();
Ok(Self {
name,
kind,
mapping: mapping.to_string(),
via,
user: edits.user.clone(),
identity: edits.identity.clone(),
ssh_port: edits.port.unwrap_or_else(default_ssh_port),
reconnect: edits.reconnect.unwrap_or_else(default_true),
retry_interval: edits.retry_interval.unwrap_or_else(default_retry_interval),
backoff_max: edits.backoff_max.unwrap_or_else(default_backoff_max),
keepalive: edits.keepalive.unwrap_or_else(default_keepalive),
created_at: now,
updated_at: now,
})
}
pub fn normalize(name: &str) -> String { name.to_lowercase() }
/// Applies `edits` on top of an existing profile (`edit`'s semantics:
/// only provided fields change).
pub fn apply_edits(&mut self, edits: &ProfileEdits) -> Result<()> {
if let Some((kind, mapping)) = edits.mapping_kind()? {
validate_mapping(kind, mapping)?;
self.kind = kind;
self.mapping = mapping.to_string();
}
if let Some(via) = &edits.via {
if via.is_empty() {
return Err(PortholeError::NoViaHosts);
}
for hop in via {
validate_via_hop(hop)?;
}
self.via = via.clone();
}
if let Some(user) = &edits.user {
self.user = Some(user.clone());
}
if let Some(identity) = &edits.identity {
self.identity = Some(identity.clone());
}
if let Some(port) = edits.port {
self.ssh_port = port;
}
if let Some(reconnect) = edits.reconnect {
self.reconnect = reconnect;
}
if let Some(v) = edits.retry_interval {
self.retry_interval = v;
}
if let Some(v) = edits.backoff_max {
self.backoff_max = v;
}
if let Some(v) = edits.keepalive {
self.keepalive = v;
}
self.updated_at = timefmt::now();
Ok(())
}
/// Splits `via` into the `-J` jump-chain value (comma-joined, all but
/// the last hop; `None` for a single-hop `via`) and the final `ssh`
/// connection target - see spec §3.1. Every path that constructs a
/// `Profile` validates `via` as non-empty.
pub fn ssh_target(&self) -> (Option<String>, &str) {
match self.via.split_last() {
Some((target, jumps)) if !jumps.is_empty() => (Some(jumps.join(",")), target.as_str()),
Some((target, _)) => (None, target.as_str()),
None => (None, ""),
}
}
}
pub fn normalize(name: &str) -> String {
name.to_lowercase()
}
fn profiles_dir() -> PathBuf {
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"))
}
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 exists(name: &str) -> bool { profile_path(name).is_file() }
pub fn load(name: &str) -> Result<Profile> {
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<()> {
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<()> {
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>> {
pub fn list_all() -> Result<Vec<Profile>>
{
let dir = profiles_dir();
if !dir.is_dir() {
return Ok(Vec::new());
}
if !dir.is_dir() { return Ok(Vec::new()); }
let mut names: Vec<String> = std::fs::read_dir(&dir)?
.flatten()
.filter_map(|e| {
@@ -304,12 +303,12 @@ pub fn list_all() -> Result<Vec<Profile>> {
.flatten()
})
.collect();
names.sort();
let mut out = Vec::with_capacity(names.len());
for name in names {
out.push(load(&name)?);
}
for name in names { out.push(load(&name)?); }
Ok(out)
}

View File

@@ -1,16 +1,17 @@
//! Builds the `ssh` invocation for a profile - spec §3.1.
//! Builds the `ssh` invocation for a profile.
use std::process::{ Stdio, Command };
use crate::profile::Profile;
use std::process::{Command, Stdio};
/// Builds the `ssh` command for `profile`, stdio wired for the supervisor
/// to capture (stdout/stderr piped so failure text can be classified per
/// spec §4.2; stdin from `/dev/null` since porthole never wants a shell).
/// to capture (stdout/stderr piped so failure text can be classified;
/// stdin from `/dev/null` since porthole never wants a shell).
pub fn build(profile: &Profile) -> Command {
let mut cmd = Command::new("ssh");
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
// Forced flags, see spec §3.1.
// Flags forced on every invocation, not user-configurable.
cmd.args([
"-o",
"BatchMode=yes",

View File

@@ -1,23 +1,24 @@
//! The `__supervise` loop - spec §3/§4. Runs as a detached, re-exec'd copy
//! 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 crate::error::Result;
use crate::instance::{self, Instance, Lock, State};
use crate::profile::{self, Profile};
use crate::{ssh, timefmt};
use std::fs::OpenOptions;
use std::io::{BufRead, BufReader, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::sync::{ Arc, Mutex };
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
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 - spec §4.1.
/// 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 - spec §4.2.
/// 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.
@@ -31,7 +32,7 @@ extern "C" fn handle_sigterm(_sig: libc::c_int) { SHUTDOWN.store(true, Ordering:
/// Traps SIGTERM and SIGINT into a flag instead of the default
/// terminate-immediately behavior. This is how `close` (SIGTERM) and
/// `-f/--foreground`'s Ctrl-C (SIGINT, spec §5.2) are distinguished from a
/// `-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
@@ -214,7 +215,7 @@ fn join_all<const N: usize>(handles: [Option<JoinHandle<()>>; N]) {
}
}
/// Classifies `ssh`'s captured stderr per spec §4.2. Fatal patterns stop
/// 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.

View File

@@ -3,7 +3,7 @@
//! profile/instance state; this module only turns them into text for
//! `status`/`list` output.
use std::time::{SystemTime, UNIX_EPOCH};
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)

462
tests/live_test.sh Executable file
View File

@@ -0,0 +1,462 @@
#!/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
p "$1" status "$2" --json 2>/dev/null | sed -n "s/.*\"$3\": \"\\{0,1\\}\\([^\",]*\\)\"\\{0,1\\},\\{0,1\\}\$/\\1/p" | 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[@]}" -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
remote_check="$(ssh "${SSH_PROBE_OPTS[@]}" -i "$IDENTITY" "$USER_@$HOST" \
"ssh -p $REMOTE_PORT -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null localhost true && echo REMOTE_FORWARD_OK" 2>&1)"
if [[ "$remote_check" == *REMOTE_FORWARD_OK* ]]; 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)"
# ---------------------------------------------------------------------------
HOP_PORT=28223
p_run "$STATE_DIR" add "${NAME}_multihop" -l "$HOP_PORT:localhost:22" -i "$IDENTITY" --via "$USER_@$HOST,$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[@]}" -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
# ---------------------------------------------------------------------------
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)"
sleep 6
rc2="$(json_field "$STATE_DIR" "${NAME}_deadport" reconnect_count)"
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."
assert_true "the phase-13 orphan is gone too (kill_orphaned_supervisors)" bash -c \
"! pgrep -f '__supervise ${NAME}_keep\$' >/dev/null"
assert_true "port $KEEP_PORT is no longer listening" wait_port_closed "$KEEP_PORT" 6
fi
fi