Skip to content

Commit b7c000e

Browse files
refactor(agent-tunnel): drop server-side enrollment-string mint
The `POST /jet/tunnel/enrollment-string` endpoint had the gateway generating, storing, and returning a UUID enrollment token wrapped in a `dgw-enroll:v1:<base64-json>` envelope. That put token issuance and state where it does not belong: in Devolutions' architecture, DVLS is the only authority for tokens (it holds the provisioner private key), the gateway is a stateless verifier (it holds the public key). The in-memory `EnrollmentTokenStore` also broke HA — an agent could redeem its token only against the specific gateway node that minted it, and a gateway restart silently invalidated all unredeemed tokens. The same `/jet/tunnel/enroll` handler already accepts a JWT scope token (`TunnelEnroll` / `Wildcard`) signed by the provisioner key, which is the correct path: DVLS signs, the agent presents the JWT, the gateway verifies statelessly. With the redundant path removed, the gateway no longer mints tokens at all. Removes the route, the request/response types, the `EnrollmentTokenStore` itself, and the corresponding branch from the `enroll` handler. The `gateway.agent.enroll` scope is kept (DVLS signs `TunnelEnroll` JWTs with that scope on its scope-token path) and so is the static `enrollment_secret` fallback for environments without DVLS.
1 parent da3f54b commit b7c000e

4 files changed

Lines changed: 15 additions & 288 deletions

File tree

crates/agent-tunnel/src/enrollment_store.rs

Lines changed: 0 additions & 138 deletions
This file was deleted.

crates/agent-tunnel/src/lib.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,11 @@
77
extern crate tracing;
88

99
pub mod cert;
10-
pub mod enrollment_store;
1110
pub mod listener;
1211
pub mod registry;
1312
pub mod routing;
1413
pub mod stream;
1514

16-
pub use enrollment_store::EnrollmentTokenStore;
1715
pub use listener::{AgentTunnelHandle, AgentTunnelListener};
1816
pub use registry::AgentRegistry;
1917
pub use stream::TunnelStream;

crates/agent-tunnel/src/listener.rs

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ use tokio::sync::RwLock;
1616
use uuid::Uuid;
1717

1818
use super::cert::CaManager;
19-
use super::enrollment_store::EnrollmentTokenStore;
2019
use super::registry::{AgentPeer, AgentRegistry};
2120
use super::stream::TunnelStream;
2221

@@ -33,7 +32,6 @@ pub struct AgentTunnelHandle {
3332
/// Map of agent_id → live Quinn connection, used for opening new streams.
3433
agent_connections: Arc<RwLock<HashMap<Uuid, quinn::Connection>>>,
3534
ca_manager: Arc<CaManager>,
36-
enrollment_token_store: Arc<EnrollmentTokenStore>,
3735
}
3836

3937
impl AgentTunnelHandle {
@@ -45,10 +43,6 @@ impl AgentTunnelHandle {
4543
&self.ca_manager
4644
}
4745

48-
pub fn enrollment_token_store(&self) -> &EnrollmentTokenStore {
49-
&self.enrollment_token_store
50-
}
51-
5246
/// Open a proxy stream through a connected agent.
5347
// TODO: Emit TrafficEvent for connections routed through the agent tunnel.
5448
pub async fn connect_via_agent(
@@ -150,13 +144,11 @@ impl AgentTunnelListener {
150144

151145
let registry = Arc::new(AgentRegistry::new());
152146
let agent_connections: Arc<RwLock<HashMap<Uuid, quinn::Connection>>> = Arc::new(RwLock::new(HashMap::new()));
153-
let enrollment_token_store = Arc::new(EnrollmentTokenStore::new());
154147

155148
let handle = AgentTunnelHandle {
156149
registry: Arc::clone(&registry),
157150
agent_connections: Arc::clone(&agent_connections),
158151
ca_manager: Arc::clone(&ca_manager),
159-
enrollment_token_store,
160152
};
161153

162154
let listener = Self {

devolutions-gateway/src/api/tunnel.rs

Lines changed: 15 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -88,19 +88,19 @@ pub struct EnrollResponse {
8888
pub 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)]
381256
mod tests {
382257
use picky::jose::jws::JwsAlg;

0 commit comments

Comments
 (0)