use std::{fmt, fs, io::Read as _};
use rust_i18n::t;
use tabled::Tabled;
use timeweb_rs::{
apis::{configuration::Configuration, ssh_api},
models::{AddKeyToServerRequest, CreateKeyRequest, UpdateKeyRequest}
};
use crate::{error::TwcError, output::OutputFormat};
fn fmt_id<T: std::fmt::Display>(v: T) -> String {
v.to_string()
}
#[derive(Tabled)]
struct SshKeyRow {
#[tabled(rename = "ID")]
id: String,
#[tabled(rename = "Name")]
name: String,
#[tabled(rename = "Fingerprint")]
fingerprint: String,
#[tabled(rename = "Default")]
is_default: String
}
impl fmt::Display for SshKeyRow {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} {} {} {}",
self.id, self.name, self.fingerprint, self.is_default
)
}
}
fn fingerprint(body: &str) -> String {
let fp = body.split_whitespace().nth(1);
fp.map_or_else(
|| "-".to_string(),
|f| {
let bytes: Vec<u8> = f
.as_bytes()
.chunks(2)
.filter_map(|c| u8::from_str_radix(std::str::from_utf8(c).unwrap_or(""), 16).ok())
.collect();
if bytes.len() >= 16 {
let hex: Vec<String> = bytes[..16].iter().map(|b| format!("{b:02x}")).collect();
hex.join(":")
} else {
f.to_string()
}
}
)
}
pub async fn list(config: &Configuration, format: OutputFormat) -> Result<(), TwcError> {
let resp = ssh_api::get_keys(config).await?;
let rows: Vec<SshKeyRow> = resp
.ssh_keys
.iter()
.map(|k| SshKeyRow {
id: fmt_id(k.id),
name: k.name.clone(),
fingerprint: fingerprint(&k.body),
is_default: k
.is_default
.map_or_else(|| "-".to_string(), |d| d.to_string())
})
.collect();
match format {
OutputFormat::Table => {
if rows.is_empty() {
println!("{}", t!("cli.no_ssh_keys_found"));
} else {
let table = crate::output::render_table(&rows);
println!("{table}");
}
}
OutputFormat::Json | OutputFormat::Yaml => {
let out = crate::output::serialized(format, &resp.ssh_keys)
.transpose()?
.unwrap_or_default();
println!("{out}");
}
OutputFormat::Quiet => {
for k in &resp.ssh_keys {
println!("{}\t{}", fmt_id(k.id), k.name);
}
}
}
Ok(())
}
pub async fn add(
config: &Configuration,
name: &str,
file_path: Option<&str>,
is_default: bool
) -> Result<(), TwcError> {
let body = read_key_body(file_path)?;
add_key(config, name, body, is_default).await
}
fn read_key_body(file_path: Option<&str>) -> Result<String, TwcError> {
if let Some(path) = file_path {
fs::read_to_string(path).map_err(|e| TwcError::Io(format!("failed to read {path}: {e}")))
} else {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.map_err(|e| TwcError::Io(format!("failed to read stdin: {e}")))?;
Ok(buf)
}
}
pub async fn add_key(
config: &Configuration,
name: &str,
body: String,
is_default: bool
) -> Result<(), TwcError> {
let body = body.trim().to_string();
if body.is_empty() {
return Err(TwcError::Io("SSH key body is empty".to_string()));
}
let req = CreateKeyRequest::new(body, is_default, name.to_string());
let resp = ssh_api::create_key(config, req).await?;
println!(
"{}",
t!("cli.ssh_key_created", name => resp.ssh_key.name, id => fmt_id(resp.ssh_key.id))
);
Ok(())
}
pub async fn delete(config: &Configuration, id: i32) -> Result<(), TwcError> {
ssh_api::delete_key(config, id).await?;
println!("{}", t!("cli.ssh_key_deleted", id => id));
Ok(())
}
pub async fn info(config: &Configuration, id: i32, format: OutputFormat) -> Result<(), TwcError> {
let resp = ssh_api::get_key(config, id).await?;
let key = &resp.ssh_key;
match format {
OutputFormat::Table => {
println!("ID: {}", fmt_id(key.id));
println!("Name: {}", key.name);
println!(
"Default: {}",
key.is_default
.map_or_else(|| "-".to_string(), |d| d.to_string())
);
println!("Fingerprint: {}", fingerprint(&key.body));
println!("Created: {}", key.created_at.to_rfc3339());
println!("Used by: {}", fmt_id(key.used_by.len()));
println!("Body: {}", key.body);
}
OutputFormat::Json | OutputFormat::Yaml => {
if let Some(out) = crate::output::serialized(format, key.as_ref()) {
println!("{}", out?);
}
}
OutputFormat::Quiet => {
println!("{}\t{}", fmt_id(key.id), key.name);
}
}
Ok(())
}
pub async fn edit(
config: &Configuration,
id: i32,
name: Option<&str>,
is_default: Option<bool>
) -> Result<(), TwcError> {
let mut req = UpdateKeyRequest::new();
req.name = name.map(ToString::to_string);
req.is_default = is_default;
let resp = ssh_api::update_key(config, id, req).await?;
println!(
"{}",
t!("cli.ssh_key_updated", name => resp.ssh_key.name, id => fmt_id(resp.ssh_key.id))
);
Ok(())
}
pub async fn attach(
config: &Configuration,
server_id: i32,
key_ids: &[i32]
) -> Result<(), TwcError> {
let ids: Vec<f64> = key_ids.iter().map(|id| f64::from(*id)).collect();
let req = AddKeyToServerRequest::new(ids);
ssh_api::add_key_to_server(config, server_id, req).await?;
let keys = key_ids
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ");
println!(
"{}",
t!("cli.ssh_key_attached", keys => keys, server => server_id)
);
Ok(())
}
pub async fn detach(config: &Configuration, server_id: i32, key_id: i32) -> Result<(), TwcError> {
ssh_api::delete_key_from_server(config, server_id, key_id).await?;
println!(
"{}",
t!("cli.ssh_key_detached", key => key_id, server => server_id)
);
Ok(())
}
#[cfg(test)]
mod tests;