Skip to content

Commit b00ffbe

Browse files
feat(agent-tunnel): admin-facing /jet/tunnel/enrollment-string endpoint
Adds the path that DVLS (and any other authenticated admin UI) uses to bootstrap new agents: POST a JSON body → receive an `devolutions-agent up --enrollment-string "dgw-enroll:v1:…"` command ready to paste on the target machine. - Gateway mints a one-time enrollment token stored server-side, then encodes `{ api_base_url, quic_endpoint, enrollment_token, name }` into a base64url payload prefixed with `dgw-enroll:v1:`. The agent decodes this string and posts the token as a Bearer on `/jet/tunnel/enroll`. - The endpoint derives the QUIC endpoint from the caller-supplied api_base_url (operator knows the externally reachable host) falling back to conf.hostname. A running gateway cannot self-discover its externally reachable address — see `EnrollmentJwtClaims::jet_quic_endpoint`. Adds two new canonical `AccessScope` variants that callers should prefer for admin-tunnel operations: - `AccessScope::AgentEnroll` (serde `gateway.agent.enroll`) — for minting enrollment strings and other write operations on the tunnel. - `AccessScope::AgentRead` (serde `gateway.agent.read`) — for reading the connected agents list and status. `AgentManagementWriteAccess` now accepts `AgentEnroll | ConfigWrite | Wildcard`; `AgentManagementReadAccess` accepts `AgentRead | DiagnosticsRead | ConfigWrite | Wildcard`. The broader existing scopes are retained for back-compat with any caller that predates the dedicated agent scopes.
1 parent d7a61ec commit b00ffbe

3 files changed

Lines changed: 125 additions & 5 deletions

File tree

devolutions-gateway/src/api/tunnel.rs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use axum::extract::{Path, State};
22
use axum::http::HeaderMap;
33
use axum::{Json, Router};
4+
use serde::{Deserialize, Serialize};
45
use uuid::Uuid;
56

67
use crate::DgwState;
@@ -87,6 +88,7 @@ pub struct EnrollResponse {
8788
pub fn make_router<S>(state: DgwState) -> Router<S> {
8889
Router::new()
8990
.route("/enroll", axum::routing::post(enroll_agent))
91+
.route("/enrollment-string", axum::routing::post(create_agent_enrollment_string))
9092
.route("/agents", axum::routing::get(list_agents))
9193
.route("/agents/{agent_id}", axum::routing::get(get_agent).delete(delete_agent))
9294
.with_state(state)
@@ -252,6 +254,111 @@ async fn delete_agent(
252254
Ok(axum::http::StatusCode::NO_CONTENT)
253255
}
254256

257+
// ---------------------------------------------------------------------------
258+
// Enrollment string generation (one-time token for agent bootstrap).
259+
// ---------------------------------------------------------------------------
260+
261+
#[derive(Debug, Deserialize)]
262+
pub(crate) struct AgentEnrollmentStringRequest {
263+
/// Base URL for the gateway API (e.g. `https://gateway.example.com`).
264+
api_base_url: String,
265+
/// Optional QUIC host override. Defaults to the host extracted from
266+
/// `api_base_url`, falling back to the gateway's configured hostname.
267+
quic_host: Option<String>,
268+
/// Optional agent name hint.
269+
name: Option<String>,
270+
/// Token lifetime in seconds (default: 3600).
271+
lifetime: Option<u64>,
272+
}
273+
274+
#[derive(Debug, Serialize)]
275+
pub(crate) struct AgentEnrollmentStringResponse {
276+
enrollment_string: String,
277+
enrollment_command: String,
278+
quic_endpoint: String,
279+
expires_at_unix: u64,
280+
}
281+
282+
/// Generate a one-time enrollment string for agent bootstrap.
283+
///
284+
/// Accepts scope tokens with `AgentEnroll`, `ConfigWrite`, or `Wildcard` scope
285+
/// via [`AgentManagementWriteAccess`]. DVLS signs scope tokens with
286+
/// `AgentEnroll` specifically; other callers may use the broader
287+
/// `ConfigWrite` for back-compat.
288+
async fn create_agent_enrollment_string(
289+
State(DgwState {
290+
conf_handle,
291+
agent_tunnel_handle,
292+
..
293+
}): State<DgwState>,
294+
_access: AgentManagementWriteAccess,
295+
Json(req): Json<AgentEnrollmentStringRequest>,
296+
) -> Result<Json<AgentEnrollmentStringResponse>, HttpError> {
297+
use base64::Engine as _;
298+
299+
let conf = conf_handle.get_conf();
300+
301+
let handle = agent_tunnel_handle
302+
.as_ref()
303+
.ok_or_else(|| HttpError::not_found().msg("agent tunnel not configured"))?;
304+
305+
let lifetime_secs = req.lifetime.unwrap_or(3600);
306+
307+
// Generate a one-time enrollment token stored server-side.
308+
let enrollment_token = Uuid::new_v4().to_string();
309+
handle
310+
.enrollment_token_store()
311+
.insert(enrollment_token.clone(), req.name.clone(), Some(lifetime_secs))
312+
.await;
313+
314+
// Determine QUIC host: explicit override > extract from api_base_url > gateway hostname config.
315+
// The gateway hostname config is often a container ID in Docker, so we prefer
316+
// extracting the host from the api_base_url which the caller already knows is reachable.
317+
let quic_host = match req.quic_host.as_deref().filter(|h| !h.is_empty()) {
318+
Some(host) => host.to_owned(),
319+
None => url::Url::parse(&req.api_base_url)
320+
.ok()
321+
.and_then(|u| u.host_str().map(ToOwned::to_owned))
322+
.unwrap_or_else(|| conf.hostname.clone()),
323+
};
324+
let quic_endpoint = format!("{quic_host}:{}", conf.agent_tunnel.listen_port);
325+
326+
// Build the enrollment payload.
327+
let payload = serde_json::json!({
328+
"version": 1,
329+
"api_base_url": req.api_base_url,
330+
"quic_endpoint": quic_endpoint,
331+
"enrollment_token": enrollment_token,
332+
"name": req.name,
333+
});
334+
335+
let payload_json = serde_json::to_string(&payload)
336+
.map_err(HttpError::internal().with_msg("serialize enrollment payload").err())?;
337+
338+
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
339+
let enrollment_string = format!("dgw-enroll:v1:{encoded}");
340+
let enrollment_command = format!("devolutions-agent up --enrollment-string \"{enrollment_string}\"");
341+
342+
let now_secs = std::time::SystemTime::now()
343+
.duration_since(std::time::UNIX_EPOCH)
344+
.unwrap_or_default()
345+
.as_secs();
346+
let expires_at_unix = now_secs + lifetime_secs;
347+
348+
info!(
349+
agent_name = ?req.name,
350+
lifetime_secs,
351+
"Generated agent enrollment string"
352+
);
353+
354+
Ok(Json(AgentEnrollmentStringResponse {
355+
enrollment_string,
356+
enrollment_command,
357+
quic_endpoint,
358+
expires_at_unix,
359+
}))
360+
}
361+
255362
#[cfg(test)]
256363
mod tests {
257364
use picky::jose::jws::JwsAlg;

devolutions-gateway/src/extract.rs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -424,11 +424,16 @@ where
424424
.map_err(HttpError::internal().err())?
425425
.0;
426426

427-
// DiagnosticsRead is accepted because DVLS maps its AgentRead scope
428-
// to GatewayDiagnosticsRead, which serializes as "gateway.diagnostics.read".
427+
// Accepted scopes:
428+
// - AgentRead: the canonical scope for this endpoint (DVLS uses this).
429+
// - DiagnosticsRead / ConfigWrite: back-compat for older callers that predate
430+
// the dedicated agent scopes; safe because both imply broader privileges.
429431
match claims {
430432
AccessTokenClaims::Scope(scope) => match scope.scope {
431-
AccessScope::Wildcard | AccessScope::DiagnosticsRead | AccessScope::ConfigWrite => Ok(Self),
433+
AccessScope::Wildcard
434+
| AccessScope::AgentRead
435+
| AccessScope::DiagnosticsRead
436+
| AccessScope::ConfigWrite => Ok(Self),
432437
_ => Err(HttpError::forbidden().msg("invalid scope for agent management read")),
433438
},
434439
_ => Err(HttpError::forbidden().msg("scope token required for agent management read")),
@@ -438,7 +443,7 @@ where
438443

439444
/// Grants write access to agent management endpoints (e.g. enrollment, delete).
440445
///
441-
/// Accepts scope tokens with `ConfigWrite` (or `Wildcard`) scope only.
446+
/// Accepts scope tokens with `AgentEnroll`, `ConfigWrite`, or `Wildcard` scope.
442447
#[derive(Clone, Copy)]
443448
pub struct AgentManagementWriteAccess;
444449

@@ -454,9 +459,13 @@ where
454459
.map_err(HttpError::internal().err())?
455460
.0;
456461

462+
// Accepted scopes:
463+
// - AgentEnroll: the canonical scope for this endpoint (DVLS uses this).
464+
// - ConfigWrite: back-compat for older callers that predate the
465+
// dedicated agent scope.
457466
match claims {
458467
AccessTokenClaims::Scope(scope) => match scope.scope {
459-
AccessScope::Wildcard | AccessScope::ConfigWrite => Ok(Self),
468+
AccessScope::Wildcard | AccessScope::AgentEnroll | AccessScope::ConfigWrite => Ok(Self),
460469
_ => Err(HttpError::forbidden().msg("invalid scope for agent management write")),
461470
},
462471
_ => Err(HttpError::forbidden().msg("scope token required for agent management write")),

devolutions-gateway/src/token.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,10 @@ pub enum AccessScope {
474474
NetMonitorDrain,
475475
#[serde(rename = "gateway.tunnel.enroll")]
476476
TunnelEnroll,
477+
#[serde(rename = "gateway.agent.enroll")]
478+
AgentEnroll,
479+
#[serde(rename = "gateway.agent.read")]
480+
AgentRead,
477481
}
478482

479483
#[derive(Clone, Serialize, Deserialize)]

0 commit comments

Comments
 (0)