Files
scriptbase/ssh-rs/src/main.rs
2026-01-02 19:00:20 +08:00

234 lines
7.5 KiB
Rust

use clap::Parser;
use rust_util::{
debugging, failure_and_exit, information, opt_result, simple_error, success, util_cmd, util_env,
util_file, util_term, XResult,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::process::Command;
#[derive(Debug, Parser)]
#[command(name = "ssh-rs", bin_name = "ssh.rs")]
#[command(about = "SSH util", long_about = None)]
struct SshRsArgs {
/// Forward agent
#[arg(long, short = 'f')]
pub forward_agent: Option<bool>,
/// Proxy
#[arg(long, short = 'p')]
pub proxy: Option<bool>,
/// [username@]host
pub username_and_host: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct SshConfig {
pub default_forward_agent: Option<bool>,
pub default_proxy: Option<bool>,
pub default_username: Option<String>,
pub profiles: BTreeMap<String, SshProfile>,
}
#[derive(Debug, Serialize, Deserialize)]
struct SshProfile {
pub default_username: Option<String>,
pub alias: Option<Vec<String>>,
pub host: String,
pub proxy: Option<bool>,
pub forward_agent: Option<bool>,
pub comment: Option<String>,
}
impl SshConfig {
fn find_profiles(&self, profile: &str) -> Vec<(&String, &SshProfile)> {
let mut found = vec![];
for (k, v) in &self.profiles {
if k == profile {
found.push((k, v));
} else {
if let Some(alias) = &v.alias {
if alias.contains(&profile.to_string()) {
found.push((k, v));
}
}
}
}
found
}
}
const ENV_SSH_RS_CONFIG_FILE: &str = "SSH_RS_CONFIG_FILE";
const SSH_RS_CONFIG_FILE: &str = "~/.config/ssh-rs-config.json";
const CMD_SSH: &str = "ssh";
fn main() -> XResult<()> {
let args = SshRsArgs::parse();
let ssh_rs_config = load_ssh_rs_config()?;
let username_and_host = match args.username_and_host {
None => {
success!("Total {} server(s):", ssh_rs_config.profiles.len());
let mut max_profile_id_len = 0_usize;
let mut max_host_len = 0_usize;
for (profile_id, profile) in &ssh_rs_config.profiles {
if profile_id.len() > max_profile_id_len {
max_profile_id_len = profile_id.len();
}
if profile.host.len() > max_host_len {
max_host_len = profile.host.len();
}
}
for (profile_id, profile) in &ssh_rs_config.profiles {
let mut features = vec![];
if let Some(true) = profile.forward_agent {
features.push("forward_agent");
}
if let Some(true) = profile.proxy {
features.push("proxy");
}
println!(
"- {} : {}{} {}{}{} # {}{}",
pad(profile_id, max_profile_id_len),
util_term::GREEN,
pad(&profile.host, max_host_len),
util_term::YELLOW,
match &profile.alias {
None => {
"".to_string()
}
Some(alias) => {
format!("alias: [{}]", alias.join(", "))
}
},
util_term::END,
profile.comment.clone().unwrap_or_else(|| "-".to_string()),
if features.is_empty() {
"".to_string()
} else {
format!(" ;[{}]", features.join(", "))
}
);
}
let mut features = vec![];
if let Some(true) = ssh_rs_config.default_forward_agent {
features.push("forward_agent");
}
if let Some(true) = ssh_rs_config.default_proxy {
features.push("proxy");
}
if !features.is_empty() {
println!();
information!("Global default features: [{}]", features.join(", "));
}
return Ok(());
}
Some(username_and_host) => username_and_host,
};
let (username, host) = parse_username_and_host(&username_and_host)?;
let profiles = ssh_rs_config.find_profiles(&host);
if profiles.is_empty() {
return simple_error!("Profile not found");
}
if profiles.len() > 1 {
let profile_names = profiles
.iter()
.map(|p| p.0.to_string())
.collect::<Vec<_>>()
.join(", ");
return simple_error!("Multiple profiles found: {}", profile_names);
}
let profile = profiles[0].1;
debugging!("Found profile: {:#?}", profile);
let ssh_forward_agent = args.forward_agent.clone().unwrap_or_else(|| {
profile.forward_agent.unwrap_or_else(|| //-
ssh_rs_config.default_forward_agent.unwrap_or(false))
});
let ssh_proxy = args.proxy.clone().unwrap_or_else(|| {
profile.proxy.unwrap_or_else(|| //-
ssh_rs_config.default_proxy.unwrap_or(false))
});
let ssh_username = username.unwrap_or_else(|| {
profile.default_username.clone().unwrap_or_else(|| {
ssh_rs_config
.default_username
.clone()
.unwrap_or_else(|| "root".to_string())
})
});
let ssh_host = profile.host.clone();
let mut cmd = Command::new(CMD_SSH);
if ssh_forward_agent {
cmd.args(&["-o", "ForwardAgent=yes"]);
}
if ssh_proxy {
cmd.args(&["-o", "ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p"]);
}
cmd.arg(&format!("{}@{}", ssh_username, ssh_host));
success!(
"{} {}",
CMD_SSH,
cmd.get_args()
.map(|s| s.to_string_lossy().to_string())
.collect::<Vec<_>>()
.join(" ")
);
match util_cmd::run_command_and_wait(&mut cmd) {
Ok(exit_status) => {
debugging!("Command exited with status: {}", exit_status);
if !exit_status.success() {
failure_and_exit!("Exit with error: {}", exit_status);
}
}
Err(err) => {
failure_and_exit!("SSH command failed: {}", err);
}
}
Ok(())
}
fn pad(str: &str, width: usize) -> String {
if str.len() >= width {
return str.to_string();
}
format!("{}{}", str, " ".repeat(width - str.len()))
}
fn parse_username_and_host(username_and_host: &str) -> XResult<(Option<String>, String)> {
if username_and_host.is_empty() {
return simple_error!("Empty username@host");
}
let username_and_host_parts = username_and_host.split("@").collect::<Vec<_>>();
if username_and_host_parts.len() == 1 {
return Ok((None, username_and_host_parts[0].to_string()));
}
if username_and_host_parts.len() > 2 {
return simple_error!("Bad username@host: {}", username_and_host);
}
Ok((
Some(username_and_host_parts[0].to_string()),
username_and_host_parts[1].to_string(),
))
}
fn load_ssh_rs_config() -> XResult<SshConfig> {
let config_file = get_ssh_rs_config_file();
let config_content = util_file::read_file_content(&config_file)?;
let config: SshConfig = opt_result!(
serde_json::from_str(config_content.as_str()),
"Parse config failed: {}"
);
Ok(config)
}
fn get_ssh_rs_config_file() -> String {
util_env::env_var(ENV_SSH_RS_CONFIG_FILE).unwrap_or_else(|| SSH_RS_CONFIG_FILE.to_string())
}