diff --git a/implementations/rust/ockam/ockam_api/Cargo.toml b/implementations/rust/ockam/ockam_api/Cargo.toml index 106d590b692..747a5ca315f 100644 --- a/implementations/rust/ockam/ockam_api/Cargo.toml +++ b/implementations/rust/ockam/ockam_api/Cargo.toml @@ -120,7 +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"] } +utoipa = { version = "^5.3", features = ["yaml", "openapi_extensions"] } 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/authority_member.rs b/implementations/rust/ockam/ockam_api/src/control_api/backend/authority_member.rs index ef9fbfe3568..cabb2aab5e0 100644 --- a/implementations/rust/ockam/ockam_api/src/control_api/backend/authority_member.rs +++ b/implementations/rust/ockam/ockam_api/src/control_api/backend/authority_member.rs @@ -1,15 +1,16 @@ use crate::authenticator::direct::Members; use crate::control_api::backend::common; -use crate::control_api::backend::common::create_authority_client; +use crate::control_api::backend::common::{create_authority_client, ResourceKind}; use crate::control_api::backend::entrypoint::HttpControlNodeApiBackend; use crate::control_api::http::ControlApiHttpResponse; use crate::control_api::protocol::authority_member::{ AddOrUpdateAuthorityMemberRequest, AuthorityMember, GetAuthorityMemberRequest, ListAuthorityMembersRequest, RemoveAuthorityMemberRequest, }; +use crate::control_api::protocol::common::{Attributes, ErrorResponse, NodeName}; use crate::control_api::ControlApiError; use crate::nodes::NodeManager; -use http::StatusCode; +use http::{Method, StatusCode}; use ockam_node::Context; use std::sync::Arc; @@ -17,41 +18,36 @@ impl HttpControlNodeApiBackend { pub(super) async fn handle_authority_member( &self, context: &Context, - method: &str, + method: Method, resource_id: Option<&str>, body: Option>, ) -> Result { - let resource_name = "authority-member"; - let resource_name_identifier = "authority_member_identity"; match method { - "PUT" => match resource_id { - None => ControlApiHttpResponse::missing_resource_id( - resource_name, - resource_name_identifier, - ), + Method::PUT => match resource_id { + None => ControlApiHttpResponse::missing_resource_id(ResourceKind::AuthorityMembers), Some(id) => { handle_authority_member_add_or_update(context, &self.node_manager, body, id) .await } }, - "GET" => match resource_id { + Method::GET => match resource_id { None => handle_authority_member_list(context, &self.node_manager, body).await, Some(id) => { handle_authority_member_get(context, &self.node_manager, body, id).await } }, - "DELETE" => match resource_id { - None => ControlApiHttpResponse::missing_resource_id( - resource_name, - resource_name_identifier, - ), + Method::DELETE => match resource_id { + None => ControlApiHttpResponse::missing_resource_id(ResourceKind::AuthorityMembers), Some(id) => { handle_authority_member_remove(context, &self.node_manager, body, id).await } }, _ => { warn!("Invalid method: {method}"); - ControlApiHttpResponse::invalid_method(method, vec!["PUT", "GET", "DELETE"]) + ControlApiHttpResponse::invalid_method( + method, + vec![Method::PUT, Method::GET, Method::DELETE], + ) } } } @@ -61,13 +57,17 @@ impl HttpControlNodeApiBackend { put, operation_id = "add_or_update_authority_member", summary = "Add or update an Authority Member", - path = "/{node}/authority-member/{member}", - tags = ["authority-member"], + description = +"Add or update an Authority Member with the specified attributes. +Attributes will overwrite the existing ones if the member already exists.", + path = "/{node}/authority-members/{member}", + tags = ["Authority Members"], responses( (status = CREATED, description = "Successfully created"), + (status = NOT_FOUND, description = "Specified project not found", body = ErrorResponse), ), params( - ("node" = String, description = "Destination node name"), + ("node" = NodeName,), ("member" = String, description = "Member identity", example = "Id3b788c6a89de8b1f2fd13743eb3123178cf6ec7c9253be8ddcf7e154abe016a"), ), request_body( @@ -90,7 +90,7 @@ async fn handle_authority_member_add_or_update( create_authority_client(node_manager, &request.authority, &request.identity).await?; let result = authority_client - .add_member(context, member_identity, request.attributes) + .add_member(context, member_identity, request.attributes.0) .await; match result { Ok(_) => Ok(ControlApiHttpResponse::without_body(StatusCode::CREATED)?), @@ -105,18 +105,20 @@ async fn handle_authority_member_add_or_update( get, operation_id = "list_authority_members", summary = "List Authority Members", - path = "/{node}/authority-member", - tags = ["authority-member"], + description = "List all members of the Authority.", + path = "/{node}/authority-members", + tags = ["Authority Members"], responses( (status = OK, description = "Successfully retrieved", body = Vec), + (status = NOT_FOUND, description = "Specified project not found", body = ErrorResponse), ), params( - ("node" = String, description = "Destination node name"), + ("node" = NodeName,), ), request_body( content = ListAuthorityMembersRequest, content_type = "application/json", - description = "Creation request" + description = "Optional list request" ) )] async fn handle_authority_member_list( @@ -136,7 +138,7 @@ async fn handle_authority_member_list( .into_iter() .map(|(identity, attributes_entry)| AuthorityMember { identity: identity.to_string(), - attributes: attributes_entry.string_attributes(), + attributes: Attributes(attributes_entry.string_attributes()), }) .collect(); Ok(ControlApiHttpResponse::with_body(StatusCode::OK, members)?) @@ -152,19 +154,21 @@ async fn handle_authority_member_list( get, operation_id = "get_authority_member", summary = "Get Authority Member", - path = "/{node}/authority-member/{member}", - tags = ["authority-member"], + description = "Get the specified member of the Authority by identity.", + path = "/{node}/authority-members/{member}", + tags = ["Authority Members"], responses( (status = OK, description = "Successfully retrieved", body = AuthorityMember), + (status = NOT_FOUND, description = "Specified project not found", body = ErrorResponse), ), params( - ("node" = String, description = "Destination node name"), + ("node" = NodeName,), ("member" = String, description = "Member identity", example = "Id3b788c6a89de8b1f2fd13743eb3123178cf6ec7c9253be8ddcf7e154abe016a"), ), request_body( content = GetAuthorityMemberRequest, content_type = "application/json", - description = "Get member request" + description = "Optional get member request" ) )] async fn handle_authority_member_get( @@ -188,7 +192,7 @@ async fn handle_authority_member_get( StatusCode::OK, AuthorityMember { identity: member_identity.to_string(), - attributes: attributes_entry.string_attributes(), + attributes: Attributes(attributes_entry.string_attributes()), }, )?), Err(error) => { @@ -203,19 +207,21 @@ async fn handle_authority_member_get( delete, operation_id = "remove_authority_member", summary = "Remove an Authority Member", - path = "/{node}/authority-member/{member}", - tags = ["authority-member"], + description = "Remove the specified member of the Authority by identity.", + path = "/{node}/authority-members/{member}", + tags = ["Authority Members"], responses( (status = OK, description = "Successfully removed"), + (status = NOT_FOUND, description = "Specified project not found", body = ErrorResponse), ), params( - ("node" = String, description = "Destination node name"), + ("node" = NodeName,), ("member" = String, description = "Member identity", example = "Id3b788c6a89de8b1f2fd13743eb3123178cf6ec7c9253be8ddcf7e154abe016a"), ), request_body( content = RemoveAuthorityMemberRequest, content_type = "application/json", - description = "Remove member request" + description = "Optional remove member request" ) )] async fn handle_authority_member_remove( diff --git a/implementations/rust/ockam/ockam_api/src/control_api/backend/common.rs b/implementations/rust/ockam/ockam_api/src/control_api/backend/common.rs index d9f250cd518..49ef3fdb1c0 100644 --- a/implementations/rust/ockam/ockam_api/src/control_api/backend/common.rs +++ b/implementations/rust/ockam/ockam_api/src/control_api/backend/common.rs @@ -1,16 +1,91 @@ use crate::control_api::http::ControlApiHttpResponse; -use crate::control_api::protocol::common::Authority; +use crate::control_api::protocol::common::{Authority, ErrorResponse}; use crate::control_api::ControlApiError; use crate::nodes::NodeManager; use crate::orchestrator::project::Project; use crate::orchestrator::AuthorityNodeClient; +use http::StatusCode; use ockam::identity::Identifier; use ockam_core::errcode::{Kind, Origin}; use ockam_multiaddr::MultiAddr; use serde::de::DeserializeOwned; +use std::fmt::Display; use std::str::FromStr; use std::sync::Arc; +pub(super) enum ResourceKind { + TcpInlets, + TcpOutlets, + Relays, + Tickets, + AuthorityMembers, +} + +impl ResourceKind { + pub fn enumerate() -> Vec { + vec![ + Self::TcpInlets, + Self::TcpOutlets, + Self::Relays, + Self::Tickets, + Self::AuthorityMembers, + ] + } + pub fn from_str(resource: &str) -> Option { + match resource { + "tcp-inlets" => Some(Self::TcpInlets), + "tcp-outlets" => Some(Self::TcpOutlets), + "relays" => Some(Self::Relays), + "tickets" => Some(Self::Tickets), + "authority-members" => Some(Self::AuthorityMembers), + _ => None, + } + } + + pub fn name(&self) -> &'static str { + match self { + Self::TcpInlets => "tcp-inlets", + Self::TcpOutlets => "tcp-outlets", + Self::Relays => "relays", + Self::Tickets => "tickets", + Self::AuthorityMembers => "authority-members", + } + } + + pub fn parameter_name(&self) -> &'static str { + match self { + Self::TcpInlets => "inlet_name", + Self::TcpOutlets => "outlet_address", + Self::Relays => "relay_name", + Self::Tickets => "", + Self::AuthorityMembers => "authority_member", + } + } +} + +impl Display for ResourceKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name()) + } +} + +impl ControlApiHttpResponse { + pub(super) fn missing_resource_id( + resource_kind: ResourceKind, + ) -> Result { + let resource_name = resource_kind.name(); + let resource_name_identifier = resource_kind.parameter_name(); + + Err(Self::with_body( + StatusCode::BAD_REQUEST, + ErrorResponse { + message: format!("Missing parameter {resource_name}. The HTTP path should be /{{node-name}}/{resource_name}/{{{resource_name_identifier}}}"), + }, + )? + .into()) + } +} + pub async fn create_authority_client( node_manager: &Arc, authority: &Authority, @@ -61,7 +136,7 @@ pub async fn create_authority_client( } Err(error) => { warn!("No default project: {error:?}"); - return ControlApiHttpResponse::bad_request("No default project"); + return ControlApiHttpResponse::not_found("Default project not found"); } } } 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 6fe7151d0cc..9e62b53779b 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,9 +1,11 @@ +use crate::control_api::backend::common::ResourceKind; use crate::control_api::http::{ControlApiHttpRequest, ControlApiHttpResponse}; use crate::control_api::protocol::common::ErrorResponse; use crate::control_api::ControlApiError; use crate::nodes::NodeManager; use crate::DefaultAddress; -use http::{StatusCode, Uri}; +use http::{Method, StatusCode, Uri}; +use itertools::Itertools; use ockam_abac::{IncomingAbac, OutgoingAbac, PolicyExpression}; use ockam_core::errcode::Kind; use ockam_core::{ @@ -64,48 +66,56 @@ impl Worker for HttpControlNodeApiBackend { // we can ignore the node identifier since it has been already addressed by // the reverse proxy let _node_identifier = path[1]; + let raw_resource_kind = path[2].to_lowercase(); - let resource_kind = path[2].to_lowercase(); + let resource_kind = ResourceKind::from_str(&raw_resource_kind); let resource_id = path.get(3).copied(); + let method = match Method::try_from(request.method.as_str()) { + Ok(method) => method, + Err(_) => { + warn!("Invalid method: {}", request.method); + return context + .send( + message.return_route().clone(), + NeutralMessage::from(minicbor::to_vec( + &ControlApiHttpResponse::with_body( + StatusCode::BAD_REQUEST, + ErrorResponse { + message: "Invalid method".to_string(), + }, + )?, + )?), + ) + .await; + } + }; - let result: Result = match resource_kind.as_str() { - "tcp-inlet" => { - self.handle_tcp_inlet(context, request.method.as_str(), resource_id, request.body) + let result: Result = match resource_kind { + Some(ResourceKind::TcpInlets) => { + self.handle_tcp_inlet(context, method, resource_id, request.body) .await } - "tcp-outlet" => { - self.handle_tcp_outlet(context, request.method.as_str(), resource_id, request.body) + Some(ResourceKind::TcpOutlets) => { + self.handle_tcp_outlet(context, method, resource_id, request.body) .await } - "relay" => { - self.handle_relay(context, request.method.as_str(), resource_id, request.body) + Some(ResourceKind::Relays) => { + self.handle_relay(context, method, resource_id, request.body) .await } - "ticket" => Ok(self - .handle_ticket(context, request.method.as_str(), resource_id, request.body) + Some(ResourceKind::Tickets) => Ok(self + .handle_ticket(context, method, resource_id, request.body) .await .unwrap()), - "authority-member" => { - self.handle_authority_member( - context, - request.method.as_str(), - resource_id, - request.body, - ) - .await + Some(ResourceKind::AuthorityMembers) => { + self.handle_authority_member(context, method, resource_id, request.body) + .await } - _ => { - warn!("Invalid resource kind: {resource_kind}"); - let valid_resources = [ - "tcp-inlet", - "tcp-outlet", - "relay", - "ticket", - "authority-member", - ]; + None => { + warn!("Invalid resource kind: {raw_resource_kind}"); let message = format!( - "Invalid resource kind: {resource_kind}. Possible: {}", - valid_resources.join(", ") + "Invalid resource kind: {raw_resource_kind}. Possible: {}", + ResourceKind::enumerate().iter().join(", ") ); ControlApiHttpResponse::bad_request(&message) } 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 806ba5e37f5..e121fbc2988 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,11 +1,13 @@ use crate::control_api::backend::common; +use crate::control_api::backend::common::ResourceKind; use crate::control_api::backend::entrypoint::HttpControlNodeApiBackend; use crate::control_api::http::ControlApiHttpResponse; +use crate::control_api::protocol::common::{ErrorResponse, NodeName}; use crate::control_api::protocol::inlet::{CreateInletRequest, InletKind, InletTls}; use crate::control_api::protocol::inlet::{InletStatus, UpdateInletRequest}; use crate::control_api::ControlApiError; use crate::nodes::NodeManager; -use http::StatusCode; +use http::{Method, StatusCode}; use ockam_abac::{Action, Expr, PolicyExpression, ResourceName}; use ockam_core::compat::rand::random_string; use ockam_core::Route; @@ -17,37 +19,29 @@ impl HttpControlNodeApiBackend { pub(super) async fn handle_tcp_inlet( &self, context: &Context, - method: &str, + method: Method, resource_id: Option<&str>, body: Option>, ) -> Result { - let resource_name = "tcp-inlet"; - let resource_name_identifier = "tcp_inlet_name"; match method { - "PUT" => handle_tcp_inlet_create(context, &self.node_manager, body).await, - "GET" => match resource_id { + Method::POST => handle_tcp_inlet_create(context, &self.node_manager, body).await, + Method::GET => match resource_id { 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( - resource_name, - resource_name_identifier, - ), + Method::PATCH => match resource_id { + None => ControlApiHttpResponse::missing_resource_id(ResourceKind::TcpInlets), Some(id) => handle_tcp_inlet_update(&self.node_manager, id, body).await, }, - "DELETE" => match resource_id { - None => ControlApiHttpResponse::missing_resource_id( - resource_name, - resource_name_identifier, - ), + Method::DELETE => match resource_id { + None => ControlApiHttpResponse::missing_resource_id(ResourceKind::TcpInlets), Some(id) => handle_tcp_inlet_delete(&self.node_manager, id).await, }, _ => { warn!("Invalid method: {method}"); ControlApiHttpResponse::invalid_method( method, - vec!["PUT", "GET", "PATCH", "DELETE"], + vec![Method::POST, Method::GET, Method::PATCH, Method::DELETE], ) } } @@ -55,16 +49,21 @@ impl HttpControlNodeApiBackend { } #[utoipa::path( - put, + post, operation_id = "create_tcp_inlet", summary = "Create a new TCP Inlet", - path = "/{node}/tcp-inlet", - tags = ["portal", "tcp-inlet"], + description = +"Create a TCP Inlet, the main parameters are the destination `to`, and the bind address `from`. +You can also choose to listen with a valid TLS certificate, restrict access to the Inlet with +`authorized` and `allow`, and select a specialized Portal with `kind`. +The creation will be asynchronous and the initial status will be `down`.", + path = "/{node}/tcp-inlets", + tags = ["Portals"], responses( (status = CREATED, description = "Successfully created", body = InletStatus), ), params( - ("node" = String, description = "Destination node name"), + ("node" = NodeName,), ), request_body( content = CreateInletRequest, @@ -194,15 +193,19 @@ async fn handle_tcp_inlet_create( patch, operation_id = "update_tcp_inlet", summary = "Update a TCP Inlet", - path = "/{node}/tcp-inlet/{resource_id}", - tags = ["portal", "tcp-inlet"], + description = +"Update the specified TCP Inlet by name. +Currently the only `allow` policy expression can be updated, for more advanced updates it's necessary +to delete the TCP Inlet and create a new one.", + path = "/{node}/tcp-inlets/{tcp_inlet_name}", + tags = ["Portals"], responses( (status = OK, description = "Successfully updated", body = InletStatus), - (status = NOT_FOUND, description = "Not found"), + (status = NOT_FOUND, description = "TCP Inlet not found", body = ErrorResponse), ), params( - ("node" = String, description = "Destination node name"), - ("resource_id" = String, description = "Resource ID") + ("node" = NodeName,), + ("tcp_inlet_name" = String, description = "TCP Inlet name"), ), request_body( content = UpdateInletRequest, @@ -218,7 +221,7 @@ async fn handle_tcp_inlet_update( let request: UpdateInletRequest = common::parse_request_body(body)?; if node_manager.show_inlet(resource_id).await.is_none() { - return ControlApiHttpResponse::not_found("Inlet not found"); + return ControlApiHttpResponse::not_found("TCP Inlet not found"); } if let Some(allow) = request.allow { @@ -247,13 +250,14 @@ async fn handle_tcp_inlet_update( get, operation_id = "list_tcp_inlet", summary = "List all TCP Inlets", - path = "/{node}/tcp-inlet", - tags = ["portal", "tcp-inlet"], + description = "List all TCP Inlets created in the node regardless of their status.", + path = "/{node}/tcp-inlets", + tags = ["Portals"], responses( (status = OK, description = "Successfully listed", body = Vec), ), params( - ("node" = String, description = "Destination node name"), + ("node" = NodeName,), ) )] async fn handle_tcp_inlet_list( @@ -270,14 +274,15 @@ async fn handle_tcp_inlet_list( delete, operation_id = "delete_tcp_inlet", summary = "Delete a TCP Inlet", - path = "/{node}/tcp-inlet/{resource_id}", - tags = ["portal", "tcp-inlet"], + description = "Delete the specified TCP Inlet by name.", + path = "/{node}/tcp-inlets/{tcp_inlet_name}", + tags = ["Portals"], responses( (status = NO_CONTENT, description = "Successfully deleted"), ), params( - ("node" = String, description = "Destination node name"), - ("resource_id" = String, description = "Resource ID") + ("node" = NodeName,), + ("tcp_inlet_name" = String, description = "TCP Inlet name"), ) )] async fn handle_tcp_inlet_delete( @@ -300,15 +305,16 @@ async fn handle_tcp_inlet_delete( get, operation_id = "get_tcp_inlet", summary = "Get a TCP Inlet", - path = "/{node}/tcp-inlet/{resource_id}", - tags = ["portal", "tcp-inlet"], + description = "Get the specified TCP Inlet by name", + path = "/{node}/tcp-inlets/{tcp_inlet_name}", + tags = ["Portals"], responses( (status = OK, description = "Successfully retrieved", body = InletStatus), - (status = NOT_FOUND, description = "Resource not found"), + (status = NOT_FOUND, description = "TCP Inlet not found", body = ErrorResponse), ), params( - ("node" = String, description = "Destination node name"), - ("resource_id" = String, description = "Resource ID") + ("node" = NodeName,), + ("tcp_inlet_name" = String, description = "TCP Inlet name") ) )] async fn handle_tcp_inlet_get( @@ -345,8 +351,8 @@ mod test { .create_control_api_backend(context, None)?; let request = ControlApiHttpRequest { - method: "PUT".to_string(), - uri: "/node-name/tcp-inlet".to_string(), + method: "POST".to_string(), + uri: "/node-name/tcp-inlets".to_string(), body: Some( serde_json::to_vec(&CreateInletRequest { name: Some("inlet-name".to_string()), @@ -380,7 +386,6 @@ mod test { assert_eq!(inlet_status.status, ConnectionStatus::Down); assert_eq!(inlet_status.current_route, None); assert_eq!(inlet_status.to, "/service/outlet"); - assert!(!inlet_status.privileged); assert_eq!(inlet_status.bind_address.hostname, "127.0.0.1"); assert!(inlet_status.bind_address.port > 0); @@ -388,7 +393,7 @@ mod test { let request = ControlApiHttpRequest { method: "GET".to_string(), - uri: "/node-name/tcp-inlet/inlet-name".to_string(), + uri: "/node-name/tcp-inlets/inlet-name".to_string(), body: None, }; @@ -405,11 +410,10 @@ mod test { assert_eq!(inlet_status.status, ConnectionStatus::Up); assert_eq!(inlet_status.current_route, Some("0#outlet".to_string())); assert_eq!(inlet_status.to, "/service/outlet"); - assert!(!inlet_status.privileged); let request = ControlApiHttpRequest { method: "GET".to_string(), - uri: "/node-name/tcp-inlet".to_string(), + uri: "/node-name/tcp-inlets".to_string(), body: None, }; @@ -427,11 +431,10 @@ mod test { assert_eq!(inlets[0].status, ConnectionStatus::Up); assert_eq!(inlets[0].current_route, Some("0#outlet".to_string())); assert_eq!(inlets[0].to, "/service/outlet"); - assert!(!inlets[0].privileged); let request = ControlApiHttpRequest { method: "DELETE".to_string(), - uri: "/node-name/tcp-inlet/inlet-name".to_string(), + uri: "/node-name/tcp-inlets/inlet-name".to_string(), body: None, }; @@ -446,7 +449,7 @@ mod test { let request = ControlApiHttpRequest { method: "GET".to_string(), - uri: "/node-name/tcp-inlet/inlet-name".to_string(), + uri: "/node-name/tcp-inlets/inlet-name".to_string(), body: None, }; @@ -462,7 +465,7 @@ mod test { let request = ControlApiHttpRequest { method: "GET".to_string(), - uri: "/node-name/tcp-inlet".to_string(), + uri: "/node-name/tcp-inlets".to_string(), body: None, }; 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 440c832cd3e..d88ccbd047a 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,15 @@ use crate::control_api::backend::common; +use crate::control_api::backend::common::ResourceKind; 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::common::{ErrorResponse, NodeName}; use crate::control_api::protocol::outlet::{ CreateOutletRequest, OutletKind, OutletStatus, OutletTls, UpdateOutletRequest, }; use crate::control_api::ControlApiError; use crate::nodes::models::portal::OutletAccessControl; use crate::nodes::NodeManager; -use http::StatusCode; +use http::{Method, StatusCode}; use ockam_abac::{Action, Expr, PolicyExpression, ResourceName}; use ockam_core::errcode::Kind; use ockam_core::Address; @@ -19,37 +20,29 @@ impl HttpControlNodeApiBackend { pub(super) async fn handle_tcp_outlet( &self, context: &Context, - method: &str, + method: Method, resource_id: Option<&str>, body: Option>, ) -> Result { - let resource_name = "tcp-outlet"; - let resource_name_identifier = "tcp_outlet_name"; match method { - "PUT" => handle_tcp_outlet_create(context, &self.node_manager, body).await, - "GET" => match resource_id { + Method::POST => handle_tcp_outlet_create(context, &self.node_manager, body).await, + Method::GET => match resource_id { 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( - resource_name, - resource_name_identifier, - ), + Method::PATCH => match resource_id { + None => ControlApiHttpResponse::missing_resource_id(ResourceKind::TcpOutlets), Some(id) => handle_tcp_outlet_update(&self.node_manager, id, body).await, }, - "DELETE" => match resource_id { - None => ControlApiHttpResponse::missing_resource_id( - resource_name, - resource_name_identifier, - ), + Method::DELETE => match resource_id { + None => ControlApiHttpResponse::missing_resource_id(ResourceKind::TcpOutlets), Some(id) => handle_tcp_outlet_delete(&self.node_manager, id).await, }, _ => { warn!("Invalid method: {method}"); ControlApiHttpResponse::invalid_method( method, - vec!["PUT", "GET", "PATCH", "DELETE"], + vec![Method::POST, Method::GET, Method::PATCH, Method::DELETE], ) } } @@ -57,17 +50,23 @@ impl HttpControlNodeApiBackend { } #[utoipa::path( - put, + post, operation_id = "create_tcp_outlet", summary = "Create a TCP Outlet", - path = "/{node}/tcp-outlet", - tags = ["portal", "tcp-outlet"], + description = +"Create a new TCP Outlet, the main parameter are the destination `to`, and the worker address +`address` which is used to identify the outlet within the node. +The `kind` parameter can be used to create a special outlet, and the `tls` parameter can be used to +connect to TLS endpoints. +The creation will be synchronous, without any blocking operation.", + path = "/{node}/tcp-outlets", + tags = ["Portals"], responses( (status = CREATED, description = "Successfully created", body = OutletStatus), (status = CONFLICT, description = "Already exists", body = ErrorResponse), ), params( - ("node" = String, description = "Destination node name"), + ("node" = NodeName,), ), request_body( content = CreateOutletRequest, @@ -133,15 +132,19 @@ async fn handle_tcp_outlet_create( patch, operation_id = "update_tcp_outlet", summary = "Update a TCP Outlet", - path = "/{node}/tcp-outlet/{resource_id}", - tags = ["portal", "tcp-outlet"], + description = +"Update the specified TCP Outlet by address. +Currently only `allow` policy expression can be updated, for more advanced updates it's necessary +to delete the TCP Outlet and create a new one.", + path = "/{node}/tcp-outlets/{tcp_outlet_address}", + tags = ["Portals"], responses( (status = OK, description = "Successfully updated", body = OutletStatus), - (status = NOT_FOUND, description = "Not found"), + (status = NOT_FOUND, description = "TCP Outlet not found", body = ErrorResponse), ), params( - ("node" = String, description = "Destination node name"), - ("resource_id" = String, description = "Resource ID") + ("node" = NodeName,), + ("tcp_outlet_address" = String, description = "TCP Outlet address"), ), request_body( content = UpdateOutletRequest, @@ -186,8 +189,9 @@ async fn handle_tcp_outlet_update( get, operation_id = "list_tcp_outlets", summary = "List all TCP Outlets", - path = "/{node}/tcp-outlet", - tags = ["portal", "tcp-outlet"], + description = "List all TCP Outlets created in the node.", + path = "/{node}/tcp-outlets", + tags = ["Portals"], responses( (status = OK, description = "Successfully listed", body = Vec), ), @@ -210,15 +214,16 @@ async fn handle_tcp_outlet_list( get, operation_id = "get_tcp_outlet", summary = "Get a TCP Outlet", - path = "/{node}/tcp-outlet/{resource_id}", - tags = ["portal", "tcp-outlet"], + description = "Get the specified TCP Outlet by address.", + path = "/{node}/tcp-outlets/{tcp_outlet_address}", + tags = ["Portals"], responses( (status = OK, description = "Successfully retrieved", body = OutletStatus), - (status = NOT_FOUND, description = "Not found"), + (status = NOT_FOUND, description = "TCP Outlet not found", body = ErrorResponse), ), params( - ("node" = String, description = "Destination node name"), - ("resource_id" = String, description = "Outlet address"), + ("node" = NodeName,), + ("tcp_outlet_address" = String, description = "TCP Outlet address"), ) )] async fn handle_tcp_outlet_get( @@ -239,15 +244,16 @@ async fn handle_tcp_outlet_get( delete, operation_id = "delete_tcp_outlet", summary = "Delete a TCP Outlet", - path = "/{node}/tcp-outlet/{resource_id}", - tags = ["portal", "tcp-outlet"], + description = "Delete the specified TCP Outlet by address.", + path = "/{node}/tcp-outlets/{tcp_outlet_address}", + tags = ["Portals"], responses( (status = NO_CONTENT, description = "Successfully deleted"), - (status = NOT_FOUND, description = "Not found"), + (status = NOT_FOUND, description = "TCP Outlet not found", body = ErrorResponse), ), params( - ("node" = String, description = "Destination node name"), - ("resource_id" = String, description = "Outlet address"), + ("node" = NodeName,), + ("tcp_outlet_address" = String, description = "TCP Outlet address"), ) )] async fn handle_tcp_outlet_delete( @@ -284,8 +290,8 @@ mod test { .create_control_api_backend(context, None)?; let request = ControlApiHttpRequest { - method: "PUT".to_string(), - uri: "/node-name/tcp-outlet".to_string(), + method: "POST".to_string(), + uri: "/node-name/tcp-outlets".to_string(), body: Some( serde_json::to_vec(&CreateOutletRequest { kind: OutletKind::Regular, @@ -317,7 +323,7 @@ mod test { let request = ControlApiHttpRequest { method: "GET".to_string(), - uri: "/node-name/tcp-outlet/outlet-address".to_string(), + uri: "/node-name/tcp-outlets/outlet-address".to_string(), body: None, }; @@ -337,7 +343,7 @@ mod test { let request = ControlApiHttpRequest { method: "GET".to_string(), - uri: "/node-name/tcp-outlet".to_string(), + uri: "/node-name/tcp-outlets".to_string(), body: None, }; @@ -358,7 +364,7 @@ mod test { let request = ControlApiHttpRequest { method: "DELETE".to_string(), - uri: "/node-name/tcp-outlet/outlet-address".to_string(), + uri: "/node-name/tcp-outlets/outlet-address".to_string(), body: None, }; @@ -373,7 +379,7 @@ mod test { let request = ControlApiHttpRequest { method: "GET".to_string(), - uri: "/node-name/tcp-outlet/outlet-address".to_string(), + uri: "/node-name/tcp-outlets/outlet-address".to_string(), body: None, }; @@ -389,7 +395,7 @@ mod test { let request = ControlApiHttpRequest { method: "GET".to_string(), - uri: "/node-name/tcp-outlet".to_string(), + uri: "/node-name/tcp-outlets".to_string(), body: None, }; diff --git a/implementations/rust/ockam/ockam_api/src/control_api/backend/relay.rs b/implementations/rust/ockam/ockam_api/src/control_api/backend/relay.rs index 19e3f8dd48e..fab66bebc09 100644 --- a/implementations/rust/ockam/ockam_api/src/control_api/backend/relay.rs +++ b/implementations/rust/ockam/ockam_api/src/control_api/backend/relay.rs @@ -1,11 +1,13 @@ use crate::control_api::backend::common; +use crate::control_api::backend::common::ResourceKind; use crate::control_api::backend::entrypoint::HttpControlNodeApiBackend; use crate::control_api::http::ControlApiHttpResponse; +use crate::control_api::protocol::common::{ErrorResponse, NodeName}; use crate::control_api::protocol::relay::{CreateRelayRequest, RelayStatus}; use crate::control_api::ControlApiError; use crate::nodes::models::relay::ReturnTiming; use crate::nodes::NodeManager; -use http::StatusCode; +use http::{Method, StatusCode}; use ockam::identity::Identifier; use ockam_core::compat::rand::random_string; use ockam_multiaddr::MultiAddr; @@ -16,44 +18,49 @@ impl HttpControlNodeApiBackend { pub(super) async fn handle_relay( &self, context: &Context, - method: &str, + method: Method, resource_id: Option<&str>, body: Option>, ) -> Result { - let resource_name = "tcp-outlet"; - let resource_name_identifier = "tcp_outlet_name"; match method { - "PUT" => handle_relay_create(context, &self.node_manager, body).await, - "GET" => match resource_id { + Method::POST => handle_relay_create(context, &self.node_manager, body).await, + Method::GET => match resource_id { None => handle_relay_list(&self.node_manager).await, Some(id) => handle_relay_get(&self.node_manager, id).await, }, - "DELETE" => match resource_id { - None => ControlApiHttpResponse::missing_resource_id( - resource_name, - resource_name_identifier, - ), + Method::DELETE => match resource_id { + None => ControlApiHttpResponse::missing_resource_id(ResourceKind::Relays), Some(id) => handle_relay_delete(&self.node_manager, id).await, }, _ => { warn!("Invalid method: {method}"); - ControlApiHttpResponse::invalid_method(method, vec!["PUT", "GET", "DELETE"]) + ControlApiHttpResponse::invalid_method( + method, + vec![Method::POST, Method::GET, Method::DELETE], + ) } } } } #[utoipa::path( - put, + post, operation_id = "create_relay", summary = "Create a new Relay", - path = "/{node}/relay", - tags = ["relay"], + description = +"Create a new Relay, the main parameters are the destination node `to` and the `name`. +The address inherits the name value, but it's possible to specify it to allow the creation +of multiple relays with the same address to different nodes. +The creation will be asynchronous and the initial status will be `down`. +Note that, to allow the relay creation in the destination node, the caller identity credential must +have an `ockam-relay` attribute set with the relay name.", + path = "/{node}/relays", + tags = ["Relays"], responses( (status = CREATED, description = "Successfully created", body = RelayStatus), ), params( - ("node" = String, description = "Destination node name"), + ("node" = NodeName,), ), request_body( content = CreateRelayRequest, @@ -119,13 +126,14 @@ async fn handle_relay_create( get, operation_id = "list_relay", summary = "List all Relays", - path = "/{node}/relay", - tags = ["relay"], + description = "List all Relays created in the node regardless of their status.", + path = "/{node}/relays", + tags = ["Relays"], responses( (status = OK, description = "Successfully listed", body = Vec), ), params( - ("node" = String, description = "Destination node name"), + ("node" = NodeName,), ) )] async fn handle_relay_list( @@ -144,14 +152,15 @@ async fn handle_relay_list( delete, operation_id = "delete_relay", summary = "Delete a Relay", - path = "/{node}/relay/{resource_id}", - tags = ["relay"], + description = "Delete the specified Relay by name.", + path = "/{node}/relays/{relay_name}", + tags = ["Relays"], responses( (status = NO_CONTENT, description = "Successfully deleted"), ), params( - ("node" = String, description = "Destination node name"), - ("resource_id" = String, description = "Resource ID") + ("node" = NodeName,), + ("relay_name" = String, description = "Relay name"), ) )] async fn handle_relay_delete( @@ -174,15 +183,16 @@ async fn handle_relay_delete( get, operation_id = "get_relay", summary = "Get a Relay", - path = "/{node}/relay/{resource_id}", - tags = ["relay"], + description = "Get the specified Relay by name.", + path = "/{node}/relays/{relay_name}", + tags = ["Relays"], responses( (status = OK, description = "Successfully retrieved", body = RelayStatus), - (status = NOT_FOUND, description = "Resource not found"), + (status = NOT_FOUND, description = "Relay not found", body = ErrorResponse), ), params( - ("node" = String, description = "Destination node name"), - ("resource_id" = String, description = "Resource ID") + ("node" = NodeName,), + ("relay_name" = String, description = "Relay name"), ) )] async fn handle_relay_get( diff --git a/implementations/rust/ockam/ockam_api/src/control_api/backend/ticket.rs b/implementations/rust/ockam/ockam_api/src/control_api/backend/ticket.rs index 673405aa47a..39e4d416c6a 100644 --- a/implementations/rust/ockam/ockam_api/src/control_api/backend/ticket.rs +++ b/implementations/rust/ockam/ockam_api/src/control_api/backend/ticket.rs @@ -2,18 +2,20 @@ use crate::authenticator::enrollment_tokens::TokenIssuer; use crate::authenticator::one_time_code::OneTimeCode; use crate::cli_state::{ExportedEnrollmentTicket, ProjectRoute}; use crate::control_api::backend::common; -use crate::control_api::backend::common::{create_authority_client, parse_identifier}; +use crate::control_api::backend::common::{ + create_authority_client, parse_identifier, ResourceKind, +}; use crate::control_api::backend::entrypoint::HttpControlNodeApiBackend; use crate::control_api::http::ControlApiHttpResponse; -use crate::control_api::protocol::common::{ErrorResponse, HostnamePort, Project}; +use crate::control_api::protocol::common::{ErrorResponse, HostnamePort, NodeName, Project}; use crate::control_api::protocol::ticket::{ - AuthorityInformation, CreateTicketRequest, EnrollTicketRequest, Ticket, + AuthorityInformation, CreateTicketRequest, EnrollProjectRequest, Ticket, }; use crate::control_api::ControlApiError; use crate::enroll::enrollment::{EnrollStatus, Enrollment}; use crate::nodes::NodeManager; use crate::orchestrator::HasSecureClient; -use http::StatusCode; +use http::{Method, StatusCode}; use ockam::identity::{Identity, Vault}; use ockam_core::errcode::{Kind, Origin}; use ockam_node::Context; @@ -25,37 +27,59 @@ impl HttpControlNodeApiBackend { pub(super) async fn handle_ticket( &self, context: &Context, - method: &str, - _resource_id: Option<&str>, + method: Method, + resource_id: Option<&str>, body: Option>, ) -> Result { + let resource_name = ResourceKind::Tickets.name(); match method { - "PUT" => handle_ticket_create(context, &self.node_manager, body).await, - "POST" => handle_ticket_enroll(context, &self.node_manager, body).await, + Method::POST => { + if let Some(resource_id) = resource_id { + if resource_id == "enroll" { + handle_ticket_enroll(context, &self.node_manager, body).await + } else { + ControlApiHttpResponse::bad_request( + &format!("The HTTP path should be /{{node-name}}/{resource_name} or /{{node-name}}/{resource_name}/enroll") + ) + } + } else { + handle_ticket_create(context, &self.node_manager, body).await + } + } _ => { warn!("Invalid method: {method}"); - ControlApiHttpResponse::invalid_method(method, vec!["PUT", "POST"]) + ControlApiHttpResponse::invalid_method(method, vec![Method::POST]) } } } } #[utoipa::path( - put, + post, operation_id = "create_ticket", summary = "Create a new Ticket", - path = "/{node}/ticket", - tags = ["ticket"], + description = +"Create a new Ticket, the main parameters are `attributes`, the list of attributes associated +with the ticket, and `project`, the target project for the ticket. In the vast majority of cases, +specifying the project name will be enough, but it's also possible to specify a custom Ockam +Authority and a custom node acting as a Project. +You can also limit the validity of the ticket by specifying the `usage_count` and `expires_in` +fields. +The ticket is returned as an opaque string that can be used to enroll to a project, either via API +or via CLI with the `ockam project enroll` command.", + path = "/{node}/tickets", + tags = ["Tickets"], responses( (status = CREATED, description = "Successfully created", body = Ticket), + (status = NOT_FOUND, description = "Specified project not found", body = ErrorResponse), ), params( - ("node" = String, description = "Destination node name"), + ("node" = NodeName,), ), request_body( content = CreateTicketRequest, content_type = "application/json", - description = "Creation request" + description = "Create Ticket request" ) )] async fn handle_ticket_create( @@ -75,7 +99,7 @@ async fn handle_ticket_create( let result = authority_client .create_token( context, - request.attributes, + request.attributes.0, Some(Duration::from_secs(request.expires_in)), Some(request.usage_count), ) @@ -197,22 +221,27 @@ async fn create_encoded_ticket( #[utoipa::path( post, - operation_id = "enroll_ticket", - summary = "Enroll a Ticket", - path = "/{node}/ticket", - tags = ["ticket"], + operation_id = "project_enroll", + summary = "Enroll to a Project using a Ticket", + description = +"This API enrolls a node to a Project using the provided Ticket. +Note that this API imports the Project in the node database, but the node won't be able to use +it until it restarts. +The easiest way to use a ticket is to specify the ticket directly during the node creation.", + path = "/{node}/tickets/enroll", + tags = ["Tickets"], responses( (status = CREATED, description = "Successfully enrolled, new credential can be used right away", body = AuthorityInformation), (status = OK, description = "The node was already enrolled, no change in state", body = AuthorityInformation), (status = ACCEPTED, description = "Enrolled, but the node needs a restart", body = AuthorityInformation), ), params( - ("node" = String, description = "Destination node name"), + ("node" = NodeName,), ), request_body( - content = EnrollTicketRequest, + content = EnrollProjectRequest, content_type = "application/json", - description = "Enrollment request" + description = "Project enrollment request" ) )] async fn handle_ticket_enroll( @@ -220,7 +249,7 @@ async fn handle_ticket_enroll( node_manager: &Arc, body: Option>, ) -> Result { - let request: EnrollTicketRequest = common::parse_request_body(body)?; + let request: EnrollProjectRequest = common::parse_request_body(body)?; let caller_identifier = if let Some(identity) = request.identity { parse_identifier(&identity, "identity to enroll")? 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 08ac0e3a67e..f59452121c0 100644 --- a/implementations/rust/ockam/ockam_api/src/control_api/frontend.rs +++ b/implementations/rust/ockam/ockam_api/src/control_api/frontend.rs @@ -489,7 +489,7 @@ mod test { .await?; let response: Response = send_http_request( - Request::get(format!("http://{bind_address}/node1/tcp-inlet")) + Request::get(format!("http://{bind_address}/node1/tcp-inlets")) .body(()) .unwrap(), ) @@ -499,7 +499,7 @@ mod test { assert_eq!(response.body().message, "Missing authentication token"); let response: Response = send_http_request( - Request::get(format!("http://{bind_address}/node1/tcp-inlet")) + Request::get(format!("http://{bind_address}/node1/tcp-inlets")) .header("Authorization", "invalid_token") .body(()) .unwrap(), @@ -510,7 +510,7 @@ mod test { assert_eq!(response.body().message, "Invalid authentication token"); let response: Response = send_http_request( - Request::get(format!("http://{bind_address}/node1/tcp-inlet")) + Request::get(format!("http://{bind_address}/node1/tcp-inlets")) .header("Authorization", "Bearer invalid_token") .body(()) .unwrap(), @@ -521,7 +521,7 @@ mod test { assert_eq!(response.body().message, "Invalid authentication token"); let response: Response = send_http_request( - Request::get(format!("http://{bind_address}/node1/tcp-inlet")) + Request::get(format!("http://{bind_address}/node1/tcp-inlets")) .header("Authorization", "Bearer token") .body(()) .unwrap(), @@ -531,7 +531,7 @@ mod test { assert_eq!(response.status().as_u16(), 502); let response: Response = send_http_request( - Request::get(format!("http://{bind_address}/node1/tcp-inlet")) + Request::get(format!("http://{bind_address}/node1/tcp-inlets")) .header("Authorization", "token") .body(()) .unwrap(), @@ -565,7 +565,7 @@ mod test { context.start_worker("forward_to_node1", Hop)?; let response: Response> = send_http_request( - Request::get(format!("http://{bind_address}/node1/tcp-inlet")) + Request::get(format!("http://{bind_address}/node1/tcp-inlets")) .header("Authorization", "Bearer token") .body(()) .unwrap(), @@ -600,7 +600,7 @@ mod test { let response: Response = send_http_request( Request::get(format!( - "http://{bind_address}/non-existing-node/tcp-inlet/" + "http://{bind_address}/non-existing-node/tcp-inlets/" )) .header("Authorization", "Bearer token") .body(()) @@ -639,7 +639,7 @@ mod test { .create_control_api_backend(context, None)?; let response: Response> = send_http_request( - Request::get(format!("http://{bind_address}/node1/tcp-inlet")) + Request::get(format!("http://{bind_address}/node1/tcp-inlets")) .header("Authorization", "Bearer token") .body(()) .unwrap(), @@ -676,10 +676,12 @@ mod test { .create_control_api_backend(context, None)?; let response: Response = send_http_request( - Request::get(format!("http://{bind_address}/non-existing-node/tcp-inlet")) - .header("Authorization", "Bearer token") - .body(()) - .unwrap(), + Request::get(format!( + "http://{bind_address}/non-existing-node/tcp-inlets" + )) + .header("Authorization", "Bearer token") + .body(()) + .unwrap(), ) .await; diff --git a/implementations/rust/ockam/ockam_api/src/control_api/http.rs b/implementations/rust/ockam/ockam_api/src/control_api/http.rs index 757254ca943..3915d7f9966 100644 --- a/implementations/rust/ockam/ockam_api/src/control_api/http.rs +++ b/implementations/rust/ockam/ockam_api/src/control_api/http.rs @@ -1,7 +1,7 @@ use crate::control_api::protocol::common::ErrorResponse; use crate::control_api::ControlApiError; use bytes::Bytes; -use http::StatusCode; +use http::{Method, StatusCode}; use http_body_util::Full; use minicbor::{CborLen, Decode, Encode}; use ockam_core::errcode::{Kind, Origin}; @@ -98,33 +98,24 @@ impl ControlApiHttpResponse { } pub fn invalid_method( - method: &str, - allowed_methods: Vec<&str>, + method: Method, + allowed_methods: Vec, ) -> Result { Err(Self::with_body( StatusCode::METHOD_NOT_ALLOWED, ErrorResponse { message: format!( "Invalid method {method} for this API. Supported methods are: {}", - allowed_methods.join(", ") + allowed_methods + .iter() + .map(|m| m.as_str()) + .collect::>() + .join(", ") ), }, )? .into()) } - - pub fn missing_resource_id( - resource_name: &str, - resource_name_identifier: &str, - ) -> Result { - Err(Self::with_body( - StatusCode::BAD_REQUEST, - ErrorResponse { - message: format!("Missing parameter {resource_name}. The HTTP path should be /{{node-name}}/{resource_name}/{{{resource_name_identifier}}}"), - }, - )? - .into()) - } } pub fn build_error_body(message: &str) -> Full { diff --git a/implementations/rust/ockam/ockam_api/src/control_api/openapi.rs b/implementations/rust/ockam/ockam_api/src/control_api/openapi.rs index e1f782cb63b..bd56d70647b 100644 --- a/implementations/rust/ockam/ockam_api/src/control_api/openapi.rs +++ b/implementations/rust/ockam/ockam_api/src/control_api/openapi.rs @@ -3,6 +3,7 @@ use super::backend::inlet::*; use super::backend::outlet::*; use super::backend::relay::*; use super::backend::ticket::*; +use crate::control_api::protocol::common::NodeName; use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; use utoipa::{Modify, OpenApi}; @@ -12,9 +13,10 @@ 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", + "token", SecurityScheme::Http( HttpBuilder::new() + .description(Some("A simple token authentication, this token can be set as startup parameter")) .scheme(HttpAuthScheme::Bearer) .bearer_format("Plaintext") .build(), @@ -24,12 +26,94 @@ impl Modify for Authentications { } } +const MAIN_DESCRIPTION: &str = r#" +## Overview +This API is designed to control Ockam nodes via HTTP requests without having to use Ockam command or Ockam library. +You can expect a similar abstraction level as Ockam command, but with a more programmatic approach. + +The endpoint will receive the HTTP request and forward it to the selected node using the `node` +parameter. How the node will be selected starting from the name is configuration dependent. + +## Versioning +Current major `0.Y.Z` is considered unstable and may have breaking changes. +Future versions will follow semantic versioning, breaking changes will be reflected in the major version number. + +## Authentication +Only a simple bearer token is required to authenticate. The token is provided by the user in the +configuration or via environment variable, it's then passed in the `Authorization` header: + `Authorization: Bearer my-secret-token`. + +## Getting Started +The easiest way to get development started is to use this API with the Ockam command and run a single node acting both as a frontend and backend. +```sh +ockam node create --foreground -vv --launch-configuration '{ + "start_default_services": true, + "startup_services": { + "control_api": { + "authentication_token": "my-secret-token", + "backend": true, + "frontend": true, + "http_bind_address": "127.0.0.1:8080" + } + } +}' +``` + +When an undocumented error is returned, such as internal server error, a message contained by `ErrorResponse` is usually present for debugging purposes. +It's highly recommended to check the logs of the Ockam nodes for more information. +"#; + #[derive(OpenApi)] #[openapi( info( - title = "Ockam Control API", + title = "Ockam Node Control API", version = "0.1.0", - description = "API to control Ockam nodes", + description = MAIN_DESCRIPTION, + ), + tags(( + name = "Portals", + external_docs( + url = "https://docs.ockam.io/reference/command/advanced-routing#portal", + description = "Learn more about Portals on Ockam command documentation" + ), + description = +" +A TCP Inlet and TCP Outlet together form a Portal, working hand in hand with Relays. A TCP Inlet +defines where a node, running on another machine, listens for connections. The Inlet's route +provides information on how to forward traffic to the Outlet (its address). Relays allow you to +establish end-to-end protocols with services that operate in remote private networks. +" + ),( + name = "Relays", + external_docs( + url = "https://docs.ockam.io/reference/command/advanced-routing#relays", + description = "Learn more about Relays on Ockam command documentation" + ), + description = +" +Relays make it possible to establish end-to-end protocols with services operating in a remote +private network, without requiring a remote service to expose listening ports to an outside hostile +network like the Internet. +", + ),( + name = "Tickets", + description = +" +The ticket is plain text representing a one-time use token and the non-sensitive data about the +Project, like the route to reach it and the Project Identity Identifier, which will be used to +validate the Project Identity. The ticket itself can be stored in an environment variable, or a +file. +", + ),( + name = "Authority Members", + description = +" +An Ockam Authority is a Ockam node running a set of services dedicated to managing credentials +and identities. +The Ockam Authority keeps a list of members, which are entitled to receive credentials with proper +attributes. +" + ) ), modifiers(&Authentications), paths( @@ -55,11 +139,14 @@ impl Modify for Authentications { handle_authority_member_remove ), security( - ("bearer" = []) + ("token" = []) ), external_docs( url = "https://docs.ockam.io/", description = "Ockam documentation" + ), + components( + schemas(NodeName), ) )] struct ApiDoc; diff --git a/implementations/rust/ockam/ockam_api/src/control_api/protocol/authority_member.rs b/implementations/rust/ockam/ockam_api/src/control_api/protocol/authority_member.rs index 3c952f5fac8..510f82be2cb 100644 --- a/implementations/rust/ockam/ockam_api/src/control_api/protocol/authority_member.rs +++ b/implementations/rust/ockam/ockam_api/src/control_api/protocol/authority_member.rs @@ -1,20 +1,12 @@ -use super::common::default_authority; +use super::common::{default_authority, Attributes}; use crate::control_api::protocol::common::Authority; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; use utoipa::ToSchema; #[derive(Debug, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "kebab-case")] pub struct AddOrUpdateAuthorityMemberRequest { - /// Attributes in `key=value` format to be attached to the member; - #[schema(examples( - "ockam-role=member", - "ockam-role=enroller", - "ockam-relay=foo", - "my-attribute=my-value" - ))] - pub attributes: BTreeMap, + pub attributes: Attributes, /// Identity to use when contacting the Authority node; /// When omitted, the default identity will be used #[schema(examples("Id3b788c6a89de8b1f2fd13743eb3123178cf6ec7c9253be8ddcf7e154abe016a"))] @@ -97,7 +89,5 @@ pub struct AuthorityMember { /// Member identity #[schema(examples("Id3b788c6a89de8b1f2fd13743eb3123178cf6ec7c9253be8ddcf7e154abe016a"))] pub identity: String, - /// Attributes attached to the member - #[schema(examples("ockam-role=member, ockam-relay=foo"))] - pub attributes: BTreeMap, + pub attributes: Attributes, } 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 12bbb9e40cd..68a116a890a 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,8 +1,35 @@ use ockam::identity::{Identity, Vault}; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use std::str::FromStr; use utoipa::ToSchema; +// This is an alias for documentation purposes only +/// The destination node name. +/// Depending on the type of node resolution used, it can be a relay name +/// or a dns address. +/// The special value `self` can be used to refer to the current node. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct NodeName(String); + +// This is an alias for documentation purposes only +#[derive(Serialize, Deserialize, ToSchema, Debug)] +#[schema( + description = +r#" +Credential attributes. +Attributes are key-value pairs that can be used to describe a credential. +Ockam uses `ockam-` as a prefix for its own attributes. +[You can learn more about attributes in the Ockam documentation](https://docs.ockam.io/reference/protocols/access-controls) +"#, + example = json!({ + "ockam-role": "member", + "ockam-relay": "relay-name", + "my-attribute": "my-value", + }) +)] +pub struct Attributes(pub BTreeMap); + #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct HostnamePort { pub hostname: 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 4768be9dc9d..37b6124c374 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 @@ -76,7 +76,8 @@ pub struct CreateInletRequest { #[schema(example = "Id3b788c6a89de8b1f2fd13743eb3123178cf6ec7c9253be8ddcf7e154abe016a")] pub authorized: Option, /// Policy expression that will be used for access control to the TCP Inlet; - /// When omitted, 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. + /// [Learn more about Policies expressions on the Ockam documentation](https://docs.ockam.io/reference/protocols/access-controls). pub allow: Option, /// When connection is lost, how long to wait before retrying to connect to the TCP Outlet; /// In milliseconds; @@ -88,18 +89,24 @@ pub struct CreateInletRequest { #[serde(rename_all = "kebab-case")] pub struct UpdateInletRequest { /// Policy expression that will be used for access control to the TCP Inlet; + /// When omitted, the policy set for the "tcp-inlet" resource type will be used. + /// [Learn more about Policies expression on the Ockam documentation](https://docs.ockam.io/reference/protocols/access-controls). pub allow: Option, } #[derive(Debug, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "kebab-case")] pub struct InletStatus { + /// Name of the TCP Inlet pub name: String, + /// Status of the TCP Inlet pub status: ConnectionStatus, + /// Bind address of the TCP Inlet pub bind_address: HostnamePort, + /// The current route of the TCP Inlet, populated only when the status is `up` pub current_route: Option, + /// Multiaddress to the TCP Outlet pub to: String, - pub privileged: bool, } impl TryFrom for InletStatus { @@ -114,7 +121,6 @@ impl TryFrom for InletStatus { name: status.alias, current_route: status.outlet_route.map(|r| r.to_string()), to: status.outlet_addr, - privileged: status.privileged, }) } } 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 add48cc5a49..896b01ff335 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 @@ -38,7 +38,8 @@ pub struct CreateOutletRequest { #[schema(default = "None")] pub tls: OutletTls, /// Policy expression that will be used for access control to the TCP Outlet; - /// by default the policy set for the "tcp-outlet" resource type will be used + /// by default, the policy set for the "tcp-outlet" resource type will be used. + /// [Learn more about Policies expression on the Ockam documentation](https://docs.ockam.io/reference/protocols/access-controls). pub allow: Option, } @@ -46,14 +47,19 @@ pub struct CreateOutletRequest { #[serde(rename_all = "kebab-case")] pub struct UpdateOutletRequest { /// Policy expression that will be used for access control to the TCP Outlet; + /// by default, the policy set for the "tcp-outlet" resource type will be used. + /// [Learn more about Policies expression on the Ockam documentation](https://docs.ockam.io/reference/protocols/access-controls). pub allow: Option, } #[derive(Debug, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "kebab-case")] pub struct OutletStatus { + /// The address of the outlet pub to: HostnamePort, + /// The address of the worker, this also acts as an identifier of the TCP Outlet within the node pub address: String, + /// Whether the outlet is of privileged kind pub privileged: bool, } diff --git a/implementations/rust/ockam/ockam_api/src/control_api/protocol/ticket.rs b/implementations/rust/ockam/ockam_api/src/control_api/protocol/ticket.rs index 183ee365e77..5f31439fc78 100644 --- a/implementations/rust/ockam/ockam_api/src/control_api/protocol/ticket.rs +++ b/implementations/rust/ockam/ockam_api/src/control_api/protocol/ticket.rs @@ -1,7 +1,6 @@ -use super::common::{default_project_information, Project}; +use super::common::{default_project_information, Attributes, Project}; use crate::control_api::protocol::common::HostnamePort; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; use utoipa::ToSchema; fn default_usage_count() -> u64 { @@ -15,14 +14,7 @@ fn default_expires_in() -> u64 { #[derive(Debug, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "kebab-case")] pub struct CreateTicketRequest { - /// Attributes in `key=value` format to be attached to the member; - #[schema(examples( - "ockam-role=member", - "ockam-role=enroller", - "ockam-relay=foo", - "my-attribute=my-value" - ))] - pub attributes: BTreeMap, + pub attributes: Attributes, /// Identity to use when contacting the Authority node; /// When omitted, the default identity will be used #[schema(examples("Id3b788c6a89de8b1f2fd13743eb3123178cf6ec7c9253be8ddcf7e154abe016a"))] @@ -52,7 +44,7 @@ pub struct Ticket { } #[derive(Debug, Serialize, Deserialize, ToSchema)] -pub struct EnrollTicketRequest { +pub struct EnrollProjectRequest { /// Identity to enroll; /// When omitted, the default identity will be used #[schema(examples("Id3b788c6a89de8b1f2fd13743eb3123178cf6ec7c9253be8ddcf7e154abe016a"))] diff --git a/implementations/rust/ockam/ockam_command/tests/bats/orchestrator/control_api.bats b/implementations/rust/ockam/ockam_command/tests/bats/orchestrator/node_control_api.bats similarity index 88% rename from implementations/rust/ockam/ockam_command/tests/bats/orchestrator/control_api.bats rename to implementations/rust/ockam/ockam_command/tests/bats/orchestrator/node_control_api.bats index 8d4d8180a4b..71a769bd728 100644 --- a/implementations/rust/ockam/ockam_command/tests/bats/orchestrator/control_api.bats +++ b/implementations/rust/ockam/ockam_command/tests/bats/orchestrator/node_control_api.bats @@ -45,7 +45,7 @@ teardown() { run_success curl -vf \ -H 'Authorization: Bearer token' \ -o inlet-list.json \ - "http://localhost:${api_port}/red/tcp-inlet" + "http://localhost:${api_port}/red/tcp-inlets" run_success cat inlet-list.json assert_output "[]" } @@ -82,7 +82,7 @@ teardown() { run_success curl -vf \ -H 'Authorization: Bearer token' \ -o inlet-list.json \ - "http://localhost:${api_port}/localhost/tcp-inlet" + "http://localhost:${api_port}/localhost/tcp-inlets" run_success cat inlet-list.json assert_output "[]" } @@ -105,11 +105,11 @@ teardown() { # create outlet run_success curl -vf \ - -X PUT \ + -X POST \ -H 'Authorization: Bearer token' \ -d "{\"kind\":\"regular\",\"address\":\"my-outlet\",\"to\":{\"hostname\":\"localhost\", \"port\":$PYTHON_SERVER_PORT}}" \ -o outlet-creation.json \ - "http://localhost:${api_port}/self/tcp-outlet" + "http://localhost:${api_port}/self/tcp-outlets" run_success sh -c "cat outlet-creation.json | jq -rc .address" assert_output "my-outlet" @@ -117,7 +117,7 @@ teardown() { run_success curl -vf \ -H 'Authorization: Bearer token' \ -o outlet.json \ - "http://localhost:${api_port}/self/tcp-outlet/my-outlet" + "http://localhost:${api_port}/self/tcp-outlets/my-outlet" run_success sh -c "cat outlet.json | jq -rc .address" assert_output "my-outlet" @@ -125,17 +125,17 @@ teardown() { run_success curl -vf \ -H 'Authorization: Bearer token' \ -o outlet-list.json \ - "http://localhost:${api_port}/self/tcp-outlet" + "http://localhost:${api_port}/self/tcp-outlets" run_success sh -c "cat outlet-list.json | jq -rc .[0].address" assert_output "my-outlet" # create inlet run_success curl -vf \ - -X PUT \ + -X POST \ -H 'Authorization: Bearer token' \ -d "{\"from\":{\"hostname\":\"127.0.0.1\",\"port\":0},\"kind\":\"regular\",\"name\":\"my-inlet\",\"to\":\"/secure/api/service/my-outlet\"}" \ -o inlet-creation.json \ - "http://localhost:${api_port}/self/tcp-inlet" + "http://localhost:${api_port}/self/tcp-inlets" inlet_port=$(cat inlet-creation.json | jq -rc '."bind-address".port') wait_for_port $inlet_port @@ -143,7 +143,7 @@ teardown() { run_success curl -vf \ -H 'Authorization: Bearer token' \ -o inlet.json \ - "http://localhost:${api_port}/self/tcp-inlet/my-inlet" + "http://localhost:${api_port}/self/tcp-inlets/my-inlet" run_success sh -c "cat inlet.json | jq -rc .name" assert_output "my-inlet" @@ -151,23 +151,23 @@ teardown() { run_success curl -vf \ -H 'Authorization: Bearer token' \ -o inlet-list.json \ - "http://localhost:${api_port}/self/tcp-inlet" + "http://localhost:${api_port}/self/tcp-inlets" run_success sh -c "cat inlet-list.json | jq -rc .[0].name" assert_output "my-inlet" - # verify that the inlet is working + # verify that the portal is working run_success curl -sfI --retry-all-errors --retry-delay 5 --retry 10 -m 5 "127.0.0.1:$inlet_port" # delete the outlet run_success curl -vf \ -X DELETE \ -H 'Authorization: Bearer token' \ - "http://localhost:${api_port}/self/tcp-outlet/my-outlet" + "http://localhost:${api_port}/self/tcp-outlets/my-outlet" run_success curl -vf \ -H 'Authorization: Bearer token' \ -o outlet-list.json \ - "http://localhost:${api_port}/self/tcp-outlet" + "http://localhost:${api_port}/self/tcp-outlets" run_success sh -c "cat outlet-list.json | jq -rc ." assert_output "[]" @@ -175,12 +175,12 @@ teardown() { run_success curl -vf \ -X DELETE \ -H 'Authorization: Bearer token' \ - "http://localhost:${api_port}/self/tcp-inlet/my-inlet" + "http://localhost:${api_port}/self/tcp-inlets/my-inlet" run_success curl -vf \ -H 'Authorization: Bearer token' \ -o inlet-list.json \ - "http://localhost:${api_port}/self/tcp-inlet" + "http://localhost:${api_port}/self/tcp-inlets" run_success sh -c "cat inlet-list.json | jq -rc ." assert_output "[]" } @@ -204,11 +204,11 @@ teardown() { # create relay run_success curl -vf \ - -X PUT \ + -X POST \ -H 'Authorization: Bearer token' \ -d "{\"address\":\"my-address\",\"name\":\"my-relay\",\"to\":\"/project/default\"}" \ -o relay-creation.json \ - "http://localhost:${api_port}/self/relay" + "http://localhost:${api_port}/self/relays" run_success sh -c "cat relay-creation.json | jq -rc .name" assert_output "my-relay" @@ -217,7 +217,7 @@ teardown() { run_success curl -vf \ -H 'Authorization: Bearer token' \ -o relay.json \ - "http://localhost:${api_port}/self/relay/my-relay" + "http://localhost:${api_port}/self/relays/my-relay" run_success sh -c "cat relay.json | jq -rc .name" assert_output "my-relay" @@ -235,7 +235,7 @@ teardown() { run_success curl -vf \ -H 'Authorization: Bearer token' \ -o relay-list.json \ - "http://localhost:${api_port}/self/relay" + "http://localhost:${api_port}/self/relays" run_success sh -c "cat relay-list.json | jq -rc .[0].name" assert_output "my-relay" @@ -247,13 +247,13 @@ teardown() { run_success curl -vf \ -X DELETE \ -H 'Authorization: Bearer token' \ - "http://localhost:${api_port}/self/relay/my-relay" + "http://localhost:${api_port}/self/relays/my-relay" # verify that the relay is deleted run_success curl -vf \ -H 'Authorization: Bearer token' \ -o relay-list.json \ - "http://localhost:${api_port}/self/relay" + "http://localhost:${api_port}/self/relays" run_success sh -c "cat relay-list.json | jq -rc ." assert_output "[]" } @@ -277,11 +277,11 @@ teardown() { # create ticket run_success curl -vf \ - -X PUT \ + -X POST \ -H 'Authorization: Bearer token' \ -d "{\"attributes\":{\"ockam-role\":\"member\",\"my-attribute\":\"my-value\"}}" \ -o ticket-creation.json \ - "http://localhost:${api_port}/self/ticket" + "http://localhost:${api_port}/self/tickets" ticket=$(cat ticket-creation.json | jq -rc .encoded) # use the ticket with a dedicated identity @@ -294,7 +294,7 @@ teardown() { -H 'Authorization: Bearer token' \ -d "{\"ticket\":\"$ticket\",\"identity\":\"$identifier\"}" \ -o enrollment.json \ - "http://localhost:${api_port}/self/ticket" + "http://localhost:${api_port}/self/tickets/enroll" run_success sh -c "cat enrollment.json | jq -rc .identity" } @@ -348,11 +348,11 @@ EOF ) run_success curl -vf \ - -X PUT \ + -X POST \ -H 'Authorization: Bearer token' \ -d "${request}" \ -o ticket-creation.json \ - "http://localhost:${api_port}/self/ticket" + "http://localhost:${api_port}/self/tickets" ticket=$(cat ticket-creation.json | jq -rc .encoded) # use the ticket with a dedicated identity @@ -365,7 +365,7 @@ EOF -H 'Authorization: Bearer token' \ -d "{\"ticket\":\"$ticket\",\"identity\":\"$identifier\"}" \ -o enrollment.json \ - "http://localhost:${api_port}/self/ticket" + "http://localhost:${api_port}/self/tickets/enroll" run_success sh -c "cat enrollment.json | jq -rc .identity" } @@ -391,13 +391,13 @@ EOF -X PUT \ -H 'Authorization: Bearer token' \ -d "{\"attributes\":{\"ockam-role\":\"member\",\"my-attribute\":\"my-value\"}}" \ - "http://localhost:${api_port}/self/authority-member/Ia641901932d24b8a63b51cb78ebe099b3341dcbd6aaa202cc36868bec72bbd48" + "http://localhost:${api_port}/self/authority-members/Ia641901932d24b8a63b51cb78ebe099b3341dcbd6aaa202cc36868bec72bbd48" # get member run_success curl -vf \ -H 'Authorization: Bearer token' \ -o get-member.json \ - "http://localhost:${api_port}/self/authority-member/Ia641901932d24b8a63b51cb78ebe099b3341dcbd6aaa202cc36868bec72bbd48" + "http://localhost:${api_port}/self/authority-members/Ia641901932d24b8a63b51cb78ebe099b3341dcbd6aaa202cc36868bec72bbd48" run_success sh -c "cat get-member.json | jq -rc .attributes.\\\"my-attribute\\\"" assert_output "my-value" run_success sh -c "cat get-member.json | jq -rc .identity" @@ -407,7 +407,7 @@ EOF run_success curl -vf \ -H 'Authorization: Bearer token' \ -o list-members.json \ - "http://localhost:${api_port}/self/authority-member" + "http://localhost:${api_port}/self/authority-members" # check that the identity is listed as a member by making a search with jq run_success sh -c "cat list-members.json | jq -rc '.[] | select(.identity == \"Ia641901932d24b8a63b51cb78ebe099b3341dcbd6aaa202cc36868bec72bbd48\").attributes.\"my-attribute\"'" assert_output "my-value" @@ -416,10 +416,10 @@ EOF run_success curl -vf \ -X DELETE \ -H 'Authorization: Bearer token' \ - "http://localhost:${api_port}/self/authority-member/Ia641901932d24b8a63b51cb78ebe099b3341dcbd6aaa202cc36868bec72bbd48" + "http://localhost:${api_port}/self/authority-members/Ia641901932d24b8a63b51cb78ebe099b3341dcbd6aaa202cc36868bec72bbd48" # verify that the member is deleted run_failure curl -vf \ -H 'Authorization: Bearer token' \ - "http://localhost:${api_port}/self/authority-member/Ia641901932d24b8a63b51cb78ebe099b3341dcbd6aaa202cc36868bec72bbd48" + "http://localhost:${api_port}/self/authority-members/Ia641901932d24b8a63b51cb78ebe099b3341dcbd6aaa202cc36868bec72bbd48" }