Skip to content

Commit 03a6427

Browse files
feat: auth, enrollment API, and webapp for agent tunnel
Scope token exchange, enrollment string generation, and the Angular webapp for agent management and tunnel-aware session creation. - Enrollment string endpoint (POST /enrollment-string) - Scope token exchange for agent management API - QUIC endpoint override from enrollment string - Agent enrollment UI (Agents page, enrollment form) - Agent selector control in connection forms - Auth interceptor fix (skip requests with existing Authorization) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0b4b099 commit 03a6427

31 files changed

Lines changed: 1256 additions & 15 deletions

devolutions-agent/src/enrollment.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ pub async fn enroll_agent(
5252
agent_name: &str,
5353
advertise_subnets: Vec<String>,
5454
) -> anyhow::Result<()> {
55-
bootstrap_and_persist(gateway_url, enrollment_token, agent_name, advertise_subnets).await?;
55+
bootstrap_and_persist(gateway_url, enrollment_token, agent_name, advertise_subnets, None).await?;
5656
Ok(())
5757
}
5858

@@ -61,11 +61,20 @@ pub async fn bootstrap_and_persist(
6161
enrollment_token: &str,
6262
agent_name: &str,
6363
advertise_subnets: Vec<String>,
64+
quic_endpoint_override: Option<String>,
6465
) -> anyhow::Result<PersistedEnrollment> {
6566
// Generate key pair and CSR locally — the private key never leaves this machine.
6667
let (key_pem, csr_pem) = generate_key_and_csr(agent_name)?;
6768

68-
let enroll_response = request_enrollment(gateway_url, enrollment_token, agent_name, &csr_pem).await?;
69+
let mut enroll_response = request_enrollment(gateway_url, enrollment_token, agent_name, &csr_pem).await?;
70+
71+
// Prefer the QUIC endpoint from the enrollment string (set by the admin who knows
72+
// the reachable address) over the enroll API response (which uses conf.hostname,
73+
// often a container ID in Docker).
74+
if let Some(endpoint) = quic_endpoint_override {
75+
enroll_response.quic_endpoint = endpoint;
76+
}
77+
6978
persist_enrollment_response(advertise_subnets, enroll_response, &key_pem)
7079
}
7180

devolutions-agent/src/main.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ struct UpCommand {
6060
enrollment_token: String,
6161
agent_name: String,
6262
advertise_subnets: Vec<String>,
63+
quic_endpoint_override: Option<String>,
6364
}
6465

6566
#[derive(Debug, serde::Deserialize)]
@@ -69,6 +70,8 @@ struct EnrollmentStringPayload {
6970
enrollment_token: String,
7071
#[serde(default)]
7172
name: Option<String>,
73+
#[serde(default)]
74+
quic_endpoint: Option<String>,
7275
}
7376

7477
fn agent_service_main(
@@ -199,11 +202,14 @@ fn parse_up_command_args(args: &[String]) -> Result<UpCommand> {
199202
index += 1;
200203
}
201204

205+
let mut quic_endpoint_override = None;
206+
202207
if let Some(enrollment_string) = enrollment_string {
203208
let payload = parse_enrollment_string(&enrollment_string)?;
204209

205210
gateway_url.get_or_insert(payload.api_base_url);
206211
enrollment_token.get_or_insert(payload.enrollment_token);
212+
quic_endpoint_override = payload.quic_endpoint;
207213

208214
if agent_name.is_none() {
209215
agent_name = payload.name;
@@ -215,6 +221,7 @@ fn parse_up_command_args(args: &[String]) -> Result<UpCommand> {
215221
enrollment_token: enrollment_token.context("missing required --token")?,
216222
agent_name: agent_name.context("missing required --name")?,
217223
advertise_subnets,
224+
quic_endpoint_override,
218225
})
219226
}
220227

@@ -306,6 +313,7 @@ fn main() {
306313
&command.enrollment_token,
307314
&command.agent_name,
308315
command.advertise_subnets,
316+
command.quic_endpoint_override,
309317
)
310318
.await
311319
});

devolutions-gateway/src/api/agent_enrollment.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ pub fn make_router<S>(state: DgwState) -> Router<S> {
5050
.route("/agents", axum::routing::get(list_agents))
5151
.route("/agents/{agent_id}", axum::routing::get(get_agent).delete(delete_agent))
5252
.route("/agents/resolve-target", axum::routing::post(resolve_target))
53+
.route(
54+
"/enrollment-string",
55+
axum::routing::post(super::webapp::create_agent_enrollment_string),
56+
)
5357
.with_state(state)
5458
}
5559

devolutions-gateway/src/api/webapp.rs

Lines changed: 163 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,106 @@ 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+
621+
// Determine QUIC host: explicit override > extract from api_base_url > gateway hostname config.
622+
// The gateway hostname config is often a container ID in Docker, so we prefer
623+
// extracting the host from the api_base_url which the caller already knows is reachable.
624+
let quic_host = match req.quic_host.as_deref().filter(|h| !h.is_empty()) {
625+
Some(host) => host.to_owned(),
626+
None => url::Url::parse(&req.api_base_url)
627+
.ok()
628+
.and_then(|u| u.host_str().map(ToOwned::to_owned))
629+
.unwrap_or_else(|| conf.hostname.clone()),
630+
};
631+
let quic_endpoint = format!("{quic_host}:{}", conf.agent_tunnel.listen_port);
632+
633+
// Build the enrollment payload.
634+
let payload = serde_json::json!({
635+
"version": 1,
636+
"api_base_url": req.api_base_url,
637+
"quic_endpoint": quic_endpoint,
638+
"enrollment_token": enrollment_token,
639+
"name": req.name,
640+
});
641+
642+
let payload_json = serde_json::to_string(&payload)
643+
.map_err(HttpError::internal().with_msg("serialize enrollment payload").err())?;
644+
645+
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
646+
let enrollment_string = format!("dgw-enroll:v1:{encoded}");
647+
let enrollment_command = format!("devolutions-agent up --enrollment-string \"{enrollment_string}\"");
648+
649+
let now_secs = std::time::SystemTime::now()
650+
.duration_since(std::time::UNIX_EPOCH)
651+
.unwrap_or_default()
652+
.as_secs();
653+
let expires_at_unix = now_secs + lifetime_secs;
654+
655+
info!(
656+
agent_name = ?req.name,
657+
lifetime_secs,
658+
"Generated agent enrollment string"
659+
);
660+
661+
Ok(Json(AgentEnrollmentStringResponse {
662+
enrollment_string,
663+
enrollment_command,
664+
quic_endpoint,
665+
expires_at_unix,
666+
}))
667+
}
668+
507669
mod login_rate_limit {
508670
use std::collections::HashMap;
509671
use std::net::IpAddr;

devolutions-gateway/src/extract.rs

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

389389
/// Grants read access to agent management endpoints.
390390
///
391-
/// Accepts a scope token with `DiagnosticsRead`, `ConfigWrite`, or `Wildcard` scope.
391+
/// Accepts either a scope token with `DiagnosticsRead` (or `Wildcard`) scope,
392+
/// or a valid `WebApp` token.
392393
#[derive(Clone, Copy)]
393394
pub struct AgentManagementReadAccess;
394395

@@ -419,6 +420,8 @@ where
419420
/// Grants write access to agent management endpoints (e.g. enrollment, delete).
420421
///
421422
/// Accepts scope tokens with `ConfigWrite` (or `Wildcard`) scope only.
423+
/// The standalone webapp exchanges its WebApp token for a scope token via
424+
/// `/jet/webapp/agent-management-token` first — no direct WebApp token bypass.
422425
#[derive(Clone, Copy)]
423426
pub struct AgentManagementWriteAccess;
424427

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,41 @@
11
{
22
"/jet/webapp/app-token": {
3-
"target": "http://localhost:7171",
3+
"target": "http://127.0.0.1:7272",
44
"secure": false
55
},
66
"/jet/webapp/session-token": {
7-
"target": "http://localhost:7171",
7+
"target": "http://127.0.0.1:7272",
8+
"secure": false
9+
},
10+
"/jet/webapp/agent-management-token": {
11+
"target": "http://127.0.0.1:7272",
812
"secure": false
913
},
1014
"/jet/rdp": {
11-
"target": "http://localhost:7171",
15+
"target": "http://127.0.0.1:7272",
1216
"secure": false,
1317
"ws": true
1418
},
1519
"/jet/fwd/tcp": {
16-
"target": "http://localhost:7171",
20+
"target": "http://127.0.0.1:7272",
1721
"secure": false,
1822
"ws": true
1923
},
2024
"/jet/KdcProxy": {
21-
"target": "http://localhost:7171",
25+
"target": "http://127.0.0.1:7272",
2226
"secure": false
2327
},
2428
"/jet/health": {
25-
"target": "http://localhost:7171",
29+
"target": "http://127.0.0.1:7272",
2630
"secure": false
2731
},
2832
"/jet/net/scan": {
29-
"target": "http://localhost:7171",
33+
"target": "http://127.0.0.1:7272",
3034
"secure": false,
3135
"ws": true
36+
},
37+
"/jet/agent-tunnel": {
38+
"target": "http://127.0.0.1:7272",
39+
"secure": false
3240
}
33-
}
41+
}

webapp/apps/gateway-ui/src/client/app/app-auth.interceptor.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ export class AuthInterceptor implements HttpInterceptor {
1818
// If the request is for the app token, we don't need to add the Authorization header
1919
const goToNext = [];
2020
goToNext.push(req.url.endsWith(this.appTokenUrl));
21+
// Requests that already carry their own Authorization header (e.g. agent tunnel
22+
// endpoints using scope tokens) should not be overwritten by the app token.
23+
goToNext.push(req.headers.has('Authorization'));
2124

2225
// If the requesting third party host, we don't need to add the Authorization header
2326
try {

webapp/apps/gateway-ui/src/client/app/modules/base/menu/app-menu.component.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@
3434
<gateway-menu-list-item *ngFor="let menuKVP of mainMenus | keyvalue: asIsOrder"
3535
[label]="menuKVP.value.label"
3636
[icon]="menuKVP.value.icon"
37-
[iconOnly]="isMenuSlim"></gateway-menu-list-item>
37+
[iconOnly]="isMenuSlim"
38+
(click)="menuKVP.value.executeAction()"></gateway-menu-list-item>
3839

3940
<gateway-menu-list-active-sessions [isMenuSlim]="isMenuSlim">
4041
</gateway-menu-list-active-sessions>

0 commit comments

Comments
 (0)