Skip to content

Commit 00b9a9e

Browse files
feat: overlay PR3 (installer) + PR4 (webapp + e2e) on PR2
Feature branch is the superset of all upcoming PRs: - Base: master (PR1 merged as #1738) - PR2 content (transparent routing, cert renewal, Docker): in place - PR3 content (Windows MSI installer tunnel dialog, Linux entrypoint): overlaid from the old feature tip. - PR4 content (gateway webapp agent UI + Playwright e2e): overlaid from the old feature tip. Compiles clean. Not yet fmt-verified for the webapp side.
1 parent f323f30 commit 00b9a9e

90 files changed

Lines changed: 5406 additions & 2129 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

devolutions-gateway/src/api/tunnel.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ pub fn make_router<S>(state: DgwState) -> Router<S> {
9191
.route("/enroll", axum::routing::post(enroll_agent))
9292
.route("/agents", axum::routing::get(list_agents))
9393
.route("/agents/{agent_id}", axum::routing::get(get_agent).delete(delete_agent))
94+
.route(
95+
"/enrollment-string",
96+
axum::routing::post(super::webapp::create_agent_enrollment_string),
97+
)
9498
.with_state(state)
9599
}
96100

devolutions-gateway/src/api/webapp.rs

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ pub fn make_router<S>(state: DgwState) -> Router<S> {
2929
.route("/client/{*path}", get(get_client))
3030
.route("/app-token", post(sign_app_token))
3131
.route("/session-token", post(sign_session_token))
32+
.route("/agent-management-token", post(sign_agent_management_token))
3233
} else {
3334
Router::new()
3435
}
@@ -232,6 +233,9 @@ pub(crate) enum SessionTokenContentType {
232233
destination: TargetAddr,
233234
/// Unique ID for this session
234235
session_id: Uuid,
236+
/// Optional agent ID for routing through an enrolled agent tunnel.
237+
#[serde(default)]
238+
agent_id: Option<Uuid>,
235239
},
236240
Jmux {
237241
/// Protocol for the session (e.g.: "tunnel")
@@ -328,6 +332,7 @@ pub(crate) async fn sign_session_token(
328332
protocol,
329333
destination,
330334
session_id,
335+
agent_id,
331336
} => (
332337
AssociationTokenClaims {
333338
jet_aid: session_id,
@@ -342,7 +347,7 @@ pub(crate) async fn sign_session_token(
342347
exp,
343348
jti,
344349
cert_thumb256: None,
345-
jet_agent_id: None,
350+
jet_agent_id: agent_id,
346351
}
347352
.pipe(serde_json::to_value)
348353
.map(|mut claims| {
@@ -456,6 +461,63 @@ pub(crate) async fn sign_session_token(
456461
Ok(response)
457462
}
458463

464+
/// Exchange a WebApp token for an agent management scope token.
465+
///
466+
/// This mirrors the DVLS pattern: DVLS signs scope tokens with its RSA key,
467+
/// while the standalone webapp exchanges its WebApp token for a scope token here.
468+
/// Both paths produce the same token type, so agent tunnel endpoints have
469+
/// a single auth model (scope tokens only).
470+
async fn sign_agent_management_token(
471+
State(DgwState { conf_handle, .. }): State<DgwState>,
472+
WebAppToken(web_app_token): WebAppToken,
473+
) -> Result<Response, HttpError> {
474+
use picky::jose::jws::JwsAlg;
475+
use picky::jose::jwt::CheckedJwtSig;
476+
477+
use crate::token::{AccessScope, ScopeTokenClaims};
478+
479+
const LIFETIME_SECS: i64 = 300; // 5 minutes, same as DVLS scope tokens
480+
481+
let conf = conf_handle.get_conf();
482+
483+
let provisioner_key = conf
484+
.provisioner_private_key
485+
.as_ref()
486+
.ok_or_else(|| HttpError::internal().msg("provisioner private key is missing"))?;
487+
488+
ensure_enabled(&conf)?;
489+
490+
let now = time::OffsetDateTime::now_utc().unix_timestamp();
491+
492+
let claims = ScopeTokenClaims {
493+
scope: AccessScope::ConfigWrite,
494+
exp: now + LIFETIME_SECS,
495+
jti: Uuid::new_v4(),
496+
}
497+
.pipe(serde_json::to_value)
498+
.map(|mut claims| {
499+
if let Some(claims) = claims.as_object_mut() {
500+
claims.insert("iat".to_owned(), serde_json::json!(now));
501+
claims.insert("nbf".to_owned(), serde_json::json!(now));
502+
}
503+
claims
504+
})
505+
.map_err(HttpError::internal().with_msg("scope claims").err())?;
506+
507+
let jwt_sig = CheckedJwtSig::new_with_cty(JwsAlg::RS256, "SCOPE".to_owned(), claims);
508+
509+
let token = jwt_sig
510+
.encode(provisioner_key)
511+
.map_err(HttpError::internal().with_msg("sign agent management token").err())?;
512+
513+
info!(user = web_app_token.sub, "Granted agent management scope token");
514+
515+
let cache_control = TypedHeader(headers::CacheControl::new().with_no_cache().with_no_store());
516+
let response = (cache_control, token).into_response();
517+
518+
Ok(response)
519+
}
520+
459521
async fn get_client<ReqBody>(
460522
State(DgwState { conf_handle, .. }): State<DgwState>,
461523
path: Option<extract::Path<String>>,
@@ -504,6 +566,107 @@ fn ensure_enabled(conf: &crate::config::Conf) -> Result<(), HttpError> {
504566
extract_conf(conf).map(|_| ())
505567
}
506568

569+
// -- Agent enrollment string generation -- //
570+
571+
#[derive(Debug, Deserialize)]
572+
pub(crate) struct AgentEnrollmentStringRequest {
573+
/// Base URL for the gateway API (e.g. `https://gateway.example.com`).
574+
api_base_url: String,
575+
/// Optional QUIC host override. Defaults to the gateway hostname.
576+
quic_host: Option<String>,
577+
/// Optional agent name hint.
578+
name: Option<String>,
579+
/// Token lifetime in seconds (default: 3600).
580+
lifetime: Option<u64>,
581+
}
582+
583+
#[derive(Debug, Serialize)]
584+
pub(crate) struct AgentEnrollmentStringResponse {
585+
enrollment_string: String,
586+
enrollment_command: String,
587+
quic_endpoint: String,
588+
expires_at_unix: u64,
589+
}
590+
591+
/// Generate a one-time enrollment string for agent enrollment.
592+
///
593+
/// Accepts scope tokens with `ConfigWrite` scope only. Both the standalone
594+
/// webapp (via `/jet/webapp/agent-management-token` exchange) and DVLS
595+
/// (via direct RSA-signed scope tokens) produce the same token type.
596+
pub(crate) async fn create_agent_enrollment_string(
597+
State(DgwState {
598+
conf_handle,
599+
agent_tunnel_handle,
600+
..
601+
}): State<DgwState>,
602+
_access: crate::extract::AgentManagementWriteAccess,
603+
Json(req): Json<AgentEnrollmentStringRequest>,
604+
) -> Result<Json<AgentEnrollmentStringResponse>, HttpError> {
605+
use base64::Engine as _;
606+
607+
let conf = conf_handle.get_conf();
608+
609+
let handle = agent_tunnel_handle
610+
.as_ref()
611+
.ok_or_else(|| HttpError::not_found().msg("agent tunnel not configured"))?;
612+
613+
let lifetime_secs = req.lifetime.unwrap_or(3600);
614+
615+
// Generate a one-time enrollment token.
616+
let enrollment_token = Uuid::new_v4().to_string();
617+
handle
618+
.enrollment_token_store()
619+
.insert(enrollment_token.clone(), req.name.clone(), Some(lifetime_secs))
620+
.await;
621+
622+
// Determine QUIC host: explicit override > extract from api_base_url > gateway hostname config.
623+
// The gateway hostname config is often a container ID in Docker, so we prefer
624+
// extracting the host from the api_base_url which the caller already knows is reachable.
625+
let quic_host = match req.quic_host.as_deref().filter(|h| !h.is_empty()) {
626+
Some(host) => host.to_owned(),
627+
None => url::Url::parse(&req.api_base_url)
628+
.ok()
629+
.and_then(|u| u.host_str().map(ToOwned::to_owned))
630+
.unwrap_or_else(|| conf.hostname.clone()),
631+
};
632+
let quic_endpoint = format!("{quic_host}:{}", conf.agent_tunnel.listen_port);
633+
634+
// Build the enrollment payload.
635+
let payload = serde_json::json!({
636+
"version": 1,
637+
"api_base_url": req.api_base_url,
638+
"quic_endpoint": quic_endpoint,
639+
"enrollment_token": enrollment_token,
640+
"name": req.name,
641+
});
642+
643+
let payload_json = serde_json::to_string(&payload)
644+
.map_err(HttpError::internal().with_msg("serialize enrollment payload").err())?;
645+
646+
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
647+
let enrollment_string = format!("dgw-enroll:v1:{encoded}");
648+
let enrollment_command = format!("devolutions-agent up --enrollment-string \"{enrollment_string}\"");
649+
650+
let now_secs = std::time::SystemTime::now()
651+
.duration_since(std::time::UNIX_EPOCH)
652+
.unwrap_or_default()
653+
.as_secs();
654+
let expires_at_unix = now_secs + lifetime_secs;
655+
656+
info!(
657+
agent_name = ?req.name,
658+
lifetime_secs,
659+
"Generated agent enrollment string"
660+
);
661+
662+
Ok(Json(AgentEnrollmentStringResponse {
663+
enrollment_string,
664+
enrollment_command,
665+
quic_endpoint,
666+
expires_at_unix,
667+
}))
668+
}
669+
507670
mod login_rate_limit {
508671
use std::collections::HashMap;
509672
use std::net::IpAddr;

devolutions-gateway/src/extract.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -408,7 +408,8 @@ where
408408

409409
/// Grants read access to agent management endpoints.
410410
///
411-
/// Accepts a scope token with `DiagnosticsRead`, `ConfigWrite`, or `Wildcard` scope.
411+
/// Accepts either a scope token with `DiagnosticsRead` (or `Wildcard`) scope,
412+
/// or a valid `WebApp` token.
412413
#[derive(Clone, Copy)]
413414
pub struct AgentManagementReadAccess;
414415

@@ -439,6 +440,8 @@ where
439440
/// Grants write access to agent management endpoints (e.g. enrollment, delete).
440441
///
441442
/// Accepts scope tokens with `ConfigWrite` (or `Wildcard`) scope only.
443+
/// The standalone webapp exchanges its WebApp token for a scope token via
444+
/// `/jet/webapp/agent-management-token` first — no direct WebApp token bypass.
442445
#[derive(Clone, Copy)]
443446
pub struct AgentManagementWriteAccess;
444447

package/AgentWindowsManaged/Actions/AgentActions.cs

Lines changed: 14 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -246,26 +246,6 @@ internal static class AgentActions
246246
Execute = Execute.rollback,
247247
};
248248

249-
/// <summary>
250-
/// Read the requested feature states into a session property for use by the deferred <see cref="configureFeatures"/> action.
251-
/// </summary>
252-
/// <remarks>
253-
/// <c>session.Features</c> is only accessible from immediate custom actions (DTF restriction).
254-
/// This action runs immediately in the execute sequence after <c>InstallInitialize</c>, at which point
255-
/// <c>MigrateFeatures</c> has already run and feature <c>RequestState</c> is authoritative for all
256-
/// scenarios (fresh install, upgrade, maintenance, silent or interactive).
257-
/// </remarks>
258-
private static readonly ManagedAction setFeaturesToConfigure = new(
259-
new Id($"CA.{nameof(setFeaturesToConfigure)}"),
260-
CustomActions.SetFeaturesToConfigure,
261-
Return.check,
262-
When.After, Step.InstallInitialize,
263-
Condition.NOT_BeingRemoved,
264-
Sequence.InstallExecuteSequence)
265-
{
266-
Execute = Execute.immediate,
267-
};
268-
269249
private static readonly ElevatedManagedAction configureFeatures = new(
270250
CustomActions.ConfigureFeatures
271251
)
@@ -275,8 +255,19 @@ internal static class AgentActions
275255
Return = Return.check,
276256
Step = Step.StartServices,
277257
When = When.Before,
278-
Condition = Condition.NOT_BeingRemoved,
279-
UsesProperties = UseProperties(new[] { AgentProperties.featuresToConfigure })
258+
Condition = Condition.NOT_BeingRemoved & new Condition("(UILevel >= 3 OR WIXSHARP_MANAGED_UI_HANDLE <> \"\")")
259+
};
260+
261+
private static readonly ElevatedManagedAction enrollAgentTunnel = new(
262+
new Id($"CA.{nameof(enrollAgentTunnel)}"),
263+
CustomActions.EnrollAgentTunnel,
264+
Return.check,
265+
When.Before, Step.StartServices,
266+
Condition.NOT_BeingRemoved,
267+
Sequence.InstallExecuteSequence)
268+
{
269+
Execute = Execute.deferred,
270+
Impersonate = false,
280271
};
281272

282273
private static readonly ElevatedManagedAction registerExplorerCommand = new(
@@ -350,8 +341,8 @@ private static string UseProperties(IEnumerable<IWixProperty> properties)
350341
checkNetFxInstalledVersion,
351342
getInstallDirFromRegistry,
352343
setArpInstallLocation,
353-
setFeaturesToConfigure,
354344
configureFeatures,
345+
enrollAgentTunnel,
355346
createProgramDataDirectory,
356347
setProgramDataDirectoryPermissions,
357348
createProgramDataPedmDirectories,

0 commit comments

Comments
 (0)