use crate::api::APIErrorResponse; use crate::{HostCommands, HostOverrideCommands, TableStyle}; use serde::{Deserialize, Serialize}; use std::error::Error; use std::fs; use std::net::{Ipv4Addr, SocketAddrV4}; use comfy_table::modifiers::UTF8_ROUND_CORNERS; use comfy_table::presets::UTF8_FULL; use comfy_table::Table; use url::Url; pub async fn host_main(command: HostCommands, server: Url) -> Result<(), Box> { match command { HostCommands::List { table_style } => list_hosts(server, table_style).await, HostCommands::Create { name, network_id, role_id, ip_address, listen_port, lighthouse, relay, static_address, } => { create_host( name, network_id, role_id, ip_address, listen_port, lighthouse, relay, static_address, server, ) .await } HostCommands::Lookup { id } => get_host(id, server).await, HostCommands::Delete { id } => delete_host(id, server).await, HostCommands::Update { id, listen_port, static_address, name, ip, role, } => update_host(id, listen_port, static_address, name, ip, role, server).await, HostCommands::Block { id } => block_host(id, server).await, HostCommands::Enroll { id } => enroll_host(id, server).await, HostCommands::Overrides { command } => match command { HostOverrideCommands::List { id } => list_overrides(id, server).await, HostOverrideCommands::Set { id, key, boolean, string, numeric, } => set_override(id, key, boolean, numeric, string, server).await, HostOverrideCommands::Unset { id, key } => unset_override(id, key, server).await, }, } } #[derive(Deserialize)] pub struct HostListResp { pub data: Vec, } #[derive(Serialize, Deserialize)] pub struct HostMetadata { #[serde(rename = "lastSeenAt")] pub last_seen_at: String, pub version: String, pub platform: String, #[serde(rename = "updateAvailable")] pub update_available: bool, } #[derive(Serialize, Deserialize)] pub struct Host { pub id: String, #[serde(rename = "organizationID")] pub organization_id: String, #[serde(rename = "networkID")] pub network_id: String, #[serde(rename = "roleID")] pub role_id: String, pub name: String, #[serde(rename = "ipAddress")] pub ip_address: String, #[serde(rename = "staticAddresses")] pub static_addresses: Vec, #[serde(rename = "listenPort")] pub listen_port: i64, #[serde(rename = "isLighthouse")] pub is_lighthouse: bool, #[serde(rename = "isRelay")] pub is_relay: bool, #[serde(rename = "createdAt")] pub created_at: String, #[serde(rename = "isBlocked")] pub is_blocked: bool, pub metadata: HostMetadata, } pub async fn list_hosts(server: Url, table_style: TableStyle) -> Result<(), Box> { let client = reqwest::Client::new(); // load session token let sess_token_store = dirs::config_dir().unwrap().join("tfcli-session.token"); let session_token = fs::read_to_string(&sess_token_store)?; let auth_token_store = dirs::config_dir().unwrap().join("tfcli-auth.token"); let auth_token = fs::read_to_string(&auth_token_store)?; let token = format!("{} {}", session_token, auth_token); let res = client .get(server.join("/v1/hosts?pageSize=5000")?) .bearer_auth(token) .send() .await?; if res.status().is_success() { let resp: HostListResp = res.json().await?; if resp.data.is_empty() { println!("No hosts found"); return Ok(()); } if matches!(table_style, TableStyle::List) { for host in &resp.data { println!(" Host: {} ({})", host.name, host.id); println!(" Organization: {}", host.organization_id); println!(" Network: {}", host.network_id); println!(" Role: {}", host.role_id); println!(" IP Address: {}", host.ip_address); println!( " Static Addresses: {}", host.static_addresses .iter() .map(|u| u.to_string()) .collect::>() .join(", ") ); println!(" Listen Port: {}", host.listen_port); println!( " Type: {}", if host.is_lighthouse { "Lighthouse" } else if host.is_relay { "Relay" } else { "Host" } ); println!(" Blocked: {}", host.is_blocked); println!(" Last Seen: {}", host.metadata.last_seen_at); println!(" Client Version: {}", host.metadata.version); println!(" Platform: {}", host.metadata.platform); println!( "Client Update Available: {}", host.metadata.update_available ); println!(" Created: {}", host.created_at); println!(); } return Ok(()); } let mut table = Table::new(); match table_style { TableStyle::List => unreachable!(), TableStyle::Basic => (), TableStyle::Pretty => { table.load_preset(UTF8_FULL).apply_modifier(UTF8_ROUND_CORNERS) ; }, }; table.set_header(vec!["ID", "Name", "Organization ID", "Network ID", "Role ID", "IP Address", "Static Addresses", "Listen Port", "Type", "Blocked", "Last Seen"]); for host in &resp.data { table.add_row(vec![host.id.as_str(), &host.name, &host.organization_id, &host.network_id, &host.role_id, &host.ip_address, &host.static_addresses.iter().map(|u| u.to_string()).collect::>().join(" "), &host.listen_port.to_string(), if host.is_lighthouse { "Lighthouse" } else if host.is_relay { "Relay" } else { "Host" }, if host.is_blocked { "true" } else { "false" }, &host.metadata.last_seen_at]); } println!("{table}"); } else { let resp: APIErrorResponse = res.json().await?; eprintln!( "[error] Error listing hosts: {} {}", resp.errors[0].code, resp.errors[0].message ); std::process::exit(1); } Ok(()) } #[derive(Serialize, Deserialize)] pub struct HostCreateBody { pub name: String, #[serde(rename = "networkID")] pub network_id: String, #[serde(rename = "roleID")] pub role_id: String, #[serde(rename = "ipAddress")] pub ip_address: Ipv4Addr, #[serde(rename = "listenPort")] pub listen_port: u16, #[serde(rename = "isLighthouse")] pub is_lighthouse: bool, #[serde(rename = "isRelay")] pub is_relay: bool, #[serde(rename = "staticAddresses")] pub static_addresses: Vec, } #[derive(Serialize, Deserialize)] pub struct HostGetMetadata {} #[derive(Serialize, Deserialize)] pub struct HostGetResponse { pub data: Host, pub metadata: HostGetMetadata, } #[allow(clippy::too_many_arguments)] pub async fn create_host( name: String, network_id: String, role_id: String, ip_address: Ipv4Addr, listen_port: Option, lighthouse: bool, relay: bool, static_address: Option, server: Url, ) -> Result<(), Box> { if lighthouse && relay { eprintln!("[error] Error creating host: a host cannot be both a lighthouse and a relay at the same time"); std::process::exit(1); } if (lighthouse || relay) && static_address.is_none() { eprintln!("[error] Error creating host: a relay or lighthouse must have a static IP"); std::process::exit(1); } if (lighthouse || relay) && listen_port.is_none() { eprintln!("[error] Error creating host: a relay or lighthouse must have a listen port"); std::process::exit(1); } let client = reqwest::Client::new(); // load session token let sess_token_store = dirs::config_dir().unwrap().join("tfcli-session.token"); let session_token = fs::read_to_string(&sess_token_store)?; let auth_token_store = dirs::config_dir().unwrap().join("tfcli-auth.token"); let auth_token = fs::read_to_string(&auth_token_store)?; let token = format!("{} {}", session_token, auth_token); let res = client .post(server.join("/v1/hosts")?) .json(&HostCreateBody { name, network_id, role_id, ip_address, listen_port: listen_port.unwrap_or(0), is_lighthouse: lighthouse, is_relay: relay, static_addresses: static_address.map_or(vec![], |u| vec![u]), }) .bearer_auth(token) .send() .await?; if res.status().is_success() { let host: Host = res.json::().await?.data; println!(" Host: {} ({})", host.name, host.id); println!(" Organization: {}", host.organization_id); println!(" Network: {}", host.network_id); println!(" Role: {}", host.role_id); println!(" IP Address: {}", host.ip_address); println!( " Static Addresses: {}", host.static_addresses .iter() .map(|u| u.to_string()) .collect::>() .join(", ") ); println!(" Listen Port: {}", host.listen_port); println!( " Type: {}", if host.is_lighthouse { "Lighthouse" } else if host.is_relay { "Relay" } else { "Host" } ); println!(" Blocked: {}", host.is_blocked); println!(" Last Seen: {}", host.metadata.last_seen_at); println!(" Client Version: {}", host.metadata.version); println!(" Platform: {}", host.metadata.platform); println!( "Client Update Available: {}", host.metadata.update_available ); println!(" Created: {}", host.created_at); println!(); } else { let resp: APIErrorResponse = res.json().await?; eprintln!( "[error] Error creating host: {} {}", resp.errors[0].code, resp.errors[0].message ); std::process::exit(1); } Ok(()) } pub async fn get_host(id: String, server: Url) -> Result<(), Box> { let client = reqwest::Client::new(); // load session token let sess_token_store = dirs::config_dir().unwrap().join("tfcli-session.token"); let session_token = fs::read_to_string(&sess_token_store)?; let auth_token_store = dirs::config_dir().unwrap().join("tfcli-auth.token"); let auth_token = fs::read_to_string(&auth_token_store)?; let token = format!("{} {}", session_token, auth_token); let res = client .get(server.join(&format!("/v1/hosts/{}", id))?) .bearer_auth(token) .send() .await?; if res.status().is_success() { let host: Host = res.json::().await?.data; println!(" Host: {} ({})", host.name, host.id); println!(" Organization: {}", host.organization_id); println!(" Network: {}", host.network_id); println!(" Role: {}", host.role_id); println!(" IP Address: {}", host.ip_address); println!( " Static Addresses: {}", host.static_addresses .iter() .map(|u| u.to_string()) .collect::>() .join(", ") ); println!(" Listen Port: {}", host.listen_port); println!( " Type: {}", if host.is_lighthouse { "Lighthouse" } else if host.is_relay { "Relay" } else { "Host" } ); println!(" Blocked: {}", host.is_blocked); println!(" Last Seen: {}", host.metadata.last_seen_at); println!(" Client Version: {}", host.metadata.version); println!(" Platform: {}", host.metadata.platform); println!( "Client Update Available: {}", host.metadata.update_available ); println!(" Created: {}", host.created_at); println!(); } else { let resp: APIErrorResponse = res.json().await?; eprintln!( "[error] Error listing hosts: {} {}", resp.errors[0].code, resp.errors[0].message ); std::process::exit(1); } Ok(()) } pub async fn delete_host(id: String, server: Url) -> Result<(), Box> { let client = reqwest::Client::new(); // load session token let sess_token_store = dirs::config_dir().unwrap().join("tfcli-session.token"); let session_token = fs::read_to_string(&sess_token_store)?; let auth_token_store = dirs::config_dir().unwrap().join("tfcli-auth.token"); let auth_token = fs::read_to_string(&auth_token_store)?; let token = format!("{} {}", session_token, auth_token); let res = client .delete(server.join(&format!("/v1/hosts/{}", id))?) .bearer_auth(token) .send() .await?; if res.status().is_success() { println!("Host removed"); } else { let resp: APIErrorResponse = res.json().await?; eprintln!( "[error] Error removing host: {} {}", resp.errors[0].code, resp.errors[0].message ); std::process::exit(1); } Ok(()) } #[derive(Serialize, Deserialize)] pub struct HostUpdateBody { #[serde(rename = "listenPort")] pub listen_port: u16, #[serde(rename = "staticAddresses")] pub static_addresses: Vec, pub name: Option, pub ip: Option, pub role: Option, } pub async fn update_host( id: String, listen_port: Option, static_address: Option, name: Option, ip: Option, role: Option, server: Url, ) -> Result<(), Box> { let client = reqwest::Client::new(); // load session token let sess_token_store = dirs::config_dir().unwrap().join("tfcli-session.token"); let session_token = fs::read_to_string(&sess_token_store)?; let auth_token_store = dirs::config_dir().unwrap().join("tfcli-auth.token"); let auth_token = fs::read_to_string(&auth_token_store)?; let token = format!("{} {}", session_token, auth_token); let res = client .put(server.join(&format!("/v1/hosts/{}?extension=extended_hosts", id))?) .json(&HostUpdateBody { listen_port: listen_port.unwrap_or(0), static_addresses: static_address.map_or_else(Vec::new, |u| vec![u]), name, ip, role, }) .bearer_auth(token) .send() .await?; if res.status().is_success() { let host: Host = res.json::().await?.data; println!(" Host: {} ({})", host.name, host.id); println!(" Organization: {}", host.organization_id); println!(" Network: {}", host.network_id); println!(" Role: {}", host.role_id); println!(" IP Address: {}", host.ip_address); println!( " Static Addresses: {}", host.static_addresses .iter() .map(|u| u.to_string()) .collect::>() .join(", ") ); println!(" Listen Port: {}", host.listen_port); println!( " Type: {}", if host.is_lighthouse { "Lighthouse" } else if host.is_relay { "Relay" } else { "Host" } ); println!(" Blocked: {}", host.is_blocked); println!(" Last Seen: {}", host.metadata.last_seen_at); println!(" Client Version: {}", host.metadata.version); println!(" Platform: {}", host.metadata.platform); println!( "Client Update Available: {}", host.metadata.update_available ); println!(" Created: {}", host.created_at); println!(); } else { let resp: APIErrorResponse = res.json().await?; eprintln!( "[error] Error updating host: {} {}", resp.errors[0].code, resp.errors[0].message ); std::process::exit(1); } Ok(()) } #[derive(Serialize, Deserialize)] pub struct EnrollmentCodeResponseMetadata {} #[derive(Serialize, Deserialize)] pub struct EnrollmentCode { pub code: String, #[serde(rename = "lifetimeSeconds")] pub lifetime_seconds: i64, } #[derive(Serialize, Deserialize)] pub struct EnrollmentResponseData { #[serde(rename = "enrollmentCode")] pub enrollment_code: EnrollmentCode, } #[derive(Serialize, Deserialize)] pub struct EnrollmentResponse { pub data: EnrollmentResponseData, pub metadata: EnrollmentCodeResponseMetadata, } pub async fn enroll_host(id: String, server: Url) -> Result<(), Box> { let client = reqwest::Client::new(); // load session token let sess_token_store = dirs::config_dir().unwrap().join("tfcli-session.token"); let session_token = fs::read_to_string(&sess_token_store)?; let auth_token_store = dirs::config_dir().unwrap().join("tfcli-auth.token"); let auth_token = fs::read_to_string(&auth_token_store)?; let token = format!("{} {}", session_token, auth_token); let res = client .post(server.join(&format!("/v1/hosts/{}/enrollment-code", id))?) .header("content-length", 0) .bearer_auth(token) .send() .await?; if res.status().is_success() { let resp: EnrollmentResponse = res.json().await?; println!( "Enrollment code generated. Enroll the host with the following code: {}", resp.data.enrollment_code.code ); println!("This code will be valid for {} seconds, at which point you will need to generate a new code", resp.data.enrollment_code.lifetime_seconds); println!("If this host is blocked, a successful re-enrollment will unblock it."); } else { let resp: APIErrorResponse = res.json().await?; eprintln!( "[error] Error blocking host: {} {}", resp.errors[0].code, resp.errors[0].message ); std::process::exit(1); } Ok(()) } pub async fn block_host(id: String, server: Url) -> Result<(), Box> { let client = reqwest::Client::new(); // load session token let sess_token_store = dirs::config_dir().unwrap().join("tfcli-session.token"); let session_token = fs::read_to_string(&sess_token_store)?; let auth_token_store = dirs::config_dir().unwrap().join("tfcli-auth.token"); let auth_token = fs::read_to_string(&auth_token_store)?; let token = format!("{} {}", session_token, auth_token); let res = client .post(server.join(&format!("/v1/hosts/{}/block", id))?) .header("Content-Length", "0") .bearer_auth(token) .send() .await?; if res.status().is_success() { println!("Host blocked. To unblock it, re-enroll the host."); } else { let resp: APIErrorResponse = res.json().await?; eprintln!( "[error] Error blocking host: {} {}", resp.errors[0].code, resp.errors[0].message ); std::process::exit(1); } Ok(()) } #[derive(Serialize, Deserialize)] pub struct HostConfigOverrideResponse { pub data: HostConfigOverrideData, } #[derive(Serialize, Deserialize)] pub struct HostConfigOverrideData { pub overrides: Vec, } #[derive(Serialize, Deserialize)] pub struct HostConfigOverrideDataOverride { pub key: String, pub value: HostConfigOverrideDataOverrideValue, } #[derive(Serialize, Deserialize)] #[serde(untagged)] pub enum HostConfigOverrideDataOverrideValue { Boolean(bool), Numeric(i64), Other(String), } pub async fn list_overrides(id: String, server: Url) -> Result<(), Box> { let client = reqwest::Client::new(); // load session token let sess_token_store = dirs::config_dir().unwrap().join("tfcli-session.token"); let session_token = fs::read_to_string(&sess_token_store)?; let auth_token_store = dirs::config_dir().unwrap().join("tfcli-auth.token"); let auth_token = fs::read_to_string(&auth_token_store)?; let token = format!("{} {}", session_token, auth_token); let res = client .get(server.join(&format!("/v1/hosts/{}/config-overrides", id))?) .bearer_auth(token) .send() .await?; if res.status().is_success() { let resp: HostConfigOverrideResponse = res.json().await?; for c_override in &resp.data.overrides { println!(" Key: {}", c_override.key); println!( "Value: {}", match &c_override.value { HostConfigOverrideDataOverrideValue::Boolean(v) => format!("bool:{}", v), HostConfigOverrideDataOverrideValue::Numeric(v) => format!("numeric:{}", v), HostConfigOverrideDataOverrideValue::Other(v) => format!("string:{}", v), } ); } if resp.data.overrides.is_empty() { println!("No overrides found"); } } else { let resp: APIErrorResponse = res.json().await?; eprintln!( "[error] Error looking up config overrides: {} {}", resp.errors[0].code, resp.errors[0].message ); std::process::exit(1); } Ok(()) } #[derive(Serialize, Deserialize)] pub struct SetOverrideRequest { pub overrides: Vec, } pub async fn set_override( id: String, key: String, boolean: Option, numeric: Option, other: Option, server: Url, ) -> Result<(), Box> { if boolean.is_none() && numeric.is_none() && other.is_none() { eprintln!("[error] no value provided: you must provide at least --boolean, --numeric, or --string"); std::process::exit(1); } else if boolean.is_some() && numeric.is_some() || boolean.is_some() && other.is_some() || numeric.is_some() && other.is_some() { eprintln!("[error] multiple values provided: you must provide only one of --boolean, --numeric, or --string"); std::process::exit(1); } let val; if let Some(v) = boolean { val = HostConfigOverrideDataOverrideValue::Boolean(v); } else if let Some(v) = numeric { val = HostConfigOverrideDataOverrideValue::Numeric(v); } else if let Some(v) = other { val = HostConfigOverrideDataOverrideValue::Other(v); } else { unreachable!(); } let client = reqwest::Client::new(); // load session token let sess_token_store = dirs::config_dir().unwrap().join("tfcli-session.token"); let session_token = fs::read_to_string(&sess_token_store)?; let auth_token_store = dirs::config_dir().unwrap().join("tfcli-auth.token"); let auth_token = fs::read_to_string(&auth_token_store)?; let token = format!("{} {}", session_token, auth_token); let res = client .get(server.join(&format!("/v1/hosts/{}/config-overrides", id))?) .bearer_auth(token.clone()) .send() .await?; if res.status().is_success() { let resp: HostConfigOverrideResponse = res.json().await?; let mut others: Vec = vec![]; for c_override in resp.data.overrides { if c_override.key != key { others.push(c_override); } } others.push(HostConfigOverrideDataOverride { key, value: val }); let res = client .put(server.join(&format!("/v1/hosts/{}/config-overrides", id))?) .bearer_auth(token.clone()) .json(&SetOverrideRequest { overrides: others }) .send() .await?; if res.status().is_success() { let resp: HostConfigOverrideResponse = res.json().await?; for c_override in &resp.data.overrides { println!(" Key: {}", c_override.key); println!( "Value: {}", match &c_override.value { HostConfigOverrideDataOverrideValue::Boolean(v) => format!("bool:{}", v), HostConfigOverrideDataOverrideValue::Numeric(v) => format!("numeric:{}", v), HostConfigOverrideDataOverrideValue::Other(v) => format!("string:{}", v), } ); } if resp.data.overrides.is_empty() { println!("No overrides found"); } println!("Override set successfully"); } else { let resp: APIErrorResponse = res.json().await?; eprintln!( "[error] Error setting config overrides: {} {}", resp.errors[0].code, resp.errors[0].message ); std::process::exit(1); } } else { let resp: APIErrorResponse = res.json().await?; eprintln!( "[error] Error setting config overrides: {} {}", resp.errors[0].code, resp.errors[0].message ); std::process::exit(1); } Ok(()) } pub async fn unset_override(id: String, key: String, server: Url) -> Result<(), Box> { let client = reqwest::Client::new(); // load session token let sess_token_store = dirs::config_dir().unwrap().join("tfcli-session.token"); let session_token = fs::read_to_string(&sess_token_store)?; let auth_token_store = dirs::config_dir().unwrap().join("tfcli-auth.token"); let auth_token = fs::read_to_string(&auth_token_store)?; let token = format!("{} {}", session_token, auth_token); let res = client .get(server.join(&format!("/v1/hosts/{}/config-overrides", id))?) .bearer_auth(token.clone()) .send() .await?; if res.status().is_success() { let resp: HostConfigOverrideResponse = res.json().await?; let mut others: Vec = vec![]; for c_override in resp.data.overrides { if c_override.key != key { others.push(c_override); } } let res = client .put(server.join(&format!("/v1/hosts/{}/config-overrides", id))?) .bearer_auth(token.clone()) .json(&SetOverrideRequest { overrides: others }) .send() .await?; if res.status().is_success() { let resp: HostConfigOverrideResponse = res.json().await?; for c_override in &resp.data.overrides { println!(" Key: {}", c_override.key); println!( "Value: {}", match &c_override.value { HostConfigOverrideDataOverrideValue::Boolean(v) => format!("bool:{}", v), HostConfigOverrideDataOverrideValue::Numeric(v) => format!("numeric:{}", v), HostConfigOverrideDataOverrideValue::Other(v) => format!("string:{}", v), } ); } if resp.data.overrides.is_empty() { println!("No overrides found"); } println!("Override unset successfully"); } else { let resp: APIErrorResponse = res.json().await?; eprintln!( "[error] Error unsetting config overrides: {} {}", resp.errors[0].code, resp.errors[0].message ); std::process::exit(1); } } else { let resp: APIErrorResponse = res.json().await?; eprintln!( "[error] Error unsetting config overrides: {} {}", resp.errors[0].code, resp.errors[0].message ); std::process::exit(1); } Ok(()) }