diff --git a/tfcli/src/host.rs b/tfcli/src/host.rs index 8e611ba..57a1ac7 100644 --- a/tfcli/src/host.rs +++ b/tfcli/src/host.rs @@ -4,7 +4,7 @@ use std::net::{Ipv4Addr, SocketAddrV4}; use serde::{Deserialize, Serialize}; use url::{Url}; use crate::api::APIErrorResponse; -use crate::{HostCommands}; +use crate::{HostCommands, HostOverrideCommands}; pub async fn host_main(command: HostCommands, server: Url) -> Result<(), Box> { match command { @@ -14,7 +14,12 @@ pub async fn host_main(command: HostCommands, server: Url) -> Result<(), Box 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::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 + } } } @@ -412,5 +417,224 @@ pub async fn block_host(id: String, server: Url) -> Result<(), Box> { 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(()) } \ No newline at end of file diff --git a/tfcli/src/main.rs b/tfcli/src/main.rs index 2c834ab..adb6529 100644 --- a/tfcli/src/main.rs +++ b/tfcli/src/main.rs @@ -203,6 +203,40 @@ pub enum HostCommands { Enroll { #[clap(short, long)] id: String + }, + /// Manage config overrides set on the host + Overrides { + #[command(subcommand)] + command: HostOverrideCommands + } +} + +#[derive(Subcommand, Debug)] +pub enum HostOverrideCommands { + /// List the config overrides set on the host + List { + #[clap(short, long)] + id: String + }, + /// Set a config override on the host + Set { + #[clap(short, long)] + id: String, + #[clap(short, long)] + key: String, + #[clap(short, long)] + boolean: Option, + #[clap(short, long)] + numeric: Option, + #[clap(short, long)] + string: Option + }, + /// Unset a config override on the host + Unset { + #[clap(short, long)] + id: String, + #[clap(short, long)] + key: String } } diff --git a/trifid-api/src/main.rs b/trifid-api/src/main.rs index 263b90d..583c122 100644 --- a/trifid-api/src/main.rs +++ b/trifid-api/src/main.rs @@ -119,6 +119,8 @@ async fn main() -> Result<(), Box> { .service(routes::v2::enroll::enroll) .service(routes::v1::dnclient::dnclient) .service(routes::v2::whoami::whoami) + .service(routes::v1::hosts::get_host_overrides) + .service(routes::v1::hosts::update_host_overrides) }) .bind(CONFIG.server.bind)? .run() diff --git a/trifid-api/src/routes/v1/hosts.rs b/trifid-api/src/routes/v1/hosts.rs index 38568fb..f6fd78e 100644 --- a/trifid-api/src/routes/v1/hosts.rs +++ b/trifid-api/src/routes/v1/hosts.rs @@ -76,7 +76,8 @@ use serde::{Deserialize, Serialize}; use std::net::{Ipv4Addr, SocketAddrV4}; use std::str::FromStr; use std::time::{SystemTime, UNIX_EPOCH}; -use trifid_api_entities::entity::{host, host_static_address, network, organization}; +use trifid_api_entities::entity::{host, host_config_override, host_static_address, network, organization}; +use trifid_api_entities::entity::prelude::HostConfigOverride; #[derive(Serialize, Deserialize)] pub struct ListHostsRequestOpts { @@ -2328,3 +2329,495 @@ pub async fn create_host_and_enrollment_code( metadata: CreateHostAndCodeResponseMetadata {}, }) } + +#[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) +} + +#[get("/v1/hosts/{host_id}/config-overrides")] +pub async fn get_host_overrides(id: Path, req_info: HttpRequest, db: Data) -> HttpResponse { + // For this endpoint, you either need to be a fully authenticated user OR a token with hosts:read + let session_info = enforce_2fa(&req_info, &db.conn) + .await + .unwrap_or(TokenInfo::NotPresent); + let api_token_info = enforce_api_token(&req_info, &["hosts:read"], &db.conn) + .await + .unwrap_or(TokenInfo::NotPresent); + + // If neither are present, throw an error + if matches!(session_info, TokenInfo::NotPresent) + && matches!(api_token_info, TokenInfo::NotPresent) + { + return HttpResponse::Unauthorized().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_UNAUTHORIZED".to_string(), + message: "This endpoint requires either a fully authenticated user or a token with the hosts:read scope".to_string(), + path: None, + } + ], + }); + } + + // If both are present, throw an error + if matches!(session_info, TokenInfo::AuthToken(_)) + && matches!(api_token_info, TokenInfo::ApiToken(_)) + { + return HttpResponse::BadRequest().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_AMBIGUOUS_AUTHENTICATION".to_string(), + message: "Both a user token and an API token with the proper scope was provided. Please only provide one.".to_string(), + path: None + } + ], + }); + } + + let org_id = match api_token_info { + TokenInfo::ApiToken(tkn) => tkn.organization, + _ => { + // we have a session token, which means we have to do a db request to get the organization that this user owns + let user = match session_info { + TokenInfo::AuthToken(tkn) => tkn.session_info.user, + _ => unreachable!(), + }; + + let org = match organization::Entity::find() + .filter(organization::Column::Owner.eq(user.id)) + .one(&db.conn) + .await + { + Ok(r) => r, + Err(e) => { + error!("database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error performing the database request, please try again later.".to_string(), + path: None, + } + ], + }); + } + }; + + if let Some(org) = org { + org.id + } else { + return HttpResponse::Unauthorized().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_NO_ORG".to_string(), + message: "This user does not own any organizations. Try using an API token instead.".to_string(), + path: None + } + ], + }); + } + } + }; + + let net_id; + + let net = match network::Entity::find() + .filter(network::Column::Organization.eq(&org_id)) + .one(&db.conn) + .await + { + Ok(r) => r, + Err(e) => { + error!("database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error performing the database request, please try again later.".to_string(), + path: None, + } + ], + }); + } + }; + + if let Some(net) = net { + net_id = net.id; + } else { + return HttpResponse::Unauthorized().json(APIErrorsResponse { + errors: vec![APIError { + code: "ERR_NO_NET".to_string(), + message: "This user does not own any networks. Try using an API token instead." + .to_string(), + path: None, + }], + }); + } + + let host = match host::Entity::find() + .filter(host::Column::Id.eq(id.into_inner())) + .one(&db.conn) + .await + { + Ok(h) => h, + Err(e) => { + error!("Database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error with the database query. Please try again later." + .to_string(), + path: None, + }], + }); + } + }; + + let host = match host { + Some(h) => h, + None => { + return HttpResponse::Unauthorized().json(APIErrorsResponse { + errors: vec![APIError { + code: "ERR_UNAUTHORIZED".to_string(), + message: + "This resource does not exist or you do not have permission to access it." + .to_string(), + path: None, + }], + }) + } + }; + + if host.network != net_id { + return HttpResponse::Unauthorized().json(APIErrorsResponse { + errors: vec![APIError { + code: "ERR_UNAUTHORIZED".to_string(), + message: "This resource does not exist or you do not have permission to access it." + .to_string(), + path: None, + }], + }); + } + + let config_overrides = match trifid_api_entities::entity::host_config_override::Entity::find().filter(host_config_override::Column::Host.eq(host.id)).all(&db.conn).await { + Ok(h) => h, + Err(e) => { + error!("Database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error with the database query. Please try again later." + .to_string(), + path: None, + }], + }); + } + }; + + let overrides: Vec = config_overrides.iter().map(|u| { + let val; + if u.value == "true" || u.value == "false" { + val = HostConfigOverrideDataOverrideValue::Boolean(u.value == "true"); + } else if u.value.chars().all(|c| c.is_numeric()) { + val = HostConfigOverrideDataOverrideValue::Numeric(u.value.parse().unwrap()); + } else { + val = HostConfigOverrideDataOverrideValue::Other(u.value.clone()); + } + HostConfigOverrideDataOverride { + key: u.key.clone(), + value: val, + } + }).collect(); + + HttpResponse::Ok().json(HostConfigOverrideResponse { + data: HostConfigOverrideData { + overrides, + }, + }) +} + +#[derive(Serialize, Deserialize)] +pub struct UpdateOverridesRequest { + pub overrides: Vec +} + +#[put("/v1/hosts/{host_id}/config-overrides")] +pub async fn update_host_overrides(id: Path, req: Json, req_info: HttpRequest, db: Data) -> HttpResponse { + // For this endpoint, you either need to be a fully authenticated user OR a token with hosts:read + let session_info = enforce_2fa(&req_info, &db.conn) + .await + .unwrap_or(TokenInfo::NotPresent); + let api_token_info = enforce_api_token(&req_info, &["hosts:read"], &db.conn) + .await + .unwrap_or(TokenInfo::NotPresent); + + // If neither are present, throw an error + if matches!(session_info, TokenInfo::NotPresent) + && matches!(api_token_info, TokenInfo::NotPresent) + { + return HttpResponse::Unauthorized().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_UNAUTHORIZED".to_string(), + message: "This endpoint requires either a fully authenticated user or a token with the hosts:read scope".to_string(), + path: None, + } + ], + }); + } + + // If both are present, throw an error + if matches!(session_info, TokenInfo::AuthToken(_)) + && matches!(api_token_info, TokenInfo::ApiToken(_)) + { + return HttpResponse::BadRequest().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_AMBIGUOUS_AUTHENTICATION".to_string(), + message: "Both a user token and an API token with the proper scope was provided. Please only provide one.".to_string(), + path: None + } + ], + }); + } + + let org_id = match api_token_info { + TokenInfo::ApiToken(tkn) => tkn.organization, + _ => { + // we have a session token, which means we have to do a db request to get the organization that this user owns + let user = match session_info { + TokenInfo::AuthToken(tkn) => tkn.session_info.user, + _ => unreachable!(), + }; + + let org = match organization::Entity::find() + .filter(organization::Column::Owner.eq(user.id)) + .one(&db.conn) + .await + { + Ok(r) => r, + Err(e) => { + error!("database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error performing the database request, please try again later.".to_string(), + path: None, + } + ], + }); + } + }; + + if let Some(org) = org { + org.id + } else { + return HttpResponse::Unauthorized().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_NO_ORG".to_string(), + message: "This user does not own any organizations. Try using an API token instead.".to_string(), + path: None + } + ], + }); + } + } + }; + + let net_id; + + let net = match network::Entity::find() + .filter(network::Column::Organization.eq(&org_id)) + .one(&db.conn) + .await + { + Ok(r) => r, + Err(e) => { + error!("database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error performing the database request, please try again later.".to_string(), + path: None, + } + ], + }); + } + }; + + if let Some(net) = net { + net_id = net.id; + } else { + return HttpResponse::Unauthorized().json(APIErrorsResponse { + errors: vec![APIError { + code: "ERR_NO_NET".to_string(), + message: "This user does not own any networks. Try using an API token instead." + .to_string(), + path: None, + }], + }); + } + + let host = match host::Entity::find() + .filter(host::Column::Id.eq(id.into_inner())) + .one(&db.conn) + .await + { + Ok(h) => h, + Err(e) => { + error!("Database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error with the database query. Please try again later." + .to_string(), + path: None, + }], + }); + } + }; + + let host = match host { + Some(h) => h, + None => { + return HttpResponse::Unauthorized().json(APIErrorsResponse { + errors: vec![APIError { + code: "ERR_UNAUTHORIZED".to_string(), + message: + "This resource does not exist or you do not have permission to access it." + .to_string(), + path: None, + }], + }) + } + }; + + if host.network != net_id { + return HttpResponse::Unauthorized().json(APIErrorsResponse { + errors: vec![APIError { + code: "ERR_UNAUTHORIZED".to_string(), + message: "This resource does not exist or you do not have permission to access it." + .to_string(), + path: None, + }], + }); + } + + let config_overrides = match trifid_api_entities::entity::host_config_override::Entity::find().filter(host_config_override::Column::Host.eq(&host.id)).all(&db.conn).await { + Ok(h) => h, + Err(e) => { + error!("Database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error with the database query. Please try again later." + .to_string(), + path: None, + }], + }); + } + }; + + for c_override in config_overrides { + match c_override.delete(&db.conn).await { + Ok(_) => (), + Err(e) => { + error!("Database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error with the database query. Please try again later." + .to_string(), + path: None, + }], + }); + } + } + } + + for c_override in &req.overrides { + let db_override = host_config_override::Model { + id: random_id("override"), + key: c_override.key.clone(), + value: match &c_override.value { + HostConfigOverrideDataOverrideValue::Boolean(v) => v.to_string(), + HostConfigOverrideDataOverrideValue::Numeric(v) => v.to_string(), + HostConfigOverrideDataOverrideValue::Other(v) => v.clone(), + }, + host: host.id.clone(), + }; + match db_override.into_active_model().insert(&db.conn).await { + Ok(_) => (), + Err(e) => { + error!("Database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error with the database query. Please try again later." + .to_string(), + path: None, + }], + }); + } + } + } + + let config_overrides = match trifid_api_entities::entity::host_config_override::Entity::find().filter(host_config_override::Column::Host.eq(&host.id)).all(&db.conn).await { + Ok(h) => h, + Err(e) => { + error!("Database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error with the database query. Please try again later." + .to_string(), + path: None, + }], + }); + } + }; + + let overrides: Vec = config_overrides.iter().map(|u| { + let val; + if u.value == "true" || u.value == "false" { + val = HostConfigOverrideDataOverrideValue::Boolean(u.value == "true"); + } else if u.value.chars().all(|c| c.is_numeric()) || u.value.starts_with('-') && u.value.chars().collect::>()[1..].iter().all(|c| c.is_numeric()) { + val = HostConfigOverrideDataOverrideValue::Numeric(u.value.parse().unwrap()); + } else { + val = HostConfigOverrideDataOverrideValue::Other(u.value.clone()); + } + HostConfigOverrideDataOverride { + key: u.key.clone(), + value: val, + } + }).collect(); + + HttpResponse::Ok().json(HostConfigOverrideResponse { + data: HostConfigOverrideData { + overrides, + }, + }) +} \ No newline at end of file