@@ -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+
459521async 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+
507669mod login_rate_limit {
508670 use std:: collections:: HashMap ;
509671 use std:: net:: IpAddr ;
0 commit comments