(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::{ Args, Parser, Subcommand };
/// Create and manage named SSH port forwards.
#[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.
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::{Deserialize, Serialize};
use std::fs::{File, OpenOptions};
use std::os::unix::io::AsRawFd;
use std::path::PathBuf;
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,12 +29,14 @@ 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.
/// 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>,
@@ -40,7 +45,8 @@ pub struct Instance {
pub last_reconnect_at: Option<i64>,
}
impl Instance {
impl Instance
{
pub fn new(name: String, pid: i32) -> Self {
Self {
name,
@@ -55,32 +61,61 @@ impl Instance {
}
}
fn state_dir() -> PathBuf {
if let Ok(dir) = std::env::var("PORTHOLE_STATE_DIR_OVERRIDE") {
return PathBuf::from(dir);
//
/// Advisory `flock` held for the supervisor's entire lifetime.
/// The OS releases it the instant the holding process's file descriptors
/// close, including on a crash or SIGKILL, so it needs no stale-lock
/// cleanup and reliably answers "is a supervisor running for this profile."
pub struct Lock {
_file: File,
}
impl Lock
{
/// Tries to take the lock non-blocking. `Ok(None)` means another live
/// process already acquired it.
pub fn try_acquire(name: &str) -> Result<Option<Self>>
{
let dir = state_dir();
std::fs::create_dir_all(&dir)?;
let file = OpenOptions::new().create(true).write(true).open(lock_path(name))?;
let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if ret == 0 { Ok(Some(Self { _file: file })) }
else
{
let errno = std::io::Error::last_os_error();
if errno.raw_os_error() == Some(libc::EWOULDBLOCK) { Ok(None) }
else { Err(errno.into()) }
}
}
}
//
fn state_dir() -> PathBuf
{
if let Ok(dir) = std::env::var("PORTHOLE_STATE_DIR_OVERRIDE") { return PathBuf::from(dir); };
dirs::state_dir()
.or_else(dirs::data_local_dir)
.expect("could not resolve state dir")
.join("porthole")
}
pub fn instance_path(name: &str) -> PathBuf {
state_dir().join(format!("{name}.json"))
}
pub fn lock_path(name: &str) -> PathBuf {
state_dir().join(format!("{name}.lock"))
}
pub fn log_path(name: &str) -> PathBuf {
state_dir().join(format!("{name}.log"))
}
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>> {
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)?)),
@@ -89,15 +124,19 @@ pub fn load(name: &str) -> Result<Option<Instance>> {
}
}
pub fn save(instance: &Instance) -> Result<()> {
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<()> {
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(()),
@@ -105,59 +144,28 @@ pub fn delete(name: &str) -> Result<()> {
}
}
pub fn process_alive(pid: i32) -> bool {
unsafe { libc::kill(pid, 0) == 0 }
}
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;
};
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
/// 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>> {
pub fn running_pid(name: &str) -> Result<Option<i32>>
{
match load(name)? {
Some(inst) if supervisor_alive(inst.pid, name) => Ok(Some(inst.pid)),
_ => Ok(None),
}
}
/// Advisory `flock` held for the supervisor's entire lifetime (spec §2.3).
/// The OS releases it the instant the holding process's file descriptors
/// close, including on a crash or SIGKILL, so it needs no stale-lock
/// cleanup and reliably answers "is a supervisor running for this profile."
pub struct Lock {
_file: File,
}
impl Lock {
/// Tries to take the lock non-blockingly. `Ok(None)` means another live
/// process already holds it (i.e. this profile is already open).
pub fn try_acquire(name: &str) -> Result<Option<Self>> {
let dir = state_dir();
std::fs::create_dir_all(&dir)?;
let file = OpenOptions::new().create(true).write(true).open(lock_path(name))?;
let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if ret == 0 {
Ok(Some(Self { _file: file }))
} else {
let errno = std::io::Error::last_os_error();
if errno.raw_os_error() == Some(libc::EWOULDBLOCK) {
Ok(None)
} else {
Err(errno.into())
}
}
}
}

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),
};

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,9 +16,11 @@ 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",
@@ -24,7 +28,8 @@ impl Kind {
}
}
pub fn label(self) -> &'static str {
pub fn label(self) -> &'static str
{
match self {
Kind::Local => "local",
Kind::Remote => "remote",
@@ -58,21 +63,88 @@ pub struct Profile {
pub updated_at: i64,
}
fn default_ssh_port() -> u16 {
22
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,
})
}
fn default_true() -> bool {
true
/// 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();
}
fn default_retry_interval() -> u32 {
5
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();
}
fn default_backoff_max() -> u32 {
60
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(())
}
fn default_keepalive() -> u32 {
15
/// 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`].
#[derive(Debug, Default)]
@@ -90,16 +162,15 @@ pub struct ProfileEdits {
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,37 +180,47 @@ 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<()> {
fn validate_mapping(kind: Kind, mapping: &str) -> Result<()>
{
let bad = || PortholeError::InvalidMapping(mapping.to_string());
let parts: Vec<&str> = mapping.split(':').collect();
let valid_port = |s: &str| s.parse::<u16>().is_ok() && !s.is_empty();
match kind {
Kind::Dynamic => match parts.as_slice() {
match kind
{
Kind::Dynamic => match parts.as_slice()
{
[port] if valid_port(port) => Ok(()),
[_bind, port] if valid_port(port) => Ok(()),
_ => Err(bad()),
},
Kind::Local | Kind::Remote => match parts.as_slice() {
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()),
@@ -148,153 +229,71 @@ fn validate_mapping(kind: Kind, mapping: &str) -> Result<()> {
}
/// 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 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,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

View File

@@ -2,16 +2,17 @@
//! of this same binary (`porthole __supervise <name>`, see `main.rs`); owns
//! the `ssh` child process for one profile's entire supervised lifetime.
use crate::error::Result;
use crate::instance::{self, Instance, Lock, State};
use crate::profile::{self, Profile};
use crate::{ssh, timefmt};
use std::fs::OpenOptions;
use std::io::{BufRead, BufReader, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{ Arc, Mutex };
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
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.