use crate::config::{ConnectionMode, ResolvedServer, TunnelConfig};
use crate::ssh::client::build_ssh_args;
use anyhow::Result;
use std::process::{Child, Command};
#[derive(Debug)]
pub enum TunnelStatus {
Idle,
Running,
Dead(String),
Error(String),
}
pub struct TunnelHandle {
pub config: TunnelConfig,
pub yaml_index: Option<usize>,
pub user_idx: usize,
pub status: TunnelStatus,
child: Option<Child>,
}
impl std::fmt::Debug for TunnelHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TunnelHandle")
.field("config", &self.config)
.field("yaml_index", &self.yaml_index)
.field("user_idx", &self.user_idx)
.field("status", &self.status)
.field("child_pid", &self.child.as_ref().map(|c| c.id()))
.finish()
}
}
impl TunnelHandle {
pub fn new(config: TunnelConfig, yaml_index: Option<usize>, user_idx: usize) -> Self {
Self {
config,
yaml_index,
user_idx,
status: TunnelStatus::Idle,
child: None,
}
}
pub fn is_running(&self) -> bool {
matches!(self.status, TunnelStatus::Running)
}
pub fn poll(&mut self) -> bool {
let Some(child) = &mut self.child else {
return false;
};
match child.try_wait() {
Ok(Some(exit)) => {
let reason = match exit.code() {
Some(0) => "terminé normalement".to_string(),
Some(c) => format!("code de sortie {}", c),
None => "tué par un signal".to_string(),
};
self.status = TunnelStatus::Dead(reason);
self.child = None;
true
}
Ok(None) => false, Err(e) => {
self.status = TunnelStatus::Dead(e.to_string());
self.child = None;
true
}
}
}
pub fn kill(&mut self) {
if let Some(mut child) = self.child.take() {
let _ = child.kill();
let _ = child.wait();
}
self.status = TunnelStatus::Idle;
}
}
impl Drop for TunnelHandle {
fn drop(&mut self) {
self.kill();
}
}
pub fn build_tunnel_args(
server: &ResolvedServer,
mode: ConnectionMode,
tunnel: &TunnelConfig,
) -> Result<Vec<String>> {
if mode == ConnectionMode::Wallix {
anyhow::bail!("Les tunnels SSH ne sont pas disponibles en mode Wallix");
}
let mut args = build_ssh_args(server, mode, false)?;
let destination = args
.pop()
.ok_or_else(|| anyhow::anyhow!("liste d'args SSH vide"))?;
args.push("-N".into()); args.push("-L".into());
args.push(format!(
"{}:{}:{}",
tunnel.local_port, tunnel.remote_host, tunnel.remote_port
));
args.push("-o".into());
args.push("ExitOnForwardFailure=yes".into());
args.push(destination);
Ok(args)
}
pub fn spawn_tunnel(
server: &ResolvedServer,
mode: ConnectionMode,
config: TunnelConfig,
yaml_index: Option<usize>,
user_idx: usize,
) -> Result<TunnelHandle> {
let args = build_tunnel_args(server, mode, &config)?;
let child = Command::new("ssh")
.args(&args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| anyhow::anyhow!("Impossible de lancer le tunnel SSH : {}", e))?;
Ok(TunnelHandle {
config,
yaml_index,
user_idx,
status: TunnelStatus::Running,
child: Some(child),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ConnectionMode;
fn base_server() -> ResolvedServer {
ResolvedServer {
namespace: String::new(),
group_name: "G".into(),
env_name: "E".into(),
name: "srv".into(),
host: "10.0.0.1".into(),
user: "admin".into(),
port: 22,
ssh_key: String::new(),
ssh_options: vec![],
default_mode: ConnectionMode::Direct,
jump_host: None,
bastion_host: None,
bastion_user: None,
bastion_template: "{target_user}@%n:SSH:{bastion_user}".into(),
use_system_ssh_config: false,
probe_filesystems: vec![],
tunnels: vec![],
tags: vec![],
control_master: false,
control_path: String::new(),
control_persist: "10m".to_string(),
pre_connect_hook: None,
post_disconnect_hook: None,
hook_timeout_secs: 5,
}
}
fn pg_tunnel() -> TunnelConfig {
TunnelConfig {
local_port: 5433,
remote_host: "127.0.0.1".into(),
remote_port: 5432,
label: "PostgreSQL".into(),
}
}
#[test]
fn tunnel_args_direct_contains_n_and_l() {
let s = base_server();
let t = pg_tunnel();
let args = build_tunnel_args(&s, ConnectionMode::Direct, &t).unwrap();
assert!(args.contains(&"-N".to_string()), "doit contenir -N");
let l_idx = args.iter().position(|a| a == "-L").expect("-L absent");
assert_eq!(args[l_idx + 1], "5433:127.0.0.1:5432");
assert_eq!(args.last().unwrap(), "admin@10.0.0.1");
assert!(args.iter().any(|a| a.contains("ExitOnForwardFailure")));
}
#[test]
fn tunnel_args_jump_keeps_j_flag() {
let mut s = base_server();
s.jump_host = Some("jump.example.com".into());
let t = pg_tunnel();
let args = build_tunnel_args(&s, ConnectionMode::Jump, &t).unwrap();
assert!(args.contains(&"-J".to_string()), "doit contenir -J");
assert!(args.contains(&"-N".to_string()), "-N absent");
assert_eq!(args.last().unwrap(), "admin@10.0.0.1");
}
#[test]
fn tunnel_args_wallix_rejected() {
let s = base_server();
let t = pg_tunnel();
let result = build_tunnel_args(&s, ConnectionMode::Wallix, &t);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Wallix"));
}
#[test]
fn tunnel_args_destination_last_invariant() {
let mut s = base_server();
s.ssh_key = "~/.ssh/id_ed25519".into();
s.ssh_options = vec!["ServerAliveInterval=30".into()];
let t = pg_tunnel();
let args = build_tunnel_args(&s, ConnectionMode::Direct, &t).unwrap();
assert_eq!(args.last().unwrap(), "admin@10.0.0.1");
}
#[test]
fn tunnel_handle_idle_by_default() {
let t = pg_tunnel();
let h = TunnelHandle::new(t, Some(0), 0);
assert!(!h.is_running());
assert!(matches!(h.status, TunnelStatus::Idle));
}
#[test]
fn tunnel_handle_poll_returns_false_when_idle() {
let t = pg_tunnel();
let mut h = TunnelHandle::new(t, None, 0);
assert!(!h.poll(), "poll sur Idle doit retourner false");
}
#[test]
fn tunnel_args_includes_ssh_key() {
let mut s = base_server();
s.ssh_key = "/home/user/.ssh/id_ed25519".into();
let t = pg_tunnel();
let args = build_tunnel_args(&s, ConnectionMode::Direct, &t).unwrap();
let i_pos = args.iter().position(|a| a == "-i").expect("-i absent");
assert_eq!(args[i_pos + 1], "/home/user/.ssh/id_ed25519");
assert_eq!(args.last().unwrap(), "admin@10.0.0.1");
}
#[test]
fn tunnel_args_ssh_options_before_destination() {
let mut s = base_server();
s.ssh_options = vec!["ServerAliveInterval=30".into()];
let t = pg_tunnel();
let args = build_tunnel_args(&s, ConnectionMode::Direct, &t).unwrap();
let dest_pos = args.iter().rposition(|a| a == "admin@10.0.0.1").unwrap();
let opt_pos = args
.iter()
.position(|a| a == "ServerAliveInterval=30")
.unwrap();
assert!(
opt_pos < dest_pos,
"les options SSH doivent précéder la destination"
);
}
#[test]
fn tunnel_args_f_dev_null_when_not_using_system_config() {
let s = base_server(); let t = pg_tunnel();
let args = build_tunnel_args(&s, ConnectionMode::Direct, &t).unwrap();
let f_pos = args.iter().position(|a| a == "-F").expect("-F absent");
assert_eq!(args[f_pos + 1], "/dev/null");
}
#[test]
fn tunnel_args_no_f_flag_with_system_config() {
let mut s = base_server();
s.use_system_ssh_config = true;
let t = pg_tunnel();
let args = build_tunnel_args(&s, ConnectionMode::Direct, &t).unwrap();
assert!(
!args.contains(&"-F".to_string()),
"-F ne doit pas être présent quand use_system_ssh_config=true"
);
}
#[test]
fn tunnel_l_flag_format() {
let t = TunnelConfig {
local_port: 15432,
remote_host: "db.internal".into(),
remote_port: 5432,
label: "Test".into(),
};
let args = build_tunnel_args(&base_server(), ConnectionMode::Direct, &t).unwrap();
let l_pos = args.iter().position(|a| a == "-L").expect("-L absent");
assert_eq!(args[l_pos + 1], "15432:db.internal:5432");
}
}