Skip to content

Commit d112b91

Browse files
refactor(pr2): trim cert renewal and JWT enrollment refactor
PR #1741 was reviewed as too large. Reduce its scope to A+B (refactor + transparent routing) by backing out the cert-renewal additions (C) and the JWT-based enrollment pivot (D). Both will be opened as their own PRs against master. Cert renewal (C) removed: - Agent-side: drop the pre-loop expiry check, periodic cert_expiry_tick in the main select! loop, ConnectionOutcome enum, and the `is_cert_expiring` / `read_agent_name_from_cert` / `generate_csr_from_existing_key` helpers from enrollment.rs. - Gateway-side: drop the agent's ability to drive renewal; the CertRenewal proto messages stay (they exist on master from #1738) and the listener keeps the stub debug-and-drop arm. AGENT_CERT_VALIDITY_DAYS reverts to 365. JWT enrollment refactor (D) removed: - Gateway: revert token.rs (TunnelEnroll only, no AgentEnroll/AgentRead), extract.rs (no AgentManagement scope unions), and api/tunnel.rs to master (EnrollmentTokenStore-backed enroll handler with quic_endpoint in the response). - Agent-tunnel crate: restore enrollment_store module + handle getter + registration in bind(). - Agent CLI: revert main.rs and cli_tests.rs to before --advertise-domains (config-side advertise_domains support stays, only the CLI flag goes). Test JWTs go back to gateway.tunnel.enroll scope. - NuGet: delete EnrollmentClaims.cs, drop GatewayAgentEnroll/Read from AccessScope.cs, revert csproj version, drop the new JsonSerializationTests cases.
1 parent b513422 commit d112b91

16 files changed

Lines changed: 213 additions & 432 deletions

File tree

crates/agent-tunnel-proto/src/control.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ pub enum ControlMessage {
6060
protocol_version: u16,
6161
/// Monotonically increasing epoch within this agent process lifetime.
6262
epoch: u64,
63-
/// Reachable IPv4 subnets.
63+
/// Reachable subnets (IPv4 and IPv6).
6464
subnets: Vec<Ipv4Network>,
6565
/// DNS domains this agent can resolve, with source tracking.
6666
domains: Vec<DomainAdvertisement>,

crates/agent-tunnel/src/cert.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ const SERVER_CERT_FILENAME: &str = "agent-tunnel-server-cert.pem";
9494
const SERVER_KEY_FILENAME: &str = "agent-tunnel-server-key.pem";
9595
const CA_VALIDITY_DAYS: u32 = 3650; // ~10 years
9696
const SERVER_CERT_VALIDITY_DAYS: u32 = 365; // 1 year
97-
const AGENT_CERT_VALIDITY_DAYS: u32 = 30; // 30 days (short-lived, auto-renewed)
97+
const AGENT_CERT_VALIDITY_DAYS: u32 = 365; // 1 year
9898

9999
const SECS_PER_DAY: u64 = 86_400;
100100
const CA_COMMON_NAME: &str = "Devolutions Gateway Agent Tunnel CA";
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
//! In-memory store for one-time enrollment tokens.
2+
//!
3+
//! Tokens are generated by the webapp enrollment-string endpoint and redeemed
4+
//! by the agent enrollment endpoint. Each token is single-use and has an expiry.
5+
6+
use std::collections::HashMap;
7+
use std::time::{SystemTime, UNIX_EPOCH};
8+
9+
use tokio::sync::RwLock;
10+
11+
/// Default token lifetime if not specified: 1 hour.
12+
const DEFAULT_TOKEN_LIFETIME_SECS: u64 = 3600;
13+
14+
/// A single enrollment token entry.
15+
#[derive(Debug, Clone)]
16+
pub struct EnrollmentTokenEntry {
17+
/// When this token expires (UNIX timestamp in seconds).
18+
pub expires_at: u64,
19+
/// Optional agent name hint associated with this token.
20+
pub agent_name: Option<String>,
21+
}
22+
23+
/// Thread-safe in-memory store for one-time enrollment tokens.
24+
///
25+
/// Tokens are stored in a locked `HashMap` keyed by the token string.
26+
/// They are removed on consumption (one-time use) or on explicit cleanup.
27+
#[derive(Debug)]
28+
pub struct EnrollmentTokenStore {
29+
tokens: RwLock<HashMap<String, EnrollmentTokenEntry>>,
30+
}
31+
32+
impl EnrollmentTokenStore {
33+
/// Creates a new, empty token store.
34+
pub fn new() -> Self {
35+
Self {
36+
tokens: RwLock::new(HashMap::new()),
37+
}
38+
}
39+
40+
/// Inserts a new enrollment token.
41+
///
42+
/// Also cleans up any expired tokens to prevent unbounded growth.
43+
pub async fn insert(&self, token: String, agent_name: Option<String>, lifetime_secs: Option<u64>) {
44+
self.cleanup_expired().await;
45+
46+
let lifetime = lifetime_secs.unwrap_or(DEFAULT_TOKEN_LIFETIME_SECS);
47+
let now = current_time_secs();
48+
let expires_at = now + lifetime;
49+
50+
self.tokens
51+
.write()
52+
.await
53+
.insert(token, EnrollmentTokenEntry { expires_at, agent_name });
54+
}
55+
56+
/// Consumes a token if it exists and is not expired.
57+
///
58+
/// Returns `true` if the token was valid and has been redeemed (removed).
59+
/// Returns `false` if the token doesn't exist or is expired.
60+
///
61+
/// Note: an expired token is still removed from the store as a side-effect,
62+
/// so a caller cannot distinguish "didn't exist" from "existed-but-expired"
63+
/// (and shouldn't — both are authentication failures).
64+
#[must_use = "check whether the token was valid"]
65+
pub async fn redeem(&self, token: &str) -> bool {
66+
let now = current_time_secs();
67+
68+
if let Some(entry) = self.tokens.write().await.remove(token)
69+
&& entry.expires_at > now
70+
{
71+
return true;
72+
}
73+
74+
false
75+
}
76+
77+
/// Removes all expired tokens from the store.
78+
pub async fn cleanup_expired(&self) {
79+
let now = current_time_secs();
80+
self.tokens.write().await.retain(|_, entry| entry.expires_at > now);
81+
}
82+
}
83+
84+
impl Default for EnrollmentTokenStore {
85+
fn default() -> Self {
86+
Self::new()
87+
}
88+
}
89+
90+
fn current_time_secs() -> u64 {
91+
SystemTime::now()
92+
.duration_since(UNIX_EPOCH)
93+
.unwrap_or_default()
94+
.as_secs()
95+
}
96+
97+
#[cfg(test)]
98+
mod tests {
99+
use super::*;
100+
101+
#[tokio::test]
102+
async fn insert_and_redeem() {
103+
let store = EnrollmentTokenStore::new();
104+
store
105+
.insert("tok-123".to_owned(), Some("my-agent".to_owned()), Some(3600))
106+
.await;
107+
108+
assert!(store.redeem("tok-123").await);
109+
// Second redeem should fail (one-time use).
110+
assert!(!store.redeem("tok-123").await);
111+
}
112+
113+
#[tokio::test]
114+
async fn redeem_nonexistent_returns_false() {
115+
let store = EnrollmentTokenStore::new();
116+
assert!(!store.redeem("does-not-exist").await);
117+
}
118+
119+
#[tokio::test]
120+
async fn expired_token_not_consumable() {
121+
let store = EnrollmentTokenStore::new();
122+
// Insert with 0 lifetime → already expired.
123+
store.insert("expired-tok".to_owned(), None, Some(0)).await;
124+
assert!(!store.redeem("expired-tok").await);
125+
}
126+
127+
#[tokio::test]
128+
async fn cleanup_removes_expired() {
129+
let store = EnrollmentTokenStore::new();
130+
store.insert("expired".to_owned(), None, Some(0)).await;
131+
store.insert("valid".to_owned(), None, Some(3600)).await;
132+
133+
store.cleanup_expired().await;
134+
135+
assert!(!store.redeem("expired").await);
136+
assert!(store.redeem("valid").await);
137+
}
138+
}

crates/agent-tunnel/src/lib.rs

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

99
pub mod cert;
10+
pub mod enrollment_store;
1011
pub mod listener;
1112
pub mod registry;
1213
pub mod routing;
1314
pub mod stream;
1415

16+
pub use enrollment_store::EnrollmentTokenStore;
1517
pub use listener::{AgentTunnelHandle, AgentTunnelListener};
1618
pub use registry::AgentRegistry;
1719
pub use stream::TunnelStream;

crates/agent-tunnel/src/listener.rs

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

1818
use super::cert::CaManager;
19+
use super::enrollment_store::EnrollmentTokenStore;
1920
use super::registry::{AgentPeer, AgentRegistry};
2021
use super::stream::TunnelStream;
2122

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

3739
impl AgentTunnelHandle {
@@ -43,6 +45,10 @@ impl AgentTunnelHandle {
4345
&self.ca_manager
4446
}
4547

48+
pub fn enrollment_token_store(&self) -> &EnrollmentTokenStore {
49+
&self.enrollment_token_store
50+
}
51+
4652
/// Open a proxy stream through a connected agent.
4753
// TODO: Emit TrafficEvent for connections routed through the agent tunnel.
4854
pub async fn connect_via_agent(
@@ -105,7 +111,6 @@ pub struct AgentTunnelListener {
105111
endpoint: quinn::Endpoint,
106112
registry: Arc<AgentRegistry>,
107113
agent_connections: Arc<RwLock<HashMap<Uuid, quinn::Connection>>>,
108-
ca_manager: Arc<CaManager>,
109114
}
110115

111116
impl AgentTunnelListener {
@@ -148,18 +153,19 @@ impl AgentTunnelListener {
148153

149154
let registry = Arc::new(AgentRegistry::new());
150155
let agent_connections: Arc<RwLock<HashMap<Uuid, quinn::Connection>>> = Arc::new(RwLock::new(HashMap::new()));
156+
let enrollment_token_store = Arc::new(EnrollmentTokenStore::new());
151157

152158
let handle = AgentTunnelHandle {
153159
registry: Arc::clone(&registry),
154160
agent_connections: Arc::clone(&agent_connections),
155161
ca_manager: Arc::clone(&ca_manager),
162+
enrollment_token_store,
156163
};
157164

158165
let listener = Self {
159166
endpoint,
160167
registry,
161168
agent_connections,
162-
ca_manager,
163169
};
164170

165171
Ok((listener, handle))
@@ -200,11 +206,8 @@ impl devolutions_gateway_task::Task for AgentTunnelListener {
200206

201207
let registry = Arc::clone(&self.registry);
202208
let agent_connections = Arc::clone(&self.agent_connections);
203-
let ca_manager = Arc::clone(&self.ca_manager);
204209

205-
conn_handles.spawn(
206-
run_agent_connection(registry, agent_connections, ca_manager, incoming),
207-
);
210+
conn_handles.spawn(run_agent_connection(registry, agent_connections, incoming));
208211
}
209212

210213
// Reap completed connection tasks to prevent unbounded growth.
@@ -225,7 +228,6 @@ impl devolutions_gateway_task::Task for AgentTunnelListener {
225228
async fn run_agent_connection(
226229
registry: Arc<AgentRegistry>,
227230
agent_connections: Arc<RwLock<HashMap<Uuid, quinn::Connection>>>,
228-
ca_manager: Arc<CaManager>,
229231
incoming: quinn::Incoming,
230232
) {
231233
let peer_addr = incoming.remote_address();
@@ -259,7 +261,7 @@ async fn run_agent_connection(
259261
agent_connections.write().await.insert(agent_id, conn.clone());
260262

261263
// Accept the first bidirectional stream as the control stream.
262-
let control_result = run_control_loop(&conn, agent_id, &agent_name, &registry, &ca_manager).await;
264+
let control_result = run_control_loop(&conn, agent_id, &registry).await;
263265

264266
// Agent disconnected — clean up.
265267
info!(%agent_id, "Agent QUIC connection closed");
@@ -275,13 +277,7 @@ async fn run_agent_connection(
275277
}
276278
}
277279

278-
async fn run_control_loop(
279-
conn: &quinn::Connection,
280-
agent_id: Uuid,
281-
agent_name: &str,
282-
registry: &AgentRegistry,
283-
ca_manager: &CaManager,
284-
) -> anyhow::Result<()> {
280+
async fn run_control_loop(conn: &quinn::Connection, agent_id: Uuid, registry: &AgentRegistry) -> anyhow::Result<()> {
285281
let mut ctrl: ControlStream<_, _> = conn.accept_bi().await.context("accept control stream")?.into();
286282

287283
info!(%agent_id, "Control stream accepted");
@@ -302,7 +298,7 @@ async fn run_control_loop(
302298
}
303299
};
304300

305-
handle_control_message(registry, ca_manager, agent_id, agent_name, &mut ctrl, msg).await;
301+
handle_control_message(registry, agent_id, &mut ctrl, msg).await;
306302
}
307303

308304
// Detect connection close.
@@ -318,9 +314,7 @@ async fn run_control_loop(
318314

319315
async fn handle_control_message<S: tokio::io::AsyncWrite + Unpin, R: tokio::io::AsyncRead + Unpin>(
320316
registry: &AgentRegistry,
321-
ca_manager: &CaManager,
322317
agent_id: Uuid,
323-
agent_name: &str,
324318
ctrl: &mut ControlStream<S, R>,
325319
msg: ControlMessage,
326320
) {
@@ -372,30 +366,8 @@ async fn handle_control_message<S: tokio::io::AsyncWrite + Unpin, R: tokio::io::
372366
ControlMessage::HeartbeatAck { .. } => {
373367
debug!(%agent_id, "Unexpected HeartbeatAck from agent");
374368
}
375-
ControlMessage::CertRenewalRequest { csr_pem, .. } => {
376-
info!(%agent_id, "Agent requested certificate renewal");
377-
378-
let result = match ca_manager.sign_agent_csr(agent_id, agent_name, &csr_pem, None) {
379-
Ok(signed) => {
380-
info!(%agent_id, "Certificate renewed successfully");
381-
agent_tunnel_proto::CertRenewalResult::Success {
382-
client_cert_pem: signed.client_cert_pem,
383-
gateway_ca_cert_pem: signed.ca_cert_pem,
384-
}
385-
}
386-
Err(e) => {
387-
warn!(%agent_id, error = %e, "Certificate renewal failed");
388-
agent_tunnel_proto::CertRenewalResult::Error { reason: e.to_string() }
389-
}
390-
};
391-
392-
let response = ControlMessage::cert_renewal_response(result);
393-
if let Err(e) = ctrl.send(&response).await {
394-
warn!(%agent_id, error = %e, "Failed to send renewal response");
395-
}
396-
}
397-
ControlMessage::CertRenewalResponse { .. } => {
398-
debug!(%agent_id, "Unexpected CertRenewalResponse from agent");
369+
ControlMessage::CertRenewalRequest { .. } | ControlMessage::CertRenewalResponse { .. } => {
370+
debug!(%agent_id, "Certificate renewal not supported in this build");
399371
}
400372
}
401373
}

devolutions-agent/src/cli_tests.rs

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ fn parse_up_command_args_happy_path() {
2626
enrollment_token: "bootstrap-token".to_owned(),
2727
agent_name: "site-a-agent".to_owned(),
2828
advertise_subnets: vec!["10.0.0.0/8".to_owned(), "192.168.1.0/24".to_owned()],
29-
advertise_domains: vec![],
3029
quic_endpoint: "gateway.example.com:7172".to_owned(),
3130
}
3231
);
@@ -53,29 +52,6 @@ fn parse_up_command_args_accepts_aliases() {
5352
assert_eq!(parsed.quic_endpoint, "gateway.example.com:7172");
5453
}
5554

56-
#[test]
57-
fn parse_up_command_args_accepts_advertise_domains() {
58-
let args = vec![
59-
"--gateway".to_owned(),
60-
"https://gateway.example.com:7171".to_owned(),
61-
"--token".to_owned(),
62-
"bootstrap-token".to_owned(),
63-
"--name".to_owned(),
64-
"site-a-agent".to_owned(),
65-
"--quic-endpoint".to_owned(),
66-
"gateway.example.com:7172".to_owned(),
67-
"--advertise-domains".to_owned(),
68-
"corp.example.com, lab.example.com".to_owned(),
69-
];
70-
71-
let parsed = parse_up_command_args(&args).expect("parse up args");
72-
73-
assert_eq!(
74-
parsed.advertise_domains,
75-
vec!["corp.example.com".to_owned(), "lab.example.com".to_owned()]
76-
);
77-
}
78-
7955
#[test]
8056
fn parse_up_command_args_rejects_missing_quic_endpoint() {
8157
// No --quic-endpoint and no JWT claim → must fail. The gateway does not
@@ -112,7 +88,7 @@ fn make_jwt(payload: serde_json::Value) -> String {
11288
#[test]
11389
fn parse_up_command_args_accepts_enrollment_string() {
11490
let jwt = make_jwt(serde_json::json!({
115-
"scope": "gateway.agent.enroll",
91+
"scope": "gateway.tunnel.enroll",
11692
"exp": 1_999_999_999i64,
11793
"jti": "00000000-0000-0000-0000-000000000000",
11894
"jet_gw_url": "https://gateway.example.com:7171",
@@ -134,7 +110,7 @@ fn parse_up_command_args_accepts_enrollment_string() {
134110
fn parse_up_command_args_rejects_enrollment_string_missing_quic_endpoint() {
135111
// JWT lacks `jet_quic_endpoint` AND no CLI `--quic-endpoint` → must fail.
136112
let jwt = make_jwt(serde_json::json!({
137-
"scope": "gateway.agent.enroll",
113+
"scope": "gateway.tunnel.enroll",
138114
"exp": 1_999_999_999i64,
139115
"jti": "00000000-0000-0000-0000-000000000000",
140116
"jet_gw_url": "https://gateway.example.com:7171",
@@ -152,7 +128,7 @@ fn parse_up_command_args_rejects_enrollment_string_missing_quic_endpoint() {
152128
#[test]
153129
fn parse_up_command_args_cli_quic_endpoint_wins_over_jwt() {
154130
let jwt = make_jwt(serde_json::json!({
155-
"scope": "gateway.agent.enroll",
131+
"scope": "gateway.tunnel.enroll",
156132
"exp": 1_999_999_999i64,
157133
"jti": "00000000-0000-0000-0000-000000000000",
158134
"jet_gw_url": "https://gateway.example.com:7171",

0 commit comments

Comments
 (0)