diff --git a/trifid-api/src/main.rs b/trifid-api/src/main.rs index 0f8b998..1679f4d 100644 --- a/trifid-api/src/main.rs +++ b/trifid-api/src/main.rs @@ -99,6 +99,7 @@ async fn main() -> Result<(), Box> { .service(routes::v1::hosts::create_hosts_request) .service(routes::v1::hosts::get_host) .service(routes::v1::hosts::delete_host) + .service(routes::v1::hosts::edit_host) }).bind(CONFIG.server.bind)?.run().await?; Ok(()) diff --git a/trifid-api/src/routes/v1/hosts.rs b/trifid-api/src/routes/v1/hosts.rs index 05e48fc..152ae12 100644 --- a/trifid-api/src/routes/v1/hosts.rs +++ b/trifid-api/src/routes/v1/hosts.rs @@ -33,21 +33,29 @@ // This endpoint has full parity with the original API. It has been recreated from the original API documentation. // This endpoint is considered done. No major features should be added or removed, unless it fixes bugs. // This endpoint requires the `definednetworking` extension to be enabled to be used. +// +//#PUT /v1/hosts/{host_id} t+parity:full t+type:documented t+status:done t+feature:definednetworking t+ext:t+feature:extended_hosts +// This endpoint has full parity with the original API. It has been recreated from the original API documentation. +// This endpoint is considered done. No major features should be added or removed, unless it fixes bugs. +// This endpoint requires the `definednetworking` extension to be enabled to be used. +// This endpoint has additional functionality enabled by the extended_hosts feature flag. use std::net::{Ipv4Addr, SocketAddrV4}; use std::str::FromStr; use std::time::{SystemTime, UNIX_EPOCH}; -use actix_web::{HttpRequest, HttpResponse, get, post, delete}; +use actix_web::{HttpRequest, HttpResponse, get, post, delete, put}; use actix_web::web::{Data, Json, Path, Query}; use chrono::{TimeZone, Utc}; -use log::error; +use log::{debug, error}; use sea_orm::{EntityTrait, QueryFilter, ColumnTrait, QueryOrder, PaginatorTrait, IntoActiveModel, ActiveModelTrait, ModelTrait}; +use sea_orm::ActiveValue::Set; use serde::{Serialize, Deserialize}; use trifid_api_entities::entity::{host, host_static_address, network, organization}; use crate::AppState; use crate::auth_tokens::{enforce_2fa, enforce_api_token, TokenInfo}; use crate::cursor::Cursor; use crate::error::{APIError, APIErrorsResponse}; +use crate::routes::v1::trifid::SUPPORTED_EXTENSIONS; use crate::timers::TIME_FORMAT; use crate::tokens::random_id; @@ -1043,4 +1051,299 @@ pub async fn delete_host(id: Path, req_info: HttpRequest, db: Data, + #[serde(rename = "listenPort")] + pub listen_port: u16, + // t+features:extended_hosts + pub name: Option, + // t+features:extended_hosts + pub ip: Option +} + +#[derive(Serialize, Deserialize)] +pub struct EditHostExtensionQuery { + pub extension: Option +} + +#[derive(Serialize, Deserialize)] +pub struct EditHostResponse { + pub data: HostResponse, + pub metadata: EditHostResponseMetadata +} + +#[derive(Serialize, Deserialize)] +pub struct EditHostResponseMetadata {} + +#[put("/v1/hosts/{host_id}")] +pub async fn edit_host(id: Path, query: Query, 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:edit + let session_info = enforce_2fa(&req_info, &db.conn).await.unwrap_or(TokenInfo::NotPresent); + let api_token_info = enforce_api_token(&req_info, &["hosts:edit"], &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:edit 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 static_addresses = match host_static_address::Entity::find().filter(host_static_address::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 address in static_addresses { + match address.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 + } + ], + }) + } + } + } + + let mut host_clone = host.clone(); + let mut host_active_model = host_clone.into_active_model(); + + host_active_model.listen_port = Set(req.listen_port as i32); + + debug!("{:?} {} {:?} {:?} {}", query.extension, SUPPORTED_EXTENSIONS.contains(&"extended_hosts"), req.name, req.ip, query.extension == Some("extended_hosts".to_string())); + + if query.extension == Some("extended_hosts".to_string()) && SUPPORTED_EXTENSIONS.contains(&"extended_hosts") { + if let Some(new_host_name) = req.name.clone() { + debug!("updated host name"); + host_active_model.name = Set(new_host_name); + } + if let Some(new_host_ip) = req.ip { + debug!("updated host ip"); + host_active_model.ip = Set(new_host_ip.to_string()); + } + } + + + let host = match host_active_model.update(&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 static_addresses: Vec = req.static_addresses.iter().map(|u| { + host_static_address::Model { + id: random_id("hsaddress"), + host: host.id.clone(), + address: u.to_string(), + } + }).collect(); + + for rule in &static_addresses { + let active_model = rule.clone().into_active_model(); + match 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 creating the new host. Please try again later".to_string(), + path: None + } + ], + }) + } + } + } + + HttpResponse::Ok().json(EditHostResponse { + data: HostResponse { + id: host.id, + organization_id: org_id, + network_id: net_id, + role_id: host.role, + name: host.name, + ip_address: host.ip.to_string(), + static_addresses: req.static_addresses.clone(), + listen_port: host.listen_port as u16, + is_lighthouse: host.is_lighthouse, + is_relay: host.is_relay, + created_at: Utc.timestamp_opt(host.created_at, 0).unwrap().format(TIME_FORMAT).to_string(), + is_blocked: host.is_blocked, + metadata: HostResponseMetadata { + last_seen_at: Some(Utc.timestamp_opt(host.last_seen_at, 0).unwrap().format(TIME_FORMAT).to_string()), + version: host.last_version.to_string(), + platform: host.last_platform, + update_available: host.last_out_of_date, + }, + }, + metadata: EditHostResponseMetadata {}, + }) } \ No newline at end of file diff --git a/trifid-api/src/routes/v1/roles.rs b/trifid-api/src/routes/v1/roles.rs index fcb9fbe..990dfb9 100644 --- a/trifid-api/src/routes/v1/roles.rs +++ b/trifid-api/src/routes/v1/roles.rs @@ -51,6 +51,7 @@ use trifid_api_entities::entity::role; use crate::cursor::Cursor; use crate::tokens::random_id; use actix_web::delete; +use sea_orm::ActiveValue::Set; use crate::timers::TIME_FORMAT; #[derive(Serialize, Deserialize, Debug, Clone)] @@ -770,9 +771,15 @@ pub struct RoleDeleteResponseData {} #[derive(Serialize, Deserialize, Debug, Clone)] pub struct RoleDeleteResponseMetadata {} +#[derive(Serialize, Deserialize)] +pub struct RoleUpdateRequest { + pub description: String, + #[serde(rename = "firewallRules")] + pub firewall_rules: Vec +} #[put("/v1/roles/{role_id}")] -pub async fn update_role_request(role: Path, req: Json, req_info: HttpRequest, db: Data) -> HttpResponse { +pub async fn update_role_request(role: 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 roles:create let session_info = enforce_2fa(&req_info, &db.conn).await.unwrap_or(TokenInfo::NotPresent); let api_token_info = enforce_api_token(&req_info, &["roles:create"], &db.conn).await.unwrap_or(TokenInfo::NotPresent); @@ -878,19 +885,62 @@ pub async fn update_role_request(role: Path, req: Json 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, + } + ], + }); + } + }; + + let role = match role { + Some(r) => r, + 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 + } + ] + }) + } + }; + + let mut role_active_model = role.clone().into_active_model(); + + role_active_model.modified_at = Set(SystemTime::now().duration_since(UNIX_EPOCH).expect("Time went backwards").as_secs() as i64); + role_active_model.description = Set(req.description.clone()); + + let role = match role_active_model.update(&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, + } + ], + }); + } }; let firewall_rules: Vec = req.firewall_rules.iter().map(|i| { firewall_rule::Model { id: random_id("rule"), - role: new_role_model.id.clone(), + role: role.id.clone(), protocol: i.protocol.to_string(), description: i.description.clone(), allowed_role_id: i.allowed_role_id.clone(), @@ -899,26 +949,8 @@ pub async fn update_role_request(role: Path, req: Json (), - 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 creating the new role. Please try again later".to_string(), - path: None - } - ], - }) - } - } - for rule in &firewall_rules_clone { let active_model = rule.clone().into_active_model(); match active_model.insert(&db.conn).await { @@ -940,12 +972,12 @@ pub async fn update_role_request(role: Path, req: Json @@ -43,6 +45,6 @@ pub struct TrifidExtensionsResponse { #[get("/v1/trifid_extensions")] pub async fn trifid_extensions() -> HttpResponse { HttpResponse::Ok().json(TrifidExtensionsResponse { - extensions: vec!["definednetworking".to_string(), "trifidextensions".to_string(), "extended_roles".to_string(), "extended_hosts".to_string()], + extensions: SUPPORTED_EXTENSIONS.iter().map(|u| u.to_string()).collect(), }) } \ No newline at end of file diff --git a/trifid-api/trifid_api_migration/src/m20230427_170037_create_table_hosts.rs b/trifid-api/trifid_api_migration/src/m20230427_170037_create_table_hosts.rs index fc6bfe5..c1de66f 100644 --- a/trifid-api/trifid_api_migration/src/m20230427_170037_create_table_hosts.rs +++ b/trifid-api/trifid_api_migration/src/m20230427_170037_create_table_hosts.rs @@ -42,12 +42,20 @@ impl MigrationTrait for Migration { ) .index( Index::create() - .name("idx-hosts-id-name-unique") + .name("idx-hosts-net-name-unique") .table(Host::Table) - .col(Host::Id) + .col(Host::Network) .col(Host::Name) .unique() ) + .index( + Index::create() + .name("idx-hosts-net-ip-unique") + .table(Host::Table) + .col(Host::Network) + .col(Host::IP) + .unique() + ) .to_owned() ).await }