From 3d927e25d1a292f719978984d8d9558429897353 Mon Sep 17 00:00:00 2001 From: Overlord Date: Fri, 14 Aug 2026 12:28:27 +0200 Subject: [PATCH] (initial) Align formatting and code style to project standards. --- src/cli.rs | 2 +- src/instance.rs | 230 ++++++++++++++-------------- src/main.rs | 8 +- src/profile.rs | 371 ++++++++++++++++++++++----------------------- src/ssh.rs | 3 +- src/supervisor.rs | 17 ++- src/timefmt.rs | 2 +- tests/live_test.sh | 10 +- 8 files changed, 325 insertions(+), 318 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 65d364a..a7970b1 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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)] diff --git a/src/instance.rs b/src/instance.rs index 42ed25d..10b834c 100644 --- a/src/instance.rs +++ b/src/instance.rs @@ -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, - pub last_error: Option, - pub reconnect_count: u32, + pub connected_at: Option, + pub last_error: Option, + pub reconnect_count: u32, pub last_reconnect_at: Option, } -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> { - 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> { - 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,100 @@ 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> { +impl Lock +{ + /// Tries to take the lock non-blocking. `Ok(None)` means another live + /// process already acquired it. + pub fn try_acquire(name: &str) -> Result> + { let dir = state_dir(); std::fs::create_dir_all(&dir)?; + let file = OpenOptions::new().create(true).write(true).open(lock_path(name))?; - let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; - if ret == 0 { - Ok(Some(Self { _file: file })) - } else { + let 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 - 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> +{ + let path = instance_path(name); + match std::fs::read_to_string(&path) { + Ok(text) => Ok(Some(serde_json::from_str(&text)?)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } +} + +pub fn save(instance: &Instance) -> Result<()> +{ + let dir = state_dir(); + std::fs::create_dir_all(&dir)?; + + let text = serde_json::to_string_pretty(instance)?; + atomic::write(&instance_path(&instance.name), text.as_bytes())?; + + Ok(()) +} + +pub fn delete(name: &str) -> Result<()> +{ + match std::fs::remove_file(instance_path(name)) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.into()), + } +} + +pub fn process_alive(pid: i32) -> bool { unsafe { libc::kill(pid, 0) == 0 } } + +/// True only if `pid` is a live process whose cmdline identifies it as the +/// supervisor for `name` - guards against a stale or reused pid. +pub fn supervisor_alive(pid: i32, name: &str) -> bool +{ + if !process_alive(pid) { return false }; + + let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else { return false }; + + let text = String::from_utf8_lossy(&cmdline); + text.contains("__supervise") && text.contains(name) +} + +/// The live supervisor pid for `name`, if one is currently running +/// (checked against the process table, not just the instance file's +/// last-known value - so a crash or reboot is detected as "not running" +/// rather than trusting stale on-disk state). +pub fn running_pid(name: &str) -> Result> +{ + match load(name)? { + Some(inst) if supervisor_alive(inst.pid, name) => Ok(Some(inst.pid)), + _ => Ok(None), + } +} diff --git a/src/main.rs b/src/main.rs index 9053b77..5ce62f9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,8 @@ mod ui; use clap::{CommandFactory, Parser}; use cli::{Cli, Commands}; -fn main() { +fn main() +{ let args: Vec = 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), }; diff --git a/src/profile.rs b/src/profile.rs index 49fdc3c..d103b81 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -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/.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, + pub name: String, + pub kind: Kind, /// Raw `-L/-R/-D` payload, without the flag itself - see spec §5.1. - pub mapping: String, + pub mapping: String, /// Ordered hop list, each `[user@]host[:port]` - see spec §3.1. #[serde(default)] - pub via: Vec, - pub user: Option, - pub identity: Option, + pub via: Vec, + pub user: Option, + pub identity: Option, #[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 + { + 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 - see spec §3.1. Every path that constructs a + /// `Profile` validates `via` as non-empty. + pub fn ssh_target(&self) -> (Option, &str) + { + match self.via.split_last() { + Some((target, jumps)) if !jumps.is_empty() => (Some(jumps.join(",")), target.as_str()), + Some((target, _)) => (None, target.as_str()), + None => (None, ""), + } + } } +// + /// Flags shared by `add`/`edit` for building/patching a [`Profile`]. #[derive(Debug, Default)] pub struct ProfileEdits { - pub local: Option, - pub remote: Option, - pub dynamic: Option, - pub via: Option>, - pub user: Option, - pub identity: Option, - pub port: Option, - pub reconnect: Option, + pub local: Option, + pub remote: Option, + pub dynamic: Option, + pub via: Option>, + pub user: Option, + pub identity: Option, + pub port: Option, + pub reconnect: Option, pub retry_interval: Option, - pub backoff_max: Option, - pub keepalive: Option, + pub backoff_max: Option, + pub keepalive: Option, } -impl ProfileEdits { - fn mapping_kind(&self) -> Result> { +impl ProfileEdits +{ + fn mapping_kind(&self) -> Result> + { let given: Vec<(Kind, &str)> = [ self.local.as_deref().map(|m| (Kind::Local, m)), self.remote.as_deref().map(|m| (Kind::Remote, m)), self.dynamic.as_deref().map(|m| (Kind::Dynamic, m)), - ] - .into_iter() - .flatten() - .collect(); + ].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::().is_ok() && !s.is_empty(); + let valid_port = |s: &str| s.parse::().is_ok() && !s.is_empty(); - match kind { - Kind::Dynamic => match parts.as_slice() { - [port] if valid_port(port) => Ok(()), + 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::().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 { - 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, &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 { +pub fn load(name: &str) -> Result +{ require_valid_name(name)?; + let path = profile_path(name); let text = std::fs::read_to_string(&path).map_err(|_| PortholeError::NotFound(name.to_string()))?; + Ok(toml::from_str(&text)?) } -pub fn save(profile: &Profile) -> Result<()> { +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> { +pub fn list_all() -> Result> +{ let dir = profiles_dir(); - if !dir.is_dir() { - return Ok(Vec::new()); - } + if !dir.is_dir() { return Ok(Vec::new()); } + let mut names: Vec = std::fs::read_dir(&dir)? .flatten() .filter_map(|e| { @@ -304,12 +303,12 @@ pub fn list_all() -> Result> { .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) } diff --git a/src/ssh.rs b/src/ssh.rs index bd3d14f..0667f34 100644 --- a/src/ssh.rs +++ b/src/ssh.rs @@ -1,7 +1,8 @@ //! Builds the `ssh` invocation for a profile - spec §3.1. +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 diff --git a/src/supervisor.rs b/src/supervisor.rs index 90aad4f..1ac5f7d 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -2,16 +2,17 @@ //! of this same binary (`porthole __supervise `, 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. diff --git a/src/timefmt.rs b/src/timefmt.rs index 9728563..f654d2b 100644 --- a/src/timefmt.rs +++ b/src/timefmt.rs @@ -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) diff --git a/tests/live_test.sh b/tests/live_test.sh index e68a589..bb9ea51 100755 --- a/tests/live_test.sh +++ b/tests/live_test.sh @@ -11,11 +11,11 @@ # 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) +# --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