use crate::config::{ConnectionMode, ResolvedServer};
pub fn to_openssh_config(servers: &[&ResolvedServer]) -> String {
let mut out = String::from(
"# Generated by susshi — do not edit manually\n\
# susshi --export openssh\n\n",
);
for s in servers {
out.push_str(&format!("Host {}\n", s.name));
out.push_str(&format!(" HostName {}\n", bare_host(&s.host)));
if !s.user.is_empty() {
out.push_str(&format!(" User {}\n", s.user));
}
let port = port_from_server(s);
if port != 22 {
out.push_str(&format!(" Port {port}\n"));
}
if !s.ssh_key.is_empty() {
let expanded = shellexpand::tilde(&s.ssh_key);
out.push_str(&format!(" IdentityFile {}\n", expanded));
}
if s.default_mode == ConnectionMode::Jump
&& let Some(jump) = &s.jump_host
{
out.push_str(&format!(" ProxyJump {jump}\n"));
}
if !s.ssh_agent_sock.is_empty() {
let expanded = shellexpand::tilde(&s.ssh_agent_sock);
out.push_str(&format!(" IdentityAgent {}\n", expanded));
}
out.push('\n');
}
out
}
fn bare_host(host: &str) -> &str {
host.split(':').next().unwrap_or(host)
}
fn port_from_server(s: &ResolvedServer) -> u16 {
if s.port != 22 {
return s.port;
}
if let Some(p) = s.host.split(':').nth(1)
&& let Ok(n) = p.parse::<u16>()
{
return n;
}
22
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ResolvedServer;
fn make_server(name: &str, host: &str, mode: ConnectionMode) -> ResolvedServer {
ResolvedServer {
name: name.into(),
host: host.into(),
user: "ops".into(),
port: 22,
ssh_key: String::new(),
group_name: "grp".into(),
env_name: String::new(),
namespace: String::new(),
tags: vec![],
notes: String::new(),
default_mode: mode,
jump_host: None,
bastion_host: None,
bastion_user: None,
bastion_template: String::new(),
use_system_ssh_config: false,
probe_filesystems: vec![],
tunnels: vec![],
ssh_options: vec![],
control_master: false,
agent_forwarding: false,
control_path: String::new(),
control_persist: String::new(),
pre_connect_hook: None,
post_disconnect_hook: None,
hook_timeout_secs: 5,
ssh_cert: String::new(),
ssh_agent_sock: String::new(),
wallix_group: None,
wallix_account: String::new(),
wallix_protocol: String::new(),
wallix_auto_select: false,
wallix_fail_if_menu_match_error: false,
wallix_selection_timeout_secs: 8,
wallix_direct: false,
wallix_authorization: None,
wallix_header_columns: vec![],
}
}
#[test]
fn basic_host_block() {
let s = make_server("web-01", "198.51.100.1", ConnectionMode::Direct);
let out = to_openssh_config(&[&s]);
assert!(out.contains("Host web-01\n"));
assert!(out.contains(" HostName 198.51.100.1\n"));
assert!(out.contains(" User ops\n"));
assert!(!out.contains("Port"), "port 22 ne doit pas apparaître");
}
#[test]
fn non_standard_port() {
let mut s = make_server("db", "198.51.100.2", ConnectionMode::Direct);
s.port = 2222;
let out = to_openssh_config(&[&s]);
assert!(out.contains(" Port 2222\n"));
}
#[test]
fn port_in_host_string() {
let s = make_server("db", "198.51.100.2:2222", ConnectionMode::Direct);
let out = to_openssh_config(&[&s]);
assert!(
out.contains(" HostName 198.51.100.2\n"),
"host sans port attendu"
);
assert!(out.contains(" Port 2222\n"));
}
#[test]
fn jump_host_generates_proxyjump() {
let mut s = make_server("app", "198.51.100.10", ConnectionMode::Jump);
s.jump_host = Some("jops@jump.example.com".into());
let out = to_openssh_config(&[&s]);
assert!(out.contains(" ProxyJump jops@jump.example.com\n"));
}
#[test]
fn identity_file_tilde_expanded() {
let mut s = make_server("srv", "198.51.100.1", ConnectionMode::Direct);
s.ssh_key = "~/.ssh/id_ed25519".into();
let out = to_openssh_config(&[&s]);
let line = out.lines().find(|l| l.contains("IdentityFile")).unwrap();
assert!(!line.contains('~'), "tilde doit être expandé");
assert!(line.contains("/.ssh/id_ed25519"));
}
}