diff --git a/Cargo.lock b/Cargo.lock index 57d521549..98e0899b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -957,6 +957,7 @@ dependencies = [ "camino", "ceviche", "cfg-if", + "devolutions-agent-shared", "devolutions-gateway-generators", "devolutions-gateway-task", "devolutions-log", diff --git a/devolutions-gateway/Cargo.toml b/devolutions-gateway/Cargo.toml index 1b9755747..21526c5b2 100644 --- a/devolutions-gateway/Cargo.toml +++ b/devolutions-gateway/Cargo.toml @@ -18,6 +18,7 @@ openapi = ["dep:utoipa"] # In-house transport = { path = "../crates/transport" } jmux-proxy = { path = "../crates/jmux-proxy" } +devolutions-agent-shared = { path = "../crates/devolutions-agent-shared" } devolutions-gateway-task = { path = "../crates/devolutions-gateway-task" } devolutions-log = { path = "../crates/devolutions-log" } ironrdp-pdu = { version = "0.1", git = "https://github.com/Devolutions/IronRDP", rev = "4844e77b7f65024d85ba74b1824013eda6eb32b2" } diff --git a/devolutions-gateway/src/api/mod.rs b/devolutions-gateway/src/api/mod.rs index 13ea13069..f61683d0f 100644 --- a/devolutions-gateway/src/api/mod.rs +++ b/devolutions-gateway/src/api/mod.rs @@ -11,6 +11,7 @@ pub mod net; pub mod rdp; pub mod session; pub mod sessions; +pub mod update; pub mod webapp; pub fn make_router(state: crate::DgwState) -> axum::Router { @@ -27,7 +28,8 @@ pub fn make_router(state: crate::DgwState) -> axum::Router { .route("/jet/rdp", axum::routing::get(rdp::handler)) .nest("/jet/fwd", fwd::make_router(state.clone())) .nest("/jet/webapp", webapp::make_router(state.clone())) - .nest("/jet/net", net::make_router(state.clone())); + .nest("/jet/net", net::make_router(state.clone())) + .route("/jet/update", axum::routing::post(update::trigger_update_check)); if state.conf_handle.get_conf().web_app.enabled { router = router.route( diff --git a/devolutions-gateway/src/api/update.rs b/devolutions-gateway/src/api/update.rs new file mode 100644 index 000000000..2e55112c7 --- /dev/null +++ b/devolutions-gateway/src/api/update.rs @@ -0,0 +1,71 @@ +use axum::extract::Query; +use axum::Json; +use hyper::StatusCode; + +use devolutions_agent_shared::{get_updater_file_path, ProductUpdateInfo, UpdateJson, VersionSpecification}; + +use crate::extract::UpdateScope; +use crate::http::{HttpError, HttpErrorBuilder}; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateQueryParam { + version: VersionSpecification, +} + +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Serialize)] +pub(crate) struct UpdateResponse {} + +/// Triggers Devolutions Gateway update process. +/// +/// This is done via updating `Agent/update.json` file, which is then read by Devolutions Agent +/// when changes are detected. If the version written to `update.json` is indeed higher than the +/// currently installed version, Devolutions Agent will proceed with the update process. +#[cfg_attr(feature = "openapi", utoipa::path( + post, + operation_id = "Update", + tag = "Update", + path = "/jet/update", + responses( + (status = 200, description = "Update request has been processed successfully", body = UpdateResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Invalid or missing authorization token"), + (status = 403, description = "Insufficient permissions"), + (status = 500, description = "Agent updater service is malfunctioning"), + (status = 503, description = "Agent updater service is unavailable"), + ), + security(("scope_token" = ["gateway.update"])), +))] +pub(super) async fn trigger_update_check( + Query(query): Query, + _scope: UpdateScope, +) -> Result, HttpError> { + let target_version = query.version; + + let updater_file_path = get_updater_file_path(); + + if !updater_file_path.exists() { + return Err( + HttpErrorBuilder::new(StatusCode::SERVICE_UNAVAILABLE).msg("Agent updater service is not installed") + ); + } + + let update_json = UpdateJson { + gateway: Some(ProductUpdateInfo { target_version }), + }; + + let update_json = serde_json::to_string(&update_json).map_err( + HttpError::internal() + .with_msg("failed to serialize the update manifest") + .err(), + )?; + + std::fs::write(updater_file_path, update_json).map_err( + HttpError::internal() + .with_msg("failed to write the new `update.json` manifest on disk") + .err(), + )?; + + Ok(Json(UpdateResponse {})) +} diff --git a/devolutions-gateway/src/extract.rs b/devolutions-gateway/src/extract.rs index 770792475..e1d97d1d3 100644 --- a/devolutions-gateway/src/extract.rs +++ b/devolutions-gateway/src/extract.rs @@ -294,6 +294,25 @@ where } } +#[derive(Clone, Copy)] +pub struct UpdateScope; + +#[async_trait] +impl FromRequestParts for UpdateScope +where + S: Send + Sync, +{ + type Rejection = HttpError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + match ScopeToken::from_request_parts(parts, state).await?.0.scope { + AccessScope::Wildcard => Ok(Self), + AccessScope::Update => Ok(Self), + _ => Err(HttpError::forbidden().msg("invalid scope for route")), + } + } +} + #[derive(Clone)] pub struct WebAppToken(pub WebAppTokenClaims); diff --git a/devolutions-gateway/src/openapi.rs b/devolutions-gateway/src/openapi.rs index e62b313cb..9f76f8156 100644 --- a/devolutions-gateway/src/openapi.rs +++ b/devolutions-gateway/src/openapi.rs @@ -20,6 +20,7 @@ use uuid::Uuid; crate::api::jrec::pull_recording_file, crate::api::webapp::sign_app_token, crate::api::webapp::sign_session_token, + crate::api::update::trigger_update_check, // crate::api::net::get_net_config, ), components(schemas( @@ -39,6 +40,7 @@ use uuid::Uuid; crate::token::AccessScope, crate::api::webapp::AppTokenSignRequest, crate::api::webapp::AppTokenContentType, + crate::api::update::UpdateResponse, // crate::api::net::NetworkInterface, SessionTokenContentType, SessionTokenSignRequest, diff --git a/devolutions-gateway/src/token.rs b/devolutions-gateway/src/token.rs index 9870f66e4..b841f7627 100644 --- a/devolutions-gateway/src/token.rs +++ b/devolutions-gateway/src/token.rs @@ -410,6 +410,8 @@ pub enum AccessScope { RecordingDelete, #[serde(rename = "gateway.recordings.read")] RecordingsRead, + #[serde(rename = "gateway.update")] + Update, } #[derive(Clone, Deserialize)] diff --git a/utils/dotnet/Devolutions.Gateway.Utils/src/AccessScope.cs b/utils/dotnet/Devolutions.Gateway.Utils/src/AccessScope.cs index d1812655b..53af11428 100644 --- a/utils/dotnet/Devolutions.Gateway.Utils/src/AccessScope.cs +++ b/utils/dotnet/Devolutions.Gateway.Utils/src/AccessScope.cs @@ -22,6 +22,7 @@ internal AccessScope(string value) public static AccessScope GatewayHeartbeatRead = new AccessScope("gateway.heartbeat.read"); public static AccessScope GatewayRecordingDelete = new AccessScope("gateway.recording.delete"); public static AccessScope GatewayRecordingsRead = new AccessScope("gateway.recordings.read"); + public static AccessScope GatewayUpdate = new AccessScope("gateway.update"); public override string? ToString() {