@@ -88,19 +88,19 @@ pub struct EnrollResponse {
8888pub fn make_router < S > ( state : DgwState ) -> Router < S > {
8989 Router :: new ( )
9090 . route ( "/enroll" , axum:: routing:: post ( enroll_agent) )
91- . route (
92- "/enrollment-string" ,
93- axum:: routing:: post ( create_agent_enrollment_string) ,
94- )
9591 . route ( "/agents" , axum:: routing:: get ( list_agents) )
9692 . route ( "/agents/{agent_id}" , axum:: routing:: get ( get_agent) . delete ( delete_agent) )
9793 . with_state ( state)
9894}
9995
10096/// Enroll a new agent.
10197///
102- /// Requires a Bearer token matching the configured enrollment secret
103- /// or a valid one-time enrollment token from the store.
98+ /// Requires a Bearer token that is either:
99+ /// - a JWT signed by the configured provisioner key with `TunnelEnroll` /
100+ /// `Wildcard` scope (issued by DVLS — the only authority for agent
101+ /// enrollment tokens), or
102+ /// - the static `enrollment_secret` from the gateway configuration (admin
103+ /// bootstrap fallback for environments without DVLS).
104104///
105105/// The agent generates its own key pair and sends a CSR. The gateway signs it
106106/// and returns the certificate. The private key never leaves the agent.
@@ -139,23 +139,18 @@ async fn enroll_agent(
139139
140140 // Token validation order:
141141 // 1. JWT signed by the configured provisioner key (scope == TunnelEnroll)
142- // 2. One-time enrollment token from the in-memory store
143- // 3. Static enrollment secret from configuration (constant-time comparison)
142+ // 2. Static enrollment secret from configuration (constant-time comparison)
144143 let jwt_valid = validate_enrollment_jwt ( provided_token, & conf. provisioner_public_key ) ;
145144
146145 if !jwt_valid {
147- let token_valid = handle. enrollment_token_store ( ) . redeem ( provided_token) . await ;
148-
149- if !token_valid {
150- let enrollment_secret = conf
151- . agent_tunnel
152- . enrollment_secret
153- . as_deref ( )
154- . ok_or_else ( || HttpError :: not_found ( ) . msg ( "agent enrollment is not configured" ) ) ?;
155-
156- if !timing_safe_eq ( provided_token. as_bytes ( ) , enrollment_secret. as_bytes ( ) ) {
157- return Err ( HttpError :: forbidden ( ) . msg ( "invalid enrollment token" ) ) ;
158- }
146+ let enrollment_secret = conf
147+ . agent_tunnel
148+ . enrollment_secret
149+ . as_deref ( )
150+ . ok_or_else ( || HttpError :: not_found ( ) . msg ( "agent enrollment is not configured" ) ) ?;
151+
152+ if !timing_safe_eq ( provided_token. as_bytes ( ) , enrollment_secret. as_bytes ( ) ) {
153+ return Err ( HttpError :: forbidden ( ) . msg ( "invalid enrollment token" ) ) ;
159154 }
160155 }
161156
@@ -257,126 +252,6 @@ async fn delete_agent(
257252 Ok ( axum:: http:: StatusCode :: NO_CONTENT )
258253}
259254
260- // ---------------------------------------------------------------------------
261- // Enrollment string generation (one-time token for agent bootstrap).
262- // ---------------------------------------------------------------------------
263-
264- #[ derive( Debug , Deserialize ) ]
265- pub ( crate ) struct AgentEnrollmentStringRequest {
266- /// Base URL for the gateway API (e.g. `https://gateway.example.com`).
267- api_base_url : String ,
268- /// Optional QUIC host override. Defaults to the host extracted from
269- /// `api_base_url`. If neither yields a host the request is rejected with
270- /// `400`; the gateway's configured hostname is intentionally not used as
271- /// a fallback because in containerized deployments it is typically a
272- /// container ID the agent cannot dial.
273- quic_host : Option < String > ,
274- /// Optional agent name hint.
275- name : Option < String > ,
276- /// Token lifetime in seconds (default: 3600).
277- lifetime : Option < u64 > ,
278- }
279-
280- #[ derive( Debug , Serialize ) ]
281- pub ( crate ) struct AgentEnrollmentStringResponse {
282- enrollment_string : String ,
283- enrollment_command : String ,
284- quic_endpoint : String ,
285- expires_at_unix : u64 ,
286- }
287-
288- /// Generate a one-time enrollment string for agent bootstrap.
289- ///
290- /// Accepts scope tokens with `AgentEnroll`, `ConfigWrite`, or `Wildcard` scope
291- /// via [`AgentManagementWriteAccess`]. DVLS signs scope tokens with
292- /// `AgentEnroll` specifically; other callers may use the broader
293- /// `ConfigWrite` for back-compat.
294- async fn create_agent_enrollment_string (
295- State ( DgwState {
296- conf_handle,
297- agent_tunnel_handle,
298- ..
299- } ) : State < DgwState > ,
300- _access : AgentManagementWriteAccess ,
301- Json ( req) : Json < AgentEnrollmentStringRequest > ,
302- ) -> Result < Json < AgentEnrollmentStringResponse > , HttpError > {
303- use base64:: Engine as _;
304-
305- let conf = conf_handle. get_conf ( ) ;
306-
307- let handle = agent_tunnel_handle
308- . as_ref ( )
309- . ok_or_else ( || HttpError :: not_found ( ) . msg ( "agent tunnel not configured" ) ) ?;
310-
311- let lifetime_secs = req. lifetime . unwrap_or ( 3600 ) ;
312-
313- // Reject obviously bogus lifetimes up-front so we never insert a token with
314- // a poisoned expiry. The store and the response both compute
315- // `now + lifetime`, both u64 additions; clamp here to give an early 400.
316- let now_secs = std:: time:: SystemTime :: now ( )
317- . duration_since ( std:: time:: UNIX_EPOCH )
318- . unwrap_or_default ( )
319- . as_secs ( ) ;
320- let expires_at_unix = now_secs
321- . checked_add ( lifetime_secs)
322- . ok_or_else ( || HttpError :: bad_request ( ) . msg ( "lifetime is too large" ) ) ?;
323-
324- // Determine QUIC host: explicit override > host extracted from api_base_url.
325- // We deliberately do NOT fall back to `conf.hostname`: in Docker/K8s that is
326- // typically a container ID or pod name not resolvable by the agent, so a
327- // silent fallback would emit an enrollment string the agent cannot use.
328- // Force the caller to either supply `quic_host` or pass an `api_base_url`
329- // we can parse a host out of.
330- let quic_host = match req. quic_host . as_deref ( ) . filter ( |h| !h. is_empty ( ) ) {
331- Some ( host) => host. to_owned ( ) ,
332- None => url:: Url :: parse ( & req. api_base_url )
333- . ok ( )
334- . and_then ( |u| u. host_str ( ) . map ( ToOwned :: to_owned) )
335- . ok_or_else ( || {
336- HttpError :: bad_request ( )
337- . msg ( "could not derive QUIC host: api_base_url has no host component, pass `quic_host` explicitly" )
338- } ) ?,
339- } ;
340- let quic_endpoint = format ! ( "{quic_host}:{}" , conf. agent_tunnel. listen_port) ;
341-
342- // Generate a one-time enrollment token stored server-side. Done after the
343- // quic_host validation so a 400 response does not pollute the store.
344- let enrollment_token = Uuid :: new_v4 ( ) . to_string ( ) ;
345- handle
346- . enrollment_token_store ( )
347- . insert ( enrollment_token. clone ( ) , req. name . clone ( ) , Some ( lifetime_secs) )
348- . await ;
349-
350- // Build the enrollment payload.
351- let payload = serde_json:: json!( {
352- "version" : 1 ,
353- "api_base_url" : req. api_base_url,
354- "quic_endpoint" : quic_endpoint,
355- "enrollment_token" : enrollment_token,
356- "name" : req. name,
357- } ) ;
358-
359- let payload_json = serde_json:: to_string ( & payload)
360- . map_err ( HttpError :: internal ( ) . with_msg ( "serialize enrollment payload" ) . err ( ) ) ?;
361-
362- let encoded = base64:: engine:: general_purpose:: URL_SAFE_NO_PAD . encode ( payload_json. as_bytes ( ) ) ;
363- let enrollment_string = format ! ( "dgw-enroll:v1:{encoded}" ) ;
364- let enrollment_command = format ! ( "devolutions-agent up --enrollment-string \" {enrollment_string}\" " ) ;
365-
366- info ! (
367- agent_name = ?req. name,
368- lifetime_secs,
369- "Generated agent enrollment string"
370- ) ;
371-
372- Ok ( Json ( AgentEnrollmentStringResponse {
373- enrollment_string,
374- enrollment_command,
375- quic_endpoint,
376- expires_at_unix,
377- } ) )
378- }
379-
380255#[ cfg( test) ]
381256mod tests {
382257 use picky:: jose:: jws:: JwsAlg ;
0 commit comments