(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::{ Args, Parser, Subcommand };
|
||||
|
||||
/// Create and manage named SSH port forwards.
|
||||
#[derive(Parser)]
|
||||
|
||||
142
src/instance.rs
142
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,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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
|
||||
305
src/profile.rs
305
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`.
|
||||
|
||||
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,22 +63,89 @@ pub struct Profile {
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
fn default_ssh_port() -> u16 {
|
||||
22
|
||||
}
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_retry_interval() -> u32 {
|
||||
5
|
||||
}
|
||||
fn default_backoff_max() -> u32 {
|
||||
60
|
||||
}
|
||||
fn default_keepalive() -> u32 {
|
||||
15
|
||||
impl Profile
|
||||
{
|
||||
/// Builds a brand-new profile from `add`'s flags.
|
||||
//noinspection RsFieldInitShorthand
|
||||
pub fn new(name: String, edits: &ProfileEdits) -> Result<Self>
|
||||
{
|
||||
let Some((kind, mapping)) = edits.mapping_kind()? else {
|
||||
return Err(PortholeError::NoMappingKind);
|
||||
};
|
||||
|
||||
validate_mapping(kind, mapping)?;
|
||||
let via = edits.via.clone().unwrap_or_default();
|
||||
if via.is_empty() { return Err(PortholeError::NoViaHosts); }
|
||||
|
||||
for hop in &via { validate_via_hop(hop)?; }
|
||||
|
||||
let now = timefmt::now();
|
||||
|
||||
Ok(Self {
|
||||
name: name,
|
||||
kind: kind,
|
||||
mapping: mapping.to_string(),
|
||||
via: via,
|
||||
user: edits.user.clone(),
|
||||
identity: edits.identity.clone(),
|
||||
ssh_port: edits.port.unwrap_or_else(default_ssh_port),
|
||||
reconnect: edits.reconnect.unwrap_or_else(default_true),
|
||||
retry_interval: edits.retry_interval.unwrap_or_else(default_retry_interval),
|
||||
backoff_max: edits.backoff_max.unwrap_or_else(default_backoff_max),
|
||||
keepalive: edits.keepalive.unwrap_or_else(default_keepalive),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
/// Applies `edits` on top of an existing profile (`edit`'s semantics:
|
||||
/// only provided fields change).
|
||||
pub fn apply_edits(&mut self, edits: &ProfileEdits) -> Result<()>
|
||||
{
|
||||
if let Some((kind, mapping)) = edits.mapping_kind()?
|
||||
{
|
||||
validate_mapping(kind, mapping)?;
|
||||
|
||||
self.kind = kind;
|
||||
self.mapping = mapping.to_string();
|
||||
}
|
||||
|
||||
if let Some(via) = &edits.via
|
||||
{
|
||||
if via.is_empty() { return Err(PortholeError::NoViaHosts); }
|
||||
|
||||
for hop in via { validate_via_hop(hop)?; }
|
||||
self.via = via.clone();
|
||||
}
|
||||
|
||||
if let Some(user) = &edits.user { self.user = Some(user.clone()); }
|
||||
if let Some(identity) = &edits.identity { self.identity = Some(identity.clone()); }
|
||||
if let Some(port) = edits.port { self.ssh_port = port; }
|
||||
if let Some(reconnect) = edits.reconnect { self.reconnect = reconnect; }
|
||||
if let Some(v) = edits.retry_interval { self.retry_interval = v; }
|
||||
if let Some(v) = edits.backoff_max { self.backoff_max = v; }
|
||||
if let Some(v) = edits.keepalive { self.keepalive = v; }
|
||||
self.updated_at = timefmt::now();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Splits `via` into the `-J` jump-chain value (comma-joined, all but
|
||||
/// the last hop; `None` for a single-hop `via`) and the final `ssh`
|
||||
/// connection target - 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)]
|
||||
pub struct ProfileEdits {
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user