Skip to content

Commit 54f87d2

Browse files
refactor(agent-tunnel): JWT-only enrollment, drop server-side mint
DVLS holds the provisioner private key, the gateway holds the public key and verifies statelessly. The gateway-side enrollment-string mint endpoint was the wrong shape: it required the gateway to hold issuance state (the in-memory `EnrollmentTokenStore`), which broke HA (a token could only be redeemed against the specific gateway node that minted it, and a restart silently invalidated unredeemed tokens). This PR removes that path entirely. DVLS signs an enrollment JWT with its `gateway.agent.enroll` scope; the agent presents the JWT as the Bearer token on `POST /jet/tunnel/enroll`; the gateway verifies the signature against the configured provisioner public key. No state on the gateway, no per-node affinity, no restart hazards. Companion changes: - Token: rename `gateway.tunnel.enroll` to `gateway.agent.enroll` (the scope governs agent management, not the tunnel transport). Add the matching `gateway.agent.read` scope. Both extractors (`AgentManagementReadAccess` / `WriteAccess`) accept the new scopes alongside the existing `Wildcard` / `ConfigWrite` / `DiagnosticsRead` for back-compat with callers that predate the rename. - Agent CLI (`up`): the JWT itself becomes the enrollment artifact, so `up --enrollment-string <jwt>` is the one-step bootstrap path. `--enrollment-string` is decoded locally to populate `--gateway`, `--name`, and the new `--quic-endpoint` / `--advertise-domains` flags. CLI flags still take precedence over claim values. - `EnrollResponse` no longer returns `quic_endpoint`. A running gateway cannot know the address its agents can actually reach (Docker bridge NAT, K8s service FQDN, split-horizon DNS, LB VIP) — that is operator knowledge, supplied via the `jet_quic_endpoint` JWT claim or the `--quic-endpoint` agent flag. The doc on `EnrollmentJwtClaims` spells out the rationale. - .NET utils: new `EnrollmentClaims` mirroring the Rust shape so DVLS can sign the JWT directly via `TokenUtils.Sign`. Two new `AccessScope` constants. Round-trip JSON tests for both. NuGet bumped to 2026.4.27. - `--advertise-domains` agent CLI flag (parallels `--advertise-routes`, for hostname-based routing once the gateway side accepts domain advertisements).
1 parent 0475494 commit 54f87d2

13 files changed

Lines changed: 372 additions & 279 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,12 +7,10 @@
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 stream;
1413

15-
pub use enrollment_store::EnrollmentTokenStore;
1614
pub use listener::{AgentTunnelHandle, AgentTunnelListener};
1715
pub use registry::AgentRegistry;
1816
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(
@@ -148,13 +142,11 @@ impl AgentTunnelListener {
148142

149143
let registry = Arc::new(AgentRegistry::new());
150144
let agent_connections: Arc<RwLock<HashMap<Uuid, quinn::Connection>>> = Arc::new(RwLock::new(HashMap::new()));
151-
let enrollment_token_store = Arc::new(EnrollmentTokenStore::new());
152145

153146
let handle = AgentTunnelHandle {
154147
registry: Arc::clone(&registry),
155148
agent_connections: Arc::clone(&agent_connections),
156149
ca_manager,
157-
enrollment_token_store,
158150
};
159151

160152
let listener = Self {

devolutions-agent/src/cli_tests.rs

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
use base64::Engine as _;
2+
3+
use super::*;
4+
5+
#[test]
6+
fn parse_up_command_args_happy_path() {
7+
let args = vec![
8+
"--gateway".to_owned(),
9+
"https://gateway.example.com:7171".to_owned(),
10+
"--token".to_owned(),
11+
"bootstrap-token".to_owned(),
12+
"--name".to_owned(),
13+
"site-a-agent".to_owned(),
14+
"--quic-endpoint".to_owned(),
15+
"gateway.example.com:7172".to_owned(),
16+
"--advertise-routes".to_owned(),
17+
"10.0.0.0/8,192.168.1.0/24".to_owned(),
18+
];
19+
20+
let parsed = parse_up_command_args(&args).expect("parse up args");
21+
22+
assert_eq!(
23+
parsed,
24+
UpCommand {
25+
gateway_url: "https://gateway.example.com:7171".to_owned(),
26+
enrollment_token: "bootstrap-token".to_owned(),
27+
agent_name: "site-a-agent".to_owned(),
28+
advertise_subnets: vec!["10.0.0.0/8".to_owned(), "192.168.1.0/24".to_owned()],
29+
quic_endpoint: "gateway.example.com:7172".to_owned(),
30+
}
31+
);
32+
}
33+
34+
#[test]
35+
fn parse_up_command_args_accepts_aliases() {
36+
let args = vec![
37+
"--gateway".to_owned(),
38+
"https://gateway.example.com:7171".to_owned(),
39+
"--enrollment-token".to_owned(),
40+
"bootstrap-token".to_owned(),
41+
"--agent-name".to_owned(),
42+
"site-a-agent".to_owned(),
43+
"--quic-endpoint".to_owned(),
44+
"gateway.example.com:7172".to_owned(),
45+
"--advertise-subnets".to_owned(),
46+
"10.0.0.0/8".to_owned(),
47+
];
48+
49+
let parsed = parse_up_command_args(&args).expect("parse up args");
50+
51+
assert_eq!(parsed.advertise_subnets, vec!["10.0.0.0/8".to_owned()]);
52+
assert_eq!(parsed.quic_endpoint, "gateway.example.com:7172");
53+
}
54+
55+
#[test]
56+
fn parse_up_command_args_rejects_missing_quic_endpoint() {
57+
// No --quic-endpoint and no JWT claim → must fail. The gateway does not
58+
// self-report a QUIC endpoint, so the operator has to supply one.
59+
let args = vec![
60+
"--gateway".to_owned(),
61+
"https://gateway.example.com:7171".to_owned(),
62+
"--token".to_owned(),
63+
"bootstrap-token".to_owned(),
64+
"--name".to_owned(),
65+
"site-a-agent".to_owned(),
66+
];
67+
68+
let err = parse_up_command_args(&args).expect_err("should reject missing QUIC endpoint");
69+
assert!(
70+
err.to_string().to_lowercase().contains("quic endpoint"),
71+
"error should mention the missing QUIC endpoint, got: {err:#}"
72+
);
73+
}
74+
75+
/// Build a JWT with the given payload. The header and signature are placeholders —
76+
/// the agent does not verify them; only the Gateway does.
77+
fn make_jwt(payload: serde_json::Value) -> String {
78+
let header = serde_json::json!({ "alg": "RS256", "typ": "JWT" });
79+
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD;
80+
format!(
81+
"{}.{}.{}",
82+
b64.encode(header.to_string()),
83+
b64.encode(payload.to_string()),
84+
b64.encode("signature-placeholder"),
85+
)
86+
}
87+
88+
#[test]
89+
fn parse_up_command_args_accepts_enrollment_string() {
90+
let jwt = make_jwt(serde_json::json!({
91+
"scope": "gateway.agent.enroll",
92+
"exp": 1_999_999_999i64,
93+
"jti": "00000000-0000-0000-0000-000000000000",
94+
"jet_gw_url": "https://gateway.example.com:7171",
95+
"jet_agent_name": "site-a-agent",
96+
"jet_quic_endpoint": "gateway.example.com:7172",
97+
}));
98+
let args = vec!["--enrollment-string".to_owned(), jwt.clone()];
99+
100+
let parsed = parse_up_command_args(&args).expect("parse up args");
101+
102+
assert_eq!(parsed.gateway_url, "https://gateway.example.com:7171");
103+
// The JWT itself is used as the Bearer token for /jet/tunnel/enroll.
104+
assert_eq!(parsed.enrollment_token, jwt);
105+
assert_eq!(parsed.agent_name, "site-a-agent");
106+
assert_eq!(parsed.quic_endpoint, "gateway.example.com:7172");
107+
}
108+
109+
#[test]
110+
fn parse_up_command_args_rejects_enrollment_string_missing_quic_endpoint() {
111+
// JWT lacks `jet_quic_endpoint` AND no CLI `--quic-endpoint` → must fail.
112+
let jwt = make_jwt(serde_json::json!({
113+
"scope": "gateway.agent.enroll",
114+
"exp": 1_999_999_999i64,
115+
"jti": "00000000-0000-0000-0000-000000000000",
116+
"jet_gw_url": "https://gateway.example.com:7171",
117+
"jet_agent_name": "site-a-agent",
118+
}));
119+
let args = vec!["--enrollment-string".to_owned(), jwt];
120+
121+
let err = parse_up_command_args(&args).expect_err("should reject missing QUIC endpoint");
122+
assert!(
123+
err.to_string().to_lowercase().contains("quic endpoint"),
124+
"error should mention the missing QUIC endpoint, got: {err:#}"
125+
);
126+
}
127+
128+
#[test]
129+
fn parse_up_command_args_cli_quic_endpoint_wins_over_jwt() {
130+
let jwt = make_jwt(serde_json::json!({
131+
"scope": "gateway.agent.enroll",
132+
"exp": 1_999_999_999i64,
133+
"jti": "00000000-0000-0000-0000-000000000000",
134+
"jet_gw_url": "https://gateway.example.com:7171",
135+
"jet_agent_name": "site-a-agent",
136+
"jet_quic_endpoint": "from-jwt.example.com:7172",
137+
}));
138+
let args = vec![
139+
"--enrollment-string".to_owned(),
140+
jwt,
141+
"--quic-endpoint".to_owned(),
142+
"from-cli.example.com:7172".to_owned(),
143+
];
144+
145+
let parsed = parse_up_command_args(&args).expect("parse up args");
146+
147+
assert_eq!(parsed.quic_endpoint, "from-cli.example.com:7172");
148+
}

0 commit comments

Comments
 (0)