diff --git a/Cargo.lock b/Cargo.lock index 325dca0e385..12be997abfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3476,6 +3476,7 @@ checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" dependencies = [ "equivalent", "hashbrown 0.15.2", + "serde", ] [[package]] @@ -4685,6 +4686,7 @@ dependencies = [ "tracing-subscriber", "treeline", "url", + "utoipa", "uuid", ] @@ -8106,6 +8108,30 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utoipa" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435c6f69ef38c9017b4b4eea965dfb91e71e53d869e896db40d1cf2441dd75c0" +dependencies = [ + "indexmap 2.7.0", + "serde", + "serde_json", + "serde_yaml", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77d306bc75294fd52f3e99b13ece67c02c1a2789190a6f31d32f736624326f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.95", +] + [[package]] name = "uuid" version = "1.11.0" diff --git a/NOTICE.md b/NOTICE.md index a8e6eb208ae..3bb262f7a9d 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -581,6 +581,8 @@ This file contains attributions for any 3rd-party open source code used in this | utf16_iter | Apache-2.0, MIT | https://crates.io/crates/utf16_iter | | utf8_iter | Apache-2.0, MIT | https://crates.io/crates/utf8_iter | | utf8parse | Apache-2.0, MIT | https://crates.io/crates/utf8parse | +| utoipa | MIT, Apache-2.0 | https://crates.io/crates/utoipa | +| utoipa-gen | MIT, Apache-2.0 | https://crates.io/crates/utoipa-gen | | uuid | Apache-2.0, MIT | https://crates.io/crates/uuid | | value-bag | Apache-2.0, MIT | https://crates.io/crates/value-bag | | vcell | MIT, Apache-2.0 | https://crates.io/crates/vcell | diff --git a/implementations/rust/ockam/ockam_api/Cargo.toml b/implementations/rust/ockam/ockam_api/Cargo.toml index 3e4b2cff811..106d590b692 100644 --- a/implementations/rust/ockam/ockam_api/Cargo.toml +++ b/implementations/rust/ockam/ockam_api/Cargo.toml @@ -45,6 +45,10 @@ aws-lc = ["ockam_vault/aws-lc", "ockam_transport_tcp/aws-lc"] rust-crypto = ["ockam_vault/rust-crypto", "ockam_transport_tcp/ring"] privileged_portals = ["ockam_transport_tcp/privileged_portals"] +[[bin]] +name = "node_control_api_schema" +path = "src/control_api/exporter.rs" + [build-dependencies] cfg_aliases = "0.2.1" @@ -116,6 +120,7 @@ tracing-error = "0.2.0" tracing-opentelemetry = "0.27.0" tracing-subscriber = { version = "0.3", features = ["json"] } url = "2.5.2" +utoipa = { version = "^5.3", features = ["yaml"] } ockam_multiaddr = { path = "../ockam_multiaddr", version = "0.69.0", features = ["cbor", "serde"] } ockam_transport_core = { path = "../ockam_transport_core", version = "^0.101.0" } diff --git a/implementations/rust/ockam/ockam_api/src/control_api/backend/entrypoint.rs b/implementations/rust/ockam/ockam_api/src/control_api/backend/entrypoint.rs index e01e9d3ec0d..f6f521c837c 100644 --- a/implementations/rust/ockam/ockam_api/src/control_api/backend/entrypoint.rs +++ b/implementations/rust/ockam/ockam_api/src/control_api/backend/entrypoint.rs @@ -1,4 +1,5 @@ -use crate::control_api::{ControlApiHttpRequest, ControlApiHttpResponse, ErrorResponse}; +use crate::control_api::http::{ControlApiHttpRequest, ControlApiHttpResponse}; +use crate::control_api::protocol::common::ErrorResponse; use crate::nodes::NodeManager; use crate::DefaultAddress; use http::{StatusCode, Uri}; diff --git a/implementations/rust/ockam/ockam_api/src/control_api/backend/inlet.rs b/implementations/rust/ockam/ockam_api/src/control_api/backend/inlet.rs index 9c3a0e710b9..5fa558514fc 100644 --- a/implementations/rust/ockam/ockam_api/src/control_api/backend/inlet.rs +++ b/implementations/rust/ockam/ockam_api/src/control_api/backend/inlet.rs @@ -1,13 +1,15 @@ use crate::control_api::backend::entrypoint::HttpControlNodeApiBackend; -use crate::control_api::protocol::inlet::InletStatus; +use crate::control_api::http::ControlApiHttpResponse; use crate::control_api::protocol::inlet::{CreateInletRequest, InletKind, InletTls}; -use crate::control_api::ControlApiHttpResponse; +use crate::control_api::protocol::inlet::{InletStatus, UpdateInletRequest}; +use crate::nodes::NodeManager; use http::StatusCode; -use ockam_abac::PolicyExpression; +use ockam_abac::{Action, Expr, PolicyExpression, ResourceName}; use ockam_core::compat::rand::random_string; use ockam_core::Route; use ockam_multiaddr::MultiAddr; use ockam_node::Context; +use std::sync::Arc; impl HttpControlNodeApiBackend { pub(super) async fn handle_tcp_inlet( @@ -18,171 +20,297 @@ impl HttpControlNodeApiBackend { body: Option>, ) -> ockam_core::Result { match method { - "PUT" => self.handle_tcp_inlet_create(context, body).await, + "PUT" => handle_tcp_inlet_create(context, &self.node_manager, body).await, "GET" => match resource_id { - None => self.handle_tcp_inlet_list().await, - Some(id) => self.handle_tcp_inlet_get(id).await, + None => handle_tcp_inlet_list(&self.node_manager).await, + Some(id) => handle_tcp_inlet_get(&self.node_manager, id).await, + }, + "PATCH" => match resource_id { + None => ControlApiHttpResponse::missing_resource_id(), + Some(id) => handle_tcp_inlet_update(&self.node_manager, id, body).await, }, "DELETE" => match resource_id { None => ControlApiHttpResponse::missing_resource_id(), - Some(id) => self.handle_tcp_inlet_delete(id).await, + Some(id) => handle_tcp_inlet_delete(&self.node_manager, id).await, }, _ => ControlApiHttpResponse::invalid_method(), } } +} - async fn handle_tcp_inlet_create( - &self, - context: &Context, - body: Option>, - ) -> ockam_core::Result { - let request: CreateInletRequest = if let Some(body) = body { - match serde_json::from_slice(&body) { - Ok(request) => request, - Err(_error) => { - warn!("Invalid request body"); - return ControlApiHttpResponse::invalid_body(); - } +#[utoipa::path( + put, + operation_id = "create_tcp_inlet", + summary = "Create a new TCP Inlet", + path = "/{node}/tcp-inlet", + tags = ["portal", "tcp-inlet"], + responses( + (status = CREATED, description = "Successfully created", body = InletStatus), + ), + params( + ("node" = String, description = "Destination node name"), + ), + request_body( + content = CreateInletRequest, + content_type = "application/json", + description = "Creation request" + ) +)] +async fn handle_tcp_inlet_create( + context: &Context, + node_manager: &Arc, + body: Option>, +) -> ockam_core::Result { + let request: CreateInletRequest = if let Some(body) = body { + match serde_json::from_slice(&body) { + Ok(request) => request, + Err(_error) => { + warn!("Invalid request body"); + return ControlApiHttpResponse::invalid_body(); } - } else { - warn!("Missing request body"); - return ControlApiHttpResponse::missing_body(); - }; - - let allow = match request.allow { - None => None, - Some(policy) => Some(PolicyExpression::try_from(policy.as_str())?), - }; + } + } else { + warn!("Missing request body"); + return ControlApiHttpResponse::missing_body(); + }; + + let allow = match request.allow { + None => None, + Some(policy) => Some(PolicyExpression::try_from(policy.as_str())?), + }; + + let enable_udp_puncture; + let disable_tcp_fallback; + let privileged; + + match request.kind { + InletKind::Regular => { + enable_udp_puncture = false; + disable_tcp_fallback = false; + privileged = false; + } + InletKind::UdpPucture => { + enable_udp_puncture = true; + disable_tcp_fallback = false; + privileged = false; + } + InletKind::OnlyUdpPucture => { + enable_udp_puncture = true; + disable_tcp_fallback = true; + privileged = false; + } + InletKind::Privileged => { + enable_udp_puncture = false; + disable_tcp_fallback = false; + privileged = true; + } + InletKind::PrivilegedUdpPuncture => { + enable_udp_puncture = true; + disable_tcp_fallback = false; + privileged = true; + } + InletKind::PrivilegedOnlyUdpPuncture => { + enable_udp_puncture = true; + disable_tcp_fallback = true; + privileged = true; + } + } - let enable_udp_puncture; - let disable_tcp_fallback; - let privileged; + let tls_certificate_provider: Option = match request.tls { + InletTls::None => None, + InletTls::ProjectTls => Some("/project/default/service/tls_certificate_provider".parse()?), + InletTls::CustomTlsProvider { + tls_certificate_provider, + } => Some(tls_certificate_provider.parse()?), + }; + + let authorized = match request.authorized { + None => None, + Some(authorized) => Some(authorized.parse()?), + }; + + let result = node_manager + .create_inlet( + context, + request.from.try_into()?, + Route::default(), + Route::default(), + request.to.parse()?, + request.name.unwrap_or_else(random_string), + allow, + None, + authorized, + false, + None, + enable_udp_puncture, + disable_tcp_fallback, + privileged, + tls_certificate_provider, + ) + .await; + match result { + Ok(status) => { + ControlApiHttpResponse::with_body(StatusCode::CREATED, InletStatus::try_from(status)?) + } + Err(error) => { + // TODO: specialize errors + // name already exists + // port already bound + warn!("Failed to create tcp inlet: {:?}", error); + ControlApiHttpResponse::internal_error(error) + } + } +} - match request.kind { - InletKind::Regular => { - enable_udp_puncture = false; - disable_tcp_fallback = false; - privileged = false; - } - InletKind::UdpPucture => { - enable_udp_puncture = true; - disable_tcp_fallback = false; - privileged = false; - } - InletKind::OnlyUdpPucture => { - enable_udp_puncture = true; - disable_tcp_fallback = true; - privileged = false; - } - InletKind::Privileged => { - enable_udp_puncture = false; - disable_tcp_fallback = false; - privileged = true; - } - InletKind::PrivilegedUdpPuncture => { - enable_udp_puncture = true; - disable_tcp_fallback = false; - privileged = true; - } - InletKind::PrivilegedOnlyUdpPuncture => { - enable_udp_puncture = true; - disable_tcp_fallback = true; - privileged = true; +#[utoipa::path( + patch, + operation_id = "update_tcp_inlet", + summary = "Update a TCP Inlet", + path = "/{node}/tcp-inlet/{resource_id}", + tags = ["portal", "tcp-inlet"], + responses( + (status = OK, description = "Successfully updated", body = InletStatus), + (status = NOT_FOUND, description = "Not found"), + ), + params( + ("node" = String, description = "Destination node name"), + ("resource_id" = String, description = "Resource ID") + ), + request_body( + content = UpdateInletRequest, + content_type = "application/json", + description = "Update request" + ) +)] +async fn handle_tcp_inlet_update( + node_manager: &Arc, + resource_id: &str, + body: Option>, +) -> ockam_core::Result { + let request: UpdateInletRequest = if let Some(body) = body { + match serde_json::from_slice(&body) { + Ok(request) => request, + Err(_error) => { + warn!("Invalid request body"); + return ControlApiHttpResponse::invalid_body(); } } + } else { + warn!("Missing request body"); + return ControlApiHttpResponse::missing_body(); + }; - let tls_certificate_provider: Option = match request.tls { - InletTls::None => None, - InletTls::ProjectTls => { - Some("/project/default/service/tls_certificate_provider".parse()?) - } - InletTls::CustomTlsProvider { - tls_certificate_provider, - } => Some(tls_certificate_provider.parse()?), - }; + if node_manager.show_inlet(resource_id).await.is_none() { + return ControlApiHttpResponse::without_body(StatusCode::NOT_FOUND); + } - let authorized = match request.authorized { - None => None, - Some(authorized) => Some(authorized.parse()?), + if let Some(allow) = request.allow { + let expression = match Expr::try_from(allow.as_str()) { + Ok(allow) => allow, + Err(error) => { + warn!("Invalid policy expression: {:?}", error); + return ControlApiHttpResponse::invalid_body(); + } }; - let result = self - .node_manager - .create_inlet( - context, - request.from.try_into()?, - Route::default(), - Route::default(), - request.to.parse()?, - request.name.unwrap_or_else(random_string), - allow, - None, - authorized, - false, - None, - enable_udp_puncture, - disable_tcp_fallback, - privileged, - tls_certificate_provider, + node_manager + .policies() + .store_policy_for_resource_name( + &ResourceName::new(resource_id), + &Action::HandleMessage, + &expression, ) - .await; - match result { - Ok(status) => ControlApiHttpResponse::with_body( - StatusCode::CREATED, - InletStatus::try_from(status)?, - ), - Err(error) => { - // TODO: specialize errors - // name already exists - // port already bound - warn!("Failed to create tcp inlet: {:?}", error); - ControlApiHttpResponse::internal_error() - } - } + .await?; } - async fn handle_tcp_inlet_list(&self) -> ockam_core::Result { - let mut inlets: Vec = Vec::new(); - - for status in self.node_manager.list_inlets().await { - inlets.push(InletStatus::try_from(status)?); - } + handle_tcp_inlet_get(node_manager, resource_id).await +} - ControlApiHttpResponse::with_body(StatusCode::OK, inlets) +#[utoipa::path( + get, + operation_id = "list_tcp_inlet", + summary = "List all TCP Inlets", + path = "/{node}/tcp-inlet", + tags = ["portal", "tcp-inlet"], + responses( + (status = OK, description = "Successfully listed", body = Vec), + ), + params( + ("node" = String, description = "Destination node name"), + ) +)] +async fn handle_tcp_inlet_list( + node_manager: &Arc, +) -> ockam_core::Result { + let mut inlets: Vec = Vec::new(); + + for status in node_manager.list_inlets().await { + inlets.push(InletStatus::try_from(status)?); } - async fn handle_tcp_inlet_delete( - &self, - resource_id: &str, - ) -> ockam_core::Result { - let result = self.node_manager.delete_inlet(resource_id).await; - match result { - Ok(_) => ControlApiHttpResponse::without_body(StatusCode::NO_CONTENT), - Err(error) => { - warn!("Failed to delete tcp inlet: {:?}", error); - ControlApiHttpResponse::internal_error() - } + ControlApiHttpResponse::with_body(StatusCode::OK, inlets) +} + +#[utoipa::path( + delete, + operation_id = "delete_tcp_inlet", + summary = "Delete a TCP Inlet", + path = "/{node}/tcp-inlet/{resource_id}", + tags = ["portal", "tcp-inlet"], + responses( + (status = NO_CONTENT, description = "Successfully deleted"), + ), + params( + ("node" = String, description = "Destination node name"), + ("resource_id" = String, description = "Resource ID") + ) +)] +async fn handle_tcp_inlet_delete( + node_manager: &Arc, + resource_id: &str, +) -> ockam_core::Result { + let result = node_manager.delete_inlet(resource_id).await; + match result { + Ok(_) => ControlApiHttpResponse::without_body(StatusCode::NO_CONTENT), + Err(error) => { + warn!("Failed to delete tcp inlet: {:?}", error); + ControlApiHttpResponse::internal_error(error) } } +} - async fn handle_tcp_inlet_get( - &self, - resource_id: &str, - ) -> ockam_core::Result { - match self.node_manager.show_inlet(resource_id).await { - None => ControlApiHttpResponse::without_body(StatusCode::NOT_FOUND), - Some(status) => { - ControlApiHttpResponse::with_body(StatusCode::OK, InletStatus::try_from(status)?) - } +#[utoipa::path( + get, + operation_id = "get_tcp_inlet", + summary = "Get a TCP Inlet", + path = "/{node}/tcp-inlet/{resource_id}", + tags = ["portal", "tcp-inlet"], + responses( + (status = OK, description = "Successfully retrieved", body = InletStatus), + (status = NOT_FOUND, description = "Resource not found"), + ), + params( + ("node" = String, description = "Destination node name"), + ("resource_id" = String, description = "Resource ID") + ) +)] +async fn handle_tcp_inlet_get( + node_manager: &Arc, + resource_id: &str, +) -> ockam_core::Result { + match node_manager.show_inlet(resource_id).await { + None => ControlApiHttpResponse::without_body(StatusCode::NOT_FOUND), + Some(status) => { + ControlApiHttpResponse::with_body(StatusCode::OK, InletStatus::try_from(status)?) } } } #[cfg(test)] mod test { + use crate::control_api::http::{ControlApiHttpRequest, ControlApiHttpResponse}; use crate::control_api::protocol::common::HostnamePort; use crate::control_api::protocol::inlet::{ConnectionStatus, CreateInletRequest, InletStatus}; - use crate::control_api::{ControlApiHttpRequest, ControlApiHttpResponse}; use crate::test_utils::start_manager_for_tests; use crate::DefaultAddress; use ockam_core::{Address, NeutralMessage}; diff --git a/implementations/rust/ockam/ockam_api/src/control_api/backend/mod.rs b/implementations/rust/ockam/ockam_api/src/control_api/backend/mod.rs index 6de622b5256..958b84d1f79 100644 --- a/implementations/rust/ockam/ockam_api/src/control_api/backend/mod.rs +++ b/implementations/rust/ockam/ockam_api/src/control_api/backend/mod.rs @@ -1,3 +1,3 @@ mod entrypoint; -mod inlet; -mod outlet; +pub(super) mod inlet; +pub(super) mod outlet; diff --git a/implementations/rust/ockam/ockam_api/src/control_api/backend/outlet.rs b/implementations/rust/ockam/ockam_api/src/control_api/backend/outlet.rs index 244084ac6da..0bbe8405c0c 100644 --- a/implementations/rust/ockam/ockam_api/src/control_api/backend/outlet.rs +++ b/implementations/rust/ockam/ockam_api/src/control_api/backend/outlet.rs @@ -1,14 +1,17 @@ use crate::control_api::backend::entrypoint::HttpControlNodeApiBackend; +use crate::control_api::http::ControlApiHttpResponse; +use crate::control_api::protocol::common::ErrorResponse; use crate::control_api::protocol::outlet::{ - CreateOutletRequest, OutletKind, OutletStatus, OutletTls, + CreateOutletRequest, OutletKind, OutletStatus, OutletTls, UpdateOutletRequest, }; -use crate::control_api::ControlApiHttpResponse; use crate::nodes::models::portal::OutletAccessControl; +use crate::nodes::NodeManager; use http::StatusCode; -use ockam_abac::PolicyExpression; +use ockam_abac::{Action, Expr, PolicyExpression, ResourceName}; use ockam_core::errcode::Kind; use ockam_core::Address; use ockam_node::Context; +use std::sync::Arc; impl HttpControlNodeApiBackend { pub(super) async fn handle_tcp_outlet( @@ -19,123 +22,251 @@ impl HttpControlNodeApiBackend { body: Option>, ) -> ockam_core::Result { match method { - "PUT" => self.handle_tcp_outlet_create(context, body).await, + "PUT" => handle_tcp_outlet_create(context, &self.node_manager, body).await, "GET" => match resource_id { - None => self.handle_tcp_outlet_list().await, - Some(id) => self.handle_tcp_outlet_get(id).await, + None => handle_tcp_outlet_list(&self.node_manager).await, + Some(id) => handle_tcp_outlet_get(&self.node_manager, id).await, + }, + "PATCH" => match resource_id { + None => ControlApiHttpResponse::missing_resource_id(), + Some(id) => handle_tcp_outlet_update(&self.node_manager, id, body).await, }, "DELETE" => match resource_id { None => ControlApiHttpResponse::missing_resource_id(), - Some(id) => self.handle_tcp_outlet_delete(id).await, + Some(id) => handle_tcp_outlet_delete(&self.node_manager, id).await, }, _ => ControlApiHttpResponse::invalid_method(), } } +} - async fn handle_tcp_outlet_create( - &self, - context: &Context, - body: Option>, - ) -> ockam_core::Result { - let request: CreateOutletRequest = if let Some(body) = body { - match serde_json::from_slice(&body) { - Ok(request) => request, - Err(_error) => { - warn!("Invalid request body"); - return ControlApiHttpResponse::invalid_body(); - } +#[utoipa::path( + put, + operation_id = "create_tcp_outlet", + summary = "Create a TCP Outlet", + path = "/{node}/tcp-outlet", + tags = ["portal", "tcp-outlet"], + responses( + (status = CREATED, description = "Successfully created", body = OutletStatus), + (status = CONFLICT, description = "Already exists", body = ErrorResponse), + ), + params( + ("node" = String, description = "Destination node name"), + ), + request_body( + content = CreateOutletRequest, + content_type = "application/json", + description = "Creation request" + ) +)] +async fn handle_tcp_outlet_create( + context: &Context, + node_manager: &Arc, + body: Option>, +) -> ockam_core::Result { + let request: CreateOutletRequest = if let Some(body) = body { + match serde_json::from_slice(&body) { + Ok(request) => request, + Err(_error) => { + warn!("Invalid request body"); + return ControlApiHttpResponse::invalid_body(); } - } else { - warn!("Missing request body"); - return ControlApiHttpResponse::missing_body(); - }; + } + } else { + warn!("Missing request body"); + return ControlApiHttpResponse::missing_body(); + }; + + let allow = OutletAccessControl::WithPolicyExpression(match request.allow { + None => None, + Some(policy) => Some(PolicyExpression::try_from(policy.as_str())?), + }); + + let tls = match request.tls { + OutletTls::None => false, + OutletTls::Validate => true, + }; + + let priviledged = match request.kind { + OutletKind::Regular => false, + OutletKind::Privileged => true, + }; + + let result = node_manager + .create_outlet( + context, + request.to.try_into()?, + tls, + request.address.map(Address::from_string), + true, + allow, + priviledged, + ) + .await; + + match result { + Ok(outlet_status) => ControlApiHttpResponse::with_body( + StatusCode::CREATED, + OutletStatus::from(outlet_status), + ), + Err(error) => match error.code().kind { + Kind::AlreadyExists => ControlApiHttpResponse::with_body( + StatusCode::CONFLICT, + ErrorResponse { + message: error.to_string(), + }, + ), + _ => ControlApiHttpResponse::internal_error(error), + }, + } +} - let allow = OutletAccessControl::WithPolicyExpression(match request.allow { - None => None, - Some(policy) => Some(PolicyExpression::try_from(policy.as_str())?), - }); +#[utoipa::path( + patch, + operation_id = "update_tcp_outlet", + summary = "Update a TCP Outlet", + path = "/{node}/tcp-outlet/{resource_id}", + tags = ["portal", "tcp-outlet"], + responses( + (status = OK, description = "Successfully updated", body = OutletStatus), + (status = NOT_FOUND, description = "Not found"), + ), + params( + ("node" = String, description = "Destination node name"), + ("resource_id" = String, description = "Resource ID") + ), + request_body( + content = UpdateOutletRequest, + content_type = "application/json", + description = "Update request" + ) +)] +async fn handle_tcp_outlet_update( + node_manager: &Arc, + resource_id: &str, + body: Option>, +) -> ockam_core::Result { + let request: UpdateOutletRequest = if let Some(body) = body { + match serde_json::from_slice(&body) { + Ok(request) => request, + Err(_error) => { + warn!("Invalid request body"); + return ControlApiHttpResponse::invalid_body(); + } + } + } else { + warn!("Missing request body"); + return ControlApiHttpResponse::missing_body(); + }; - let tls = match request.tls { - OutletTls::None => false, - OutletTls::Validate => true, - }; + if node_manager.show_outlet(&resource_id.into()).is_none() { + return ControlApiHttpResponse::without_body(StatusCode::NOT_FOUND); + } - let priviledged = match request.kind { - OutletKind::Regular => false, - OutletKind::Privileged => true, + if let Some(allow) = request.allow { + let expression = match Expr::try_from(allow.as_str()) { + Ok(allow) => allow, + Err(error) => { + warn!("Invalid policy expression: {:?}", error); + return ControlApiHttpResponse::invalid_body(); + } }; - let result = self - .node_manager - .create_outlet( - context, - request.to.try_into()?, - tls, - request.address.map(Address::from_string), - true, - allow, - priviledged, + node_manager + .policies() + .store_policy_for_resource_name( + &ResourceName::new(resource_id), + &Action::HandleMessage, + &expression, ) - .await; - - match result { - Ok(outlet_status) => ControlApiHttpResponse::with_body( - StatusCode::CREATED, - OutletStatus::from(outlet_status), - ), - Err(error) => match error.code().kind { - Kind::AlreadyExists => { - ControlApiHttpResponse::with_body(StatusCode::CONFLICT, error.to_string()) - } - _ => ControlApiHttpResponse::with_body( - StatusCode::INTERNAL_SERVER_ERROR, - error.to_string(), - ), - }, - } + .await?; } - async fn handle_tcp_outlet_list(&self) -> ockam_core::Result { - let outlets: Vec = self - .node_manager - .list_outlets() - .into_iter() - .map(OutletStatus::from) - .collect(); - ControlApiHttpResponse::with_body(StatusCode::OK, outlets) - } + handle_tcp_outlet_get(node_manager, resource_id).await +} - async fn handle_tcp_outlet_get( - &self, - resource_id: &str, - ) -> ockam_core::Result { - let result = self - .node_manager - .show_outlet(&Address::from_string(resource_id)); - match result { - None => ControlApiHttpResponse::without_body(StatusCode::NOT_FOUND), - Some(status) => { - ControlApiHttpResponse::with_body(StatusCode::OK, OutletStatus::from(status)) - } +#[utoipa::path( + get, + operation_id = "list_tcp_outlets", + summary = "List all TCP Outlets", + path = "/{node}/tcp-outlet", + tags = ["portal", "tcp-outlet"], + responses( + (status = OK, description = "Successfully listed", body = Vec), + ), + params( + ("node" = String, description = "Destination node name"), + ) +)] +async fn handle_tcp_outlet_list( + node_manager: &Arc, +) -> ockam_core::Result { + let outlets: Vec = node_manager + .list_outlets() + .into_iter() + .map(OutletStatus::from) + .collect(); + ControlApiHttpResponse::with_body(StatusCode::OK, outlets) +} + +#[utoipa::path( + get, + operation_id = "get_tcp_outlet", + summary = "Get a TCP Outlet", + path = "/{node}/tcp-outlet/{resource_id}", + tags = ["portal", "tcp-outlet"], + responses( + (status = OK, description = "Successfully retrieved", body = OutletStatus), + (status = NOT_FOUND, description = "Not found"), + ), + params( + ("node" = String, description = "Destination node name"), + ("resource_id" = String, description = "Outlet address"), + ) +)] +async fn handle_tcp_outlet_get( + node_manager: &Arc, + resource_id: &str, +) -> ockam_core::Result { + let result = node_manager.show_outlet(&Address::from_string(resource_id)); + match result { + None => ControlApiHttpResponse::without_body(StatusCode::NOT_FOUND), + Some(status) => { + ControlApiHttpResponse::with_body(StatusCode::OK, OutletStatus::from(status)) } } +} - async fn handle_tcp_outlet_delete( - &self, - resource_id: &str, - ) -> ockam_core::Result { - self.node_manager - .delete_outlet(&Address::from_string(resource_id)) - .await?; - ControlApiHttpResponse::without_body(StatusCode::NO_CONTENT) - } +#[utoipa::path( + delete, + operation_id = "delete_tcp_outlet", + summary = "Delete a TCP Outlet", + path = "/{node}/tcp-outlet/{resource_id}", + tags = ["portal", "tcp-outlet"], + responses( + (status = NO_CONTENT, description = "Successfully deleted"), + (status = NOT_FOUND, description = "Not found"), + ), + params( + ("node" = String, description = "Destination node name"), + ("resource_id" = String, description = "Outlet address"), + ) +)] +async fn handle_tcp_outlet_delete( + node_manager: &Arc, + resource_id: &str, +) -> ockam_core::Result { + node_manager + .delete_outlet(&Address::from_string(resource_id)) + .await?; + ControlApiHttpResponse::without_body(StatusCode::NO_CONTENT) } #[cfg(test)] mod test { + use crate::control_api::http::{ControlApiHttpRequest, ControlApiHttpResponse}; use crate::control_api::protocol::common::HostnamePort; use crate::control_api::protocol::outlet::{CreateOutletRequest, OutletKind, OutletStatus}; - use crate::control_api::{ControlApiHttpRequest, ControlApiHttpResponse}; use crate::test_utils::start_manager_for_tests; use crate::DefaultAddress; use ockam_core::{Address, NeutralMessage}; diff --git a/implementations/rust/ockam/ockam_api/src/control_api/exporter.rs b/implementations/rust/ockam/ockam_api/src/control_api/exporter.rs new file mode 100644 index 00000000000..65854fe525a --- /dev/null +++ b/implementations/rust/ockam/ockam_api/src/control_api/exporter.rs @@ -0,0 +1,6 @@ +pub fn main() { + let schema = ockam_api::control_api::generate_schema() + .to_yaml() + .expect("Failed to generate schema"); + println!("{}", schema); +} diff --git a/implementations/rust/ockam/ockam_api/src/control_api/frontend.rs b/implementations/rust/ockam/ockam_api/src/control_api/frontend.rs index d961d43c849..dc4d7c75f83 100644 --- a/implementations/rust/ockam/ockam_api/src/control_api/frontend.rs +++ b/implementations/rust/ockam/ockam_api/src/control_api/frontend.rs @@ -1,4 +1,4 @@ -use crate::control_api::{build_error_body, ControlApiHttpRequest, ControlApiHttpResponse}; +use crate::control_api::http::{build_error_body, ControlApiHttpRequest, ControlApiHttpResponse}; use crate::nodes::NodeManager; use crate::DefaultAddress; use http_body_util::{BodyExt, Full}; @@ -411,8 +411,8 @@ impl NodeManager { #[cfg(test)] mod test { use crate::control_api::frontend::NodeResolution; + use crate::control_api::protocol::common::ErrorResponse; use crate::control_api::protocol::inlet::InletStatus; - use crate::control_api::ErrorResponse; use crate::hop::Hop; use crate::test_utils::start_manager_for_tests; use bytes::Bytes; diff --git a/implementations/rust/ockam/ockam_api/src/control_api/http.rs b/implementations/rust/ockam/ockam_api/src/control_api/http.rs new file mode 100644 index 00000000000..657d60ddf1c --- /dev/null +++ b/implementations/rust/ockam/ockam_api/src/control_api/http.rs @@ -0,0 +1,109 @@ +use crate::control_api::protocol::common::ErrorResponse; +use bytes::Bytes; +use http::StatusCode; +use http_body_util::Full; +use minicbor::{CborLen, Decode, Encode}; +use ockam_core::errcode::{Kind, Origin}; +use ockam_core::Error; +use serde::Serialize; +use std::fmt::Display; +use tracing::error; + +#[derive(Debug, Encode, Decode, CborLen)] +#[rustfmt::skip] +pub struct ControlApiHttpRequest { + #[n(0)] pub method: String, + #[n(1)] pub uri: String, + #[n(2)] pub body: Option>, +} + +#[derive(Debug, Encode, Decode, CborLen)] +#[rustfmt::skip] +pub struct ControlApiHttpResponse { + #[n(0)] pub status: u16, + #[n(1)] pub body: Vec, +} + +impl ControlApiHttpResponse { + pub fn with_body( + status_code: StatusCode, + body: T, + ) -> ockam_core::Result { + Ok(Self { + status: status_code.as_u16(), + body: serde_json::to_vec(&body).map_err(|_| { + Error::new( + Origin::Api, + Kind::Internal, + "Failed to encode response body", + ) + })?, + }) + } + + pub fn without_body(status_code: StatusCode) -> ockam_core::Result { + Ok(Self { + status: status_code.as_u16(), + body: Vec::new(), + }) + } + + pub fn invalid_body() -> ockam_core::Result { + Self::with_body( + StatusCode::BAD_REQUEST, + ErrorResponse { + message: "Invalid request body".to_string(), + }, + ) + } + + pub fn missing_body() -> ockam_core::Result { + Self::with_body( + StatusCode::BAD_REQUEST, + ErrorResponse { + message: "Missing request body".to_string(), + }, + ) + } + + pub fn internal_error(error: impl Display) -> ockam_core::Result { + Self::with_body( + StatusCode::INTERNAL_SERVER_ERROR, + ErrorResponse { + message: format!("Internal server error: {error}"), + }, + ) + } + + pub fn invalid_method() -> ockam_core::Result { + Self::with_body( + StatusCode::METHOD_NOT_ALLOWED, + ErrorResponse { + message: "Method not allowed".to_string(), + }, + ) + } + + pub fn missing_resource_id() -> ockam_core::Result { + Self::with_body( + StatusCode::BAD_REQUEST, + ErrorResponse { + message: "Missing resource ID".to_string(), + }, + ) + } +} + +pub fn build_error_body(message: &str) -> Full { + let result = serde_json::to_vec(&ErrorResponse { + message: message.to_string(), + }); + + match result { + Ok(body) => Full::new(Bytes::from(body)), + Err(error) => { + error!("Failed to encode error response body: {error:?}"); + Full::new(Bytes::from("{\"message\": \"Internal server error\"}")) + } + } +} diff --git a/implementations/rust/ockam/ockam_api/src/control_api/mod.rs b/implementations/rust/ockam/ockam_api/src/control_api/mod.rs index e807446efd4..473e37c432f 100644 --- a/implementations/rust/ockam/ockam_api/src/control_api/mod.rs +++ b/implementations/rust/ockam/ockam_api/src/control_api/mod.rs @@ -3,119 +3,10 @@ //! The structures are completely independent, so changes outside this module cannot break //! API compatibility. -use bytes::Bytes; -use http::StatusCode; -use http_body_util::Full; -use minicbor::{CborLen, Decode, Encode}; -use ockam_core::errcode::{Kind, Origin}; -use ockam_core::Error; -use serde::{Deserialize, Serialize}; - pub mod backend; pub mod frontend; - +mod http; +mod openapi; mod protocol; -#[derive(Debug, Encode, Decode, CborLen)] -#[rustfmt::skip] -pub(crate) struct ControlApiHttpRequest { - #[n(0)] pub(super) method: String, - #[n(1)] pub(super) uri: String, - #[n(2)] pub(super) body: Option>, -} - -#[derive(Debug, Encode, Decode, CborLen)] -#[rustfmt::skip] -pub(crate) struct ControlApiHttpResponse { - #[n(0)] pub(super) status: u16, - #[n(1)] pub(super) body: Vec, -} - -impl ControlApiHttpResponse { - fn with_body( - status_code: StatusCode, - body: T, - ) -> ockam_core::Result { - Ok(Self { - status: status_code.as_u16(), - body: serde_json::to_vec(&body).map_err(|_| { - Error::new( - Origin::Api, - Kind::Internal, - "Failed to encode response body", - ) - })?, - }) - } - - fn without_body(status_code: StatusCode) -> ockam_core::Result { - Ok(Self { - status: status_code.as_u16(), - body: Vec::new(), - }) - } - - fn invalid_body() -> ockam_core::Result { - Self::with_body( - StatusCode::BAD_REQUEST, - ErrorResponse { - message: "Invalid request body".to_string(), - }, - ) - } - - fn missing_body() -> ockam_core::Result { - Self::with_body( - StatusCode::BAD_REQUEST, - ErrorResponse { - message: "Missing request body".to_string(), - }, - ) - } - - fn internal_error() -> ockam_core::Result { - Self::with_body( - StatusCode::INTERNAL_SERVER_ERROR, - ErrorResponse { - message: "Internal server error".to_string(), - }, - ) - } - - fn invalid_method() -> ockam_core::Result { - Self::with_body( - StatusCode::METHOD_NOT_ALLOWED, - ErrorResponse { - message: "Method not allowed".to_string(), - }, - ) - } - - pub(crate) fn missing_resource_id() -> ockam_core::Result { - Self::with_body( - StatusCode::BAD_REQUEST, - ErrorResponse { - message: "Missing resource ID".to_string(), - }, - ) - } -} - -#[derive(Debug, Serialize, Deserialize)] -struct ErrorResponse { - message: String, -} - -fn build_error_body(message: &str) -> Full { - let result = serde_json::to_vec(&ErrorResponse { - message: message.to_string(), - }); - - match result { - Ok(body) => Full::new(Bytes::from(body)), - Err(error) => { - error!("Failed to encode error response body: {error:?}"); - Full::new(Bytes::from("{\"message\": \"Internal server error\"}")) - } - } -} +pub use openapi::generate_schema; diff --git a/implementations/rust/ockam/ockam_api/src/control_api/openapi.rs b/implementations/rust/ockam/ockam_api/src/control_api/openapi.rs new file mode 100644 index 00000000000..e65a0cf6529 --- /dev/null +++ b/implementations/rust/ockam/ockam_api/src/control_api/openapi.rs @@ -0,0 +1,56 @@ +use super::backend::inlet::*; +use super::backend::outlet::*; +use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; +use utoipa::{Modify, OpenApi}; + +struct Authentications; + +impl Modify for Authentications { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + if let Some(schema) = openapi.components.as_mut() { + schema.add_security_scheme( + "bearer", + SecurityScheme::Http( + HttpBuilder::new() + .scheme(HttpAuthScheme::Bearer) + .bearer_format("Plaintext") + .build(), + ), + ); + } + } +} + +#[derive(OpenApi)] +#[openapi( + info( + title = "Ockam Control API", + version = "0.1.0", + description = "API to control Ockam nodes", + ), + modifiers(&Authentications), + paths( + handle_tcp_inlet_create, + handle_tcp_inlet_update, + handle_tcp_inlet_list, + handle_tcp_inlet_delete, + handle_tcp_inlet_get, + handle_tcp_outlet_create, + handle_tcp_outlet_update, + handle_tcp_outlet_list, + handle_tcp_outlet_delete, + handle_tcp_outlet_get, + ), + security( + ("bearer" = []) + ), + external_docs( + url = "https://docs.ockam.io/", + description = "Ockam documentation" + ) +)] +struct ApiDoc; + +pub fn generate_schema() -> utoipa::openapi::OpenApi { + ApiDoc::openapi() +} diff --git a/implementations/rust/ockam/ockam_api/src/control_api/protocol/common.rs b/implementations/rust/ockam/ockam_api/src/control_api/protocol/common.rs index 2d9710553ef..c6354a80888 100644 --- a/implementations/rust/ockam/ockam_api/src/control_api/protocol/common.rs +++ b/implementations/rust/ockam/ockam_api/src/control_api/protocol/common.rs @@ -1,7 +1,8 @@ use serde::{Deserialize, Serialize}; use std::str::FromStr; +use utoipa::ToSchema; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct HostnamePort { pub hostname: String, pub port: u16, @@ -25,3 +26,8 @@ impl TryFrom<&str> for HostnamePort { }) } } + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct ErrorResponse { + pub message: String, +} diff --git a/implementations/rust/ockam/ockam_api/src/control_api/protocol/inlet.rs b/implementations/rust/ockam/ockam_api/src/control_api/protocol/inlet.rs index 20bd368b65b..dc5c9693086 100644 --- a/implementations/rust/ockam/ockam_api/src/control_api/protocol/inlet.rs +++ b/implementations/rust/ockam/ockam_api/src/control_api/protocol/inlet.rs @@ -1,5 +1,6 @@ use crate::control_api::protocol::common::HostnamePort; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; fn tcp_inlet_default_bind_address() -> HostnamePort { HostnamePort { @@ -12,9 +13,9 @@ fn retry_wait_default() -> u64 { 20000 } -#[derive(Debug, Serialize, Deserialize, Default)] +#[derive(Debug, Serialize, Deserialize, Default, ToSchema)] #[serde(rename_all = "kebab-case")] -pub(in crate::control_api) enum InletKind { +pub enum InletKind { /// Uses the provided Multiaddress to connect to the Outlet #[default] Regular, @@ -33,9 +34,9 @@ pub(in crate::control_api) enum InletKind { PrivilegedOnlyUdpPuncture, } -#[derive(Debug, Serialize, Deserialize, Default)] +#[derive(Debug, Serialize, Deserialize, Default, ToSchema)] #[serde(rename_all = "kebab-case")] -pub(in crate::control_api) enum InletTls { +pub enum InletTls { #[default] None, ProjectTls, @@ -47,50 +48,57 @@ pub(in crate::control_api) enum InletTls { }, } -#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] -pub(in crate::control_api) enum ConnectionStatus { - #[serde(rename = "up")] +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, ToSchema)] +#[serde(rename_all = "kebab-case")] +pub enum ConnectionStatus { Up, - #[serde(rename = "down")] Down, } -#[derive(Debug, Serialize, Deserialize)] -pub(in crate::control_api) struct CreateInletRequest { +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct CreateInletRequest { /// Name of the TCP Inlet; - /// By default, a random name will be generated + /// Whe omitted, a random name will be generated pub name: Option, /// Kind of the Portal - #[serde(flatten)] + #[serde(default)] pub kind: InletKind, /// TLS Inlet implementation #[serde(default)] + #[schema(default = "none")] pub tls: InletTls, /// Bind address for the TCP Inlet #[serde(default = "tcp_inlet_default_bind_address")] + #[schema(default = tcp_inlet_default_bind_address)] pub from: HostnamePort, /// Multiaddress to a TCP Outlet - /// Example: /project/default/service/forward_to_node1/secure/api/service/outlet + #[schema(example = "/project/default/service/forward_to_node1/secure/api/service/outlet")] pub to: String, /// Identity to be used to create the secure channel; - /// by default, the node's identity will be used + /// When omitted, the node's identity will be used pub identity: Option, /// Restrict access to the TCP Inlet to the provided identity; - /// by default, all identities are allowed; - /// Example: "Id3b788c6a89de8b1f2fd13743eb3123178cf6ec7c9253be8ddcf7e154abe016a" + /// When omitted, all identities are allowed; + #[schema(example = "Id3b788c6a89de8b1f2fd13743eb3123178cf6ec7c9253be8ddcf7e154abe016a")] pub authorized: Option, /// Policy expression that will be used for access control to the TCP Inlet; - /// by default the policy set for the "tcp-inlet" resource type will be used + /// When omitted, the policy set for the "tcp-inlet" resource type will be used pub allow: Option, /// When connection is lost, how long to wait before retrying to connect to the TCP Outlet; /// In milliseconds; #[serde(default = "retry_wait_default")] + #[schema(default = retry_wait_default)] pub retry_wait: u64, } +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct UpdateInletRequest { + /// Policy expression that will be used for access control to the TCP Inlet; + pub allow: Option, +} -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "kebab-case")] -pub(in crate::control_api) struct InletStatus { +pub struct InletStatus { pub name: String, pub status: ConnectionStatus, pub bind_address: HostnamePort, diff --git a/implementations/rust/ockam/ockam_api/src/control_api/protocol/outlet.rs b/implementations/rust/ockam/ockam_api/src/control_api/protocol/outlet.rs index bc4c1c34b62..d93f00b13a5 100644 --- a/implementations/rust/ockam/ockam_api/src/control_api/protocol/outlet.rs +++ b/implementations/rust/ockam/ockam_api/src/control_api/protocol/outlet.rs @@ -1,9 +1,10 @@ use crate::control_api::protocol::common::HostnamePort; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; -#[derive(Debug, Serialize, Deserialize, Default)] +#[derive(Debug, Serialize, Deserialize, Default, ToSchema)] #[serde(rename_all = "kebab-case")] -pub(in crate::control_api) enum OutletKind { +pub enum OutletKind { /// Works as a regular TCP Outlet. It's compatible with UDP Puncture, /// but it must be enabled at node level. #[default] @@ -13,7 +14,7 @@ pub(in crate::control_api) enum OutletKind { Privileged, } -#[derive(Debug, Serialize, Deserialize, Default)] +#[derive(Debug, Serialize, Deserialize, Default, ToSchema)] pub enum OutletTls { #[default] /// No TLS @@ -22,7 +23,7 @@ pub enum OutletTls { Validate, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "kebab-case")] pub struct CreateOutletRequest { /// The kind of the outlet @@ -38,7 +39,13 @@ pub struct CreateOutletRequest { pub allow: Option, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct UpdateOutletRequest { + /// Policy expression that will be used for access control to the TCP Outlet; + pub allow: Option, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "kebab-case")] pub struct OutletStatus { pub to: HostnamePort,