Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions devolutions-agent/src/enrollment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ pub async fn enroll_agent(
agent_name: &str,
advertise_subnets: Vec<String>,
) -> anyhow::Result<()> {
bootstrap_and_persist(gateway_url, enrollment_token, agent_name, advertise_subnets).await?;
bootstrap_and_persist(gateway_url, enrollment_token, agent_name, advertise_subnets, None).await?;
Ok(())
}

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

let enroll_response = request_enrollment(gateway_url, enrollment_token, agent_name, &csr_pem).await?;
let mut enroll_response = request_enrollment(gateway_url, enrollment_token, agent_name, &csr_pem).await?;

// Prefer the QUIC endpoint from the enrollment string (set by the admin who knows
// the reachable address) over the enroll API response (which uses conf.hostname,
// often a container ID in Docker).
if let Some(endpoint) = quic_endpoint_override {
enroll_response.quic_endpoint = endpoint;
}

persist_enrollment_response(advertise_subnets, enroll_response, &key_pem)
}

Expand Down
8 changes: 8 additions & 0 deletions devolutions-agent/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ struct UpCommand {
enrollment_token: String,
agent_name: String,
advertise_subnets: Vec<String>,
quic_endpoint_override: Option<String>,
}

#[derive(Debug, serde::Deserialize)]
Expand All @@ -69,6 +70,8 @@ struct EnrollmentStringPayload {
enrollment_token: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
quic_endpoint: Option<String>,
}

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

let mut quic_endpoint_override = None;

if let Some(enrollment_string) = enrollment_string {
let payload = parse_enrollment_string(&enrollment_string)?;

gateway_url.get_or_insert(payload.api_base_url);
enrollment_token.get_or_insert(payload.enrollment_token);
quic_endpoint_override = payload.quic_endpoint;

if agent_name.is_none() {
agent_name = payload.name;
Expand All @@ -215,6 +221,7 @@ fn parse_up_command_args(args: &[String]) -> Result<UpCommand> {
enrollment_token: enrollment_token.context("missing required --token")?,
agent_name: agent_name.context("missing required --name")?,
advertise_subnets,
quic_endpoint_override,
})
}

Expand Down Expand Up @@ -306,6 +313,7 @@ fn main() {
&command.enrollment_token,
&command.agent_name,
command.advertise_subnets,
command.quic_endpoint_override,
)
.await
});
Expand Down
4 changes: 4 additions & 0 deletions devolutions-gateway/src/api/agent_enrollment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ pub fn make_router<S>(state: DgwState) -> Router<S> {
.route("/agents", axum::routing::get(list_agents))
.route("/agents/{agent_id}", axum::routing::get(get_agent).delete(delete_agent))
.route("/agents/resolve-target", axum::routing::post(resolve_target))
.route(
"/enrollment-string",
axum::routing::post(super::webapp::create_agent_enrollment_string),
)
.with_state(state)
}

Expand Down
164 changes: 163 additions & 1 deletion devolutions-gateway/src/api/webapp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pub fn make_router<S>(state: DgwState) -> Router<S> {
.route("/client/{*path}", get(get_client))
.route("/app-token", post(sign_app_token))
.route("/session-token", post(sign_session_token))
.route("/agent-management-token", post(sign_agent_management_token))
} else {
Router::new()
}
Expand Down Expand Up @@ -232,6 +233,9 @@ pub(crate) enum SessionTokenContentType {
destination: TargetAddr,
/// Unique ID for this session
session_id: Uuid,
/// Optional agent ID for routing through an enrolled agent tunnel.
#[serde(default)]
agent_id: Option<Uuid>,
},
Jmux {
/// Protocol for the session (e.g.: "tunnel")
Expand Down Expand Up @@ -328,6 +332,7 @@ pub(crate) async fn sign_session_token(
protocol,
destination,
session_id,
agent_id,
} => (
AssociationTokenClaims {
jet_aid: session_id,
Expand All @@ -342,7 +347,7 @@ pub(crate) async fn sign_session_token(
exp,
jti,
cert_thumb256: None,
jet_agent_id: None,
jet_agent_id: agent_id,
}
.pipe(serde_json::to_value)
.map(|mut claims| {
Expand Down Expand Up @@ -456,6 +461,63 @@ pub(crate) async fn sign_session_token(
Ok(response)
}

/// Exchange a WebApp token for an agent management scope token.
///
/// This mirrors the DVLS pattern: DVLS signs scope tokens with its RSA key,
/// while the standalone webapp exchanges its WebApp token for a scope token here.
/// Both paths produce the same token type, so agent tunnel endpoints have
/// a single auth model (scope tokens only).
async fn sign_agent_management_token(
State(DgwState { conf_handle, .. }): State<DgwState>,
WebAppToken(web_app_token): WebAppToken,
) -> Result<Response, HttpError> {
use picky::jose::jws::JwsAlg;
use picky::jose::jwt::CheckedJwtSig;

use crate::token::{AccessScope, ScopeTokenClaims};

const LIFETIME_SECS: i64 = 300; // 5 minutes, same as DVLS scope tokens

let conf = conf_handle.get_conf();

let provisioner_key = conf
.provisioner_private_key
.as_ref()
.ok_or_else(|| HttpError::internal().msg("provisioner private key is missing"))?;

ensure_enabled(&conf)?;

let now = time::OffsetDateTime::now_utc().unix_timestamp();

let claims = ScopeTokenClaims {
scope: AccessScope::ConfigWrite,
exp: now + LIFETIME_SECS,
jti: Uuid::new_v4(),
}
.pipe(serde_json::to_value)
.map(|mut claims| {
if let Some(claims) = claims.as_object_mut() {
claims.insert("iat".to_owned(), serde_json::json!(now));
claims.insert("nbf".to_owned(), serde_json::json!(now));
}
claims
})
.map_err(HttpError::internal().with_msg("scope claims").err())?;

let jwt_sig = CheckedJwtSig::new_with_cty(JwsAlg::RS256, "SCOPE".to_owned(), claims);

let token = jwt_sig
.encode(provisioner_key)
.map_err(HttpError::internal().with_msg("sign agent management token").err())?;

info!(user = web_app_token.sub, "Granted agent management scope token");

let cache_control = TypedHeader(headers::CacheControl::new().with_no_cache().with_no_store());
let response = (cache_control, token).into_response();

Ok(response)
}

async fn get_client<ReqBody>(
State(DgwState { conf_handle, .. }): State<DgwState>,
path: Option<extract::Path<String>>,
Expand Down Expand Up @@ -504,6 +566,106 @@ fn ensure_enabled(conf: &crate::config::Conf) -> Result<(), HttpError> {
extract_conf(conf).map(|_| ())
}

// -- Agent enrollment string generation -- //

#[derive(Debug, Deserialize)]
pub(crate) struct AgentEnrollmentStringRequest {
/// Base URL for the gateway API (e.g. `https://gateway.example.com`).
api_base_url: String,
/// Optional QUIC host override. Defaults to the gateway hostname.
quic_host: Option<String>,
/// Optional agent name hint.
name: Option<String>,
/// Token lifetime in seconds (default: 3600).
lifetime: Option<u64>,
}

#[derive(Debug, Serialize)]
pub(crate) struct AgentEnrollmentStringResponse {
enrollment_string: String,
enrollment_command: String,
quic_endpoint: String,
expires_at_unix: u64,
}

/// Generate a one-time enrollment string for agent enrollment.
///
/// Accepts scope tokens with `ConfigWrite` scope only. Both the standalone
/// webapp (via `/jet/webapp/agent-management-token` exchange) and DVLS
/// (via direct RSA-signed scope tokens) produce the same token type.
pub(crate) async fn create_agent_enrollment_string(
State(DgwState {
conf_handle,
agent_tunnel_handle,
..
}): State<DgwState>,
_access: crate::extract::AgentManagementWriteAccess,
Json(req): Json<AgentEnrollmentStringRequest>,
) -> Result<Json<AgentEnrollmentStringResponse>, HttpError> {
use base64::Engine as _;

let conf = conf_handle.get_conf();

let handle = agent_tunnel_handle
.as_ref()
.ok_or_else(|| HttpError::not_found().msg("agent tunnel not configured"))?;

let lifetime_secs = req.lifetime.unwrap_or(3600);

// Generate a one-time enrollment token.
let enrollment_token = Uuid::new_v4().to_string();
handle
.enrollment_token_store()
.insert(enrollment_token.clone(), req.name.clone(), Some(lifetime_secs));

// Determine QUIC host: explicit override > extract from api_base_url > gateway hostname config.
// The gateway hostname config is often a container ID in Docker, so we prefer
// extracting the host from the api_base_url which the caller already knows is reachable.
let quic_host = match req.quic_host.as_deref().filter(|h| !h.is_empty()) {
Some(host) => host.to_owned(),
None => url::Url::parse(&req.api_base_url)
.ok()
.and_then(|u| u.host_str().map(ToOwned::to_owned))
.unwrap_or_else(|| conf.hostname.clone()),
};
let quic_endpoint = format!("{quic_host}:{}", conf.agent_tunnel.listen_port);

// Build the enrollment payload.
let payload = serde_json::json!({
"version": 1,
"api_base_url": req.api_base_url,
"quic_endpoint": quic_endpoint,
"enrollment_token": enrollment_token,
"name": req.name,
});

let payload_json = serde_json::to_string(&payload)
.map_err(HttpError::internal().with_msg("serialize enrollment payload").err())?;

let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
let enrollment_string = format!("dgw-enroll:v1:{encoded}");
let enrollment_command = format!("devolutions-agent up --enrollment-string \"{enrollment_string}\"");

let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let expires_at_unix = now_secs + lifetime_secs;

info!(
agent_name = ?req.name,
lifetime_secs,
"Generated agent enrollment string"
);

Ok(Json(AgentEnrollmentStringResponse {
enrollment_string,
enrollment_command,
quic_endpoint,
expires_at_unix,
}))
}

mod login_rate_limit {
use std::collections::HashMap;
use std::net::IpAddr;
Expand Down
5 changes: 4 additions & 1 deletion devolutions-gateway/src/extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,8 @@ where

/// Grants read access to agent management endpoints.
///
/// Accepts a scope token with `DiagnosticsRead`, `ConfigWrite`, or `Wildcard` scope.
/// Accepts either a scope token with `DiagnosticsRead` (or `Wildcard`) scope,
/// or a valid `WebApp` token.
#[derive(Clone, Copy)]
pub struct AgentManagementReadAccess;

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

Expand Down
24 changes: 16 additions & 8 deletions webapp/apps/gateway-ui/proxy.conf.json
Original file line number Diff line number Diff line change
@@ -1,33 +1,41 @@
{
"/jet/webapp/app-token": {
"target": "http://localhost:7171",
"target": "http://127.0.0.1:7272",
"secure": false
},
"/jet/webapp/session-token": {
"target": "http://localhost:7171",
"target": "http://127.0.0.1:7272",
"secure": false
},
"/jet/webapp/agent-management-token": {
"target": "http://127.0.0.1:7272",
"secure": false
},
"/jet/rdp": {
"target": "http://localhost:7171",
"target": "http://127.0.0.1:7272",
"secure": false,
"ws": true
},
"/jet/fwd/tcp": {
"target": "http://localhost:7171",
"target": "http://127.0.0.1:7272",
"secure": false,
"ws": true
},
"/jet/KdcProxy": {
"target": "http://localhost:7171",
"target": "http://127.0.0.1:7272",
"secure": false
},
"/jet/health": {
"target": "http://localhost:7171",
"target": "http://127.0.0.1:7272",
"secure": false
},
"/jet/net/scan": {
"target": "http://localhost:7171",
"target": "http://127.0.0.1:7272",
"secure": false,
"ws": true
},
"/jet/agent-tunnel": {
"target": "http://127.0.0.1:7272",
"secure": false
}
}
}
3 changes: 3 additions & 0 deletions webapp/apps/gateway-ui/src/client/app/app-auth.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export class AuthInterceptor implements HttpInterceptor {
// If the request is for the app token, we don't need to add the Authorization header
const goToNext = [];
goToNext.push(req.url.endsWith(this.appTokenUrl));
// Requests that already carry their own Authorization header (e.g. agent tunnel
// endpoints using scope tokens) should not be overwritten by the app token.
goToNext.push(req.headers.has('Authorization'));

// If the requesting third party host, we don't need to add the Authorization header
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
<gateway-menu-list-item *ngFor="let menuKVP of mainMenus | keyvalue: asIsOrder"
[label]="menuKVP.value.label"
[icon]="menuKVP.value.icon"
[iconOnly]="isMenuSlim"></gateway-menu-list-item>
[iconOnly]="isMenuSlim"
(click)="menuKVP.value.executeAction()"></gateway-menu-list-item>

<gateway-menu-list-active-sessions [isMenuSlim]="isMenuSlim">
</gateway-menu-list-active-sessions>
Expand Down
Loading
Loading