(initial) Align formatting and code style to project standards.
This commit is contained in:
@@ -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)]
|
||||||
|
|||||||
140
src/instance.rs
140
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.
|
//! 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::error::Result;
|
||||||
use crate::{ atomic, timefmt };
|
use crate::{ atomic, timefmt };
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::fs::{File, OpenOptions};
|
use serde::{ Serialize, Deserialize };
|
||||||
use std::os::unix::io::AsRawFd;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
#[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,12 +29,14 @@ 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>,
|
||||||
@@ -40,7 +45,8 @@ pub struct Instance {
|
|||||||
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,
|
||||||
@@ -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()
|
dirs::state_dir()
|
||||||
.or_else(dirs::data_local_dir)
|
.or_else(dirs::data_local_dir)
|
||||||
.expect("could not resolve state dir")
|
.expect("could not resolve state dir")
|
||||||
.join("porthole")
|
.join("porthole")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn instance_path(name: &str) -> PathBuf {
|
pub fn instance_path(name: &str) -> PathBuf { state_dir().join(format!("{name}.json")) }
|
||||||
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 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
|
/// 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
|
/// absence of this file is the closed state (spec §2.2); there is no
|
||||||
/// separate enum value for it.
|
/// 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);
|
let path = instance_path(name);
|
||||||
match std::fs::read_to_string(&path) {
|
match std::fs::read_to_string(&path) {
|
||||||
Ok(text) => Ok(Some(serde_json::from_str(&text)?)),
|
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();
|
let dir = state_dir();
|
||||||
std::fs::create_dir_all(&dir)?;
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
|
||||||
let text = serde_json::to_string_pretty(instance)?;
|
let text = serde_json::to_string_pretty(instance)?;
|
||||||
atomic::write(&instance_path(&instance.name), text.as_bytes())?;
|
atomic::write(&instance_path(&instance.name), text.as_bytes())?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete(name: &str) -> Result<()> {
|
pub fn delete(name: &str) -> Result<()>
|
||||||
|
{
|
||||||
match std::fs::remove_file(instance_path(name)) {
|
match std::fs::remove_file(instance_path(name)) {
|
||||||
Ok(()) => Ok(()),
|
Ok(()) => Ok(()),
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => 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 {
|
pub fn process_alive(pid: i32) -> bool { unsafe { libc::kill(pid, 0) == 0 } }
|
||||||
unsafe { libc::kill(pid, 0) == 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// True only if `pid` is a live process whose cmdline identifies it as the
|
/// True only if `pid` is a live process whose cmdline identifies it as the
|
||||||
/// supervisor for `name` - guards against a stale or reused pid.
|
/// supervisor for `name` - guards against a stale or reused pid.
|
||||||
pub fn supervisor_alive(pid: i32, name: &str) -> bool {
|
pub fn supervisor_alive(pid: i32, name: &str) -> bool
|
||||||
if !process_alive(pid) {
|
{
|
||||||
return false;
|
if !process_alive(pid) { return false };
|
||||||
}
|
|
||||||
let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else {
|
let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else { return false };
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let text = String::from_utf8_lossy(&cmdline);
|
let text = String::from_utf8_lossy(&cmdline);
|
||||||
text.contains("__supervise") && text.contains(name)
|
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
|
/// (checked against the process table, not just the instance file's
|
||||||
/// last-known value - so a crash or reboot is detected as "not running"
|
/// last-known value - so a crash or reboot is detected as "not running"
|
||||||
/// rather than trusting stale on-disk state).
|
/// 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)? {
|
match load(name)? {
|
||||||
Some(inst) if supervisor_alive(inst.pid, name) => Ok(Some(inst.pid)),
|
Some(inst) if supervisor_alive(inst.pid, name) => Ok(Some(inst.pid)),
|
||||||
_ => Ok(None),
|
_ => 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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
297
src/profile.rs
297
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/<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,9 +16,11 @@ 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",
|
||||||
@@ -24,7 +28,8 @@ impl Kind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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",
|
||||||
@@ -58,21 +63,88 @@ pub struct Profile {
|
|||||||
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.
|
||||||
|
//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`].
|
/// Flags shared by `add`/`edit` for building/patching a [`Profile`].
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
@@ -90,16 +162,15 @@ pub struct ProfileEdits {
|
|||||||
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,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 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() {
|
{
|
||||||
|
Kind::Dynamic => match parts.as_slice()
|
||||||
|
{
|
||||||
[port] if valid_port(port) => Ok(()),
|
[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()),
|
||||||
@@ -148,153 +229,71 @@ fn validate_mapping(kind: Kind, mapping: &str) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::{ Arc, Mutex };
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user