(initial) Align formatting and code style to project standards.

This commit is contained in:
2026-08-14 12:28:27 +02:00
parent 2dd61206a9
commit 3d927e25d1
8 changed files with 325 additions and 318 deletions

View File

@@ -1,5 +1,5 @@
use clap::{Args, Parser, Subcommand};
use clap_complete::Shell; use clap_complete::Shell;
use clap::{ Args, Parser, Subcommand };
/// Create and manage named SSH port forwards. /// Create and manage named SSH port forwards.
#[derive(Parser)] #[derive(Parser)]

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. //! 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::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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
@@ -16,7 +18,8 @@ pub enum State {
Error, Error,
} }
impl State { impl State
{
pub fn label(self) -> &'static str { pub fn label(self) -> &'static str {
match self { match self {
State::Up => "up", State::Up => "up",
@@ -26,114 +29,42 @@ impl State {
} }
} }
//
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Instance { pub struct Instance {
pub name: String, pub name: String,
pub pid: i32, pub pid: i32,
pub state: State, pub state: State,
/// Anchor for "session uptime" (spec §5.5) - set once, when `open` starts. /// Anchor for "session uptime"; set once, when `open` starts.
pub opened_at: i64, pub opened_at: i64,
/// Start of the current unbroken connection; resets each reconnect. /// Start of the current unbroken connection; resets each reconnect.
pub connected_at: Option<i64>, pub connected_at: Option<i64>,
pub last_error: Option<String>, pub last_error: Option<String>,
pub reconnect_count: u32, pub reconnect_count: u32,
pub last_reconnect_at: Option<i64>, pub last_reconnect_at: Option<i64>,
} }
impl Instance { impl Instance
{
pub fn new(name: String, pid: i32) -> Self { pub fn new(name: String, pid: i32) -> Self {
Self { Self {
name, name,
pid, pid,
state: State::Reconnecting, state: State::Reconnecting,
opened_at: timefmt::now(), opened_at: timefmt::now(),
connected_at: None, connected_at: None,
last_error: None, last_error: None,
reconnect_count: 0, reconnect_count: 0,
last_reconnect_at: None, 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 { /// Advisory `flock` held for the supervisor's entire lifetime.
state_dir().join(format!("{name}.lock"))
}
pub fn log_path(name: &str) -> PathBuf {
state_dir().join(format!("{name}.log"))
}
/// Loads the instance file for `name`, if any. `None` means closed - the
/// absence of this file is the closed state (spec §2.2); there is no
/// separate enum value for it.
pub fn load(name: &str) -> Result<Option<Instance>> {
let path = instance_path(name);
match std::fs::read_to_string(&path) {
Ok(text) => Ok(Some(serde_json::from_str(&text)?)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
pub fn save(instance: &Instance) -> Result<()> {
let dir = state_dir();
std::fs::create_dir_all(&dir)?;
let text = serde_json::to_string_pretty(instance)?;
atomic::write(&instance_path(&instance.name), text.as_bytes())?;
Ok(())
}
pub fn delete(name: &str) -> Result<()> {
match std::fs::remove_file(instance_path(name)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
pub fn process_alive(pid: i32) -> bool {
unsafe { libc::kill(pid, 0) == 0 }
}
/// True only if `pid` is a live process whose cmdline identifies it as the
/// supervisor for `name` - guards against a stale or reused pid.
pub fn supervisor_alive(pid: i32, name: &str) -> bool {
if !process_alive(pid) {
return false;
}
let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else {
return false;
};
let text = String::from_utf8_lossy(&cmdline);
text.contains("__supervise") && text.contains(name)
}
/// The live supervisor pid for `name`, if one is actually running right now
/// (checked against the process table, not just the instance file's
/// last-known value - so a crash or reboot is detected as "not running"
/// rather than trusting stale on-disk state).
pub fn running_pid(name: &str) -> Result<Option<i32>> {
match load(name)? {
Some(inst) if supervisor_alive(inst.pid, name) => Ok(Some(inst.pid)),
_ => Ok(None),
}
}
/// Advisory `flock` held for the supervisor's entire lifetime (spec §2.3).
/// The OS releases it the instant the holding process's file descriptors /// 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 /// close, including on a crash or SIGKILL, so it needs no stale-lock
/// cleanup and reliably answers "is a supervisor running for this profile." /// cleanup and reliably answers "is a supervisor running for this profile."
@@ -141,23 +72,100 @@ pub struct Lock {
_file: File, _file: File,
} }
impl Lock { 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). /// Tries to take the lock non-blocking. `Ok(None)` means another live
pub fn try_acquire(name: &str) -> Result<Option<Self>> { /// process already acquired it.
pub fn try_acquire(name: &str) -> Result<Option<Self>>
{
let dir = state_dir(); let dir = state_dir();
std::fs::create_dir_all(&dir)?; std::fs::create_dir_all(&dir)?;
let file = OpenOptions::new().create(true).write(true).open(lock_path(name))?; 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) }; let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if ret == 0 {
Ok(Some(Self { _file: file })) if ret == 0 { Ok(Some(Self { _file: file })) }
} else { else
{
let errno = std::io::Error::last_os_error(); let errno = std::io::Error::last_os_error();
if errno.raw_os_error() == Some(libc::EWOULDBLOCK) {
Ok(None) if errno.raw_os_error() == Some(libc::EWOULDBLOCK) { Ok(None) }
} else { else { Err(errno.into()) }
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<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 clap::{CommandFactory, Parser};
use cli::{Cli, Commands}; use cli::{Cli, Commands};
fn main() { fn main()
{
let args: Vec<String> = std::env::args().collect(); let args: Vec<String> = std::env::args().collect();
// Plain `porthole`, `-h`/`--help`, or `help` at the top level: show // Plain `porthole`, `-h`/`--help`, or `help` at the top level: show
@@ -42,10 +43,7 @@ fn main() {
commands::completions::run(shell); commands::completions::run(shell);
Ok(()) Ok(())
} }
// Internal: `open` re-execs into this. Never invoked by a user // Internal
// directly (spec §3) - deliberately not wrapped in any of the
// normal command ceremony (no "profile exists" re-check etc.),
// since by the time we're here `open` has already done that.
Commands::Supervise { name } => supervisor::run(&name), Commands::Supervise { name } => supervisor::run(&name),
}; };

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

View File

@@ -1,7 +1,8 @@
//! Builds the `ssh` invocation for a profile - spec §3.1. //! Builds the `ssh` invocation for a profile - spec §3.1.
use std::process::{ Stdio, Command };
use crate::profile::Profile; use crate::profile::Profile;
use std::process::{Command, Stdio};
/// Builds the `ssh` command for `profile`, stdio wired for the supervisor /// Builds the `ssh` command for `profile`, stdio wired for the supervisor
/// to capture (stdout/stderr piped so failure text can be classified per /// to capture (stdout/stderr piped so failure text can be classified per

View File

@@ -2,16 +2,17 @@
//! of this same binary (`porthole __supervise <name>`, see `main.rs`); owns //! of this same binary (`porthole __supervise <name>`, see `main.rs`); owns
//! the `ssh` child process for one profile's entire supervised lifetime. //! 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::fs::OpenOptions;
use std::io::{BufRead, BufReader, Write}; use std::sync::{ Arc, Mutex };
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle; 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 /// 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 - spec §4.1.

View File

@@ -3,7 +3,7 @@
//! profile/instance state; this module only turns them into text for //! profile/instance state; this module only turns them into text for
//! `status`/`list` output. //! `status`/`list` output.
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{ SystemTime, UNIX_EPOCH };
pub fn now() -> i64 { pub fn now() -> i64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0) SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)

View File

@@ -11,11 +11,11 @@
# tests/live_test.sh [options] # tests/live_test.sh [options]
# #
# Options (env var or flag; flag wins if both given): # Options (env var or flag; flag wins if both given):
# --bin PATH PORTHOLE_TEST_BIN porthole binary to test (default: target/debug/porthole) # --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) # --host HOST PORTHOLE_TEST_HOST test server hostname (default: vpn.security-command.org)
# --user USER PORTHOLE_TEST_USER test server login user (default: overlord) # --user USER PORTHOLE_TEST_USER test server login user (default: overlord)
# --identity PATH PORTHOLE_TEST_IDENTITY identity file (default: ~/.ssh/id_ed25519_vpn) # --identity PATH PORTHOLE_TEST_IDENTITY identity file (default: ~/.ssh/id_ed25519_vpn)
# --name PREFIX PORTHOLE_TEST_NAME profile name prefix (default: porttestsuite) # --name PREFIX PORTHOLE_TEST_NAME profile name prefix (default: porttestsuite)
# --skip-network PORTHOLE_TEST_SKIP_NETWORK=1 skip the SOCKS/icanhazip.com phase # --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 # --skip-wipe PORTHOLE_TEST_SKIP_WIPE=1 skip the destructive `wipe` phase
# --no-build skip the `cargo build` preflight # --no-build skip the `cargo build` preflight