Skip to content

Commit 2ce4ed9

Browse files
feat(agent): derive QUIC endpoint from enrollment URL host, consume quic_port
Companion to the gateway-side identity refactor. The agent now composes the QUIC dial target from `(jet_gw_url.host, quic_port)` rather than blindly trusting whatever `quic_endpoint` the gateway returned. The host the agent uses is the host the operator already proved is reachable from this agent's network (the host the enrollment HTTP call landed on); the gateway only tells the agent which UDP port to dial. `EnrollResponse` now accepts both shapes during the compat window: - New gateways send both `quic_endpoint` (legacy) and `quic_port` (new). The agent prefers `quic_port` and pairs it with the enrollment URL host. - Older gateways send only `quic_endpoint`. The agent parses the port off that and still pairs it with the enrollment URL host so the old SAN-mismatch symptom (gateway substitutes its `conf.hostname` and breaks DNS resolution on the agent side) cannot recur on either side of the upgrade. `format_endpoint` handles DNS, IPv4, and IPv6 hosts with proper bracketing for IPv6 literals (`[fd00::7]:4433`). Issue: DGW-Agent-Tunnel-Identity
1 parent 18d4a3d commit 2ce4ed9

1 file changed

Lines changed: 183 additions & 4 deletions

File tree

devolutions-agent/src/enrollment.rs

Lines changed: 183 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,26 @@ struct EnrollRequest {
7171
}
7272

7373
/// Response from enrollment API
74+
///
75+
/// The compat bridge (per the identity refactor design) means both
76+
/// `quic_endpoint` and `quic_port` may be present:
77+
///
78+
/// - `quic_port` is the canonical new field. Agents should pair it with the
79+
/// host they already enrolled through (parsed from the JWT's `jet_gw_url`).
80+
/// - `quic_endpoint` is kept for one release so older gateways still work.
81+
///
82+
/// Both fields are `#[serde(default)]` so the deserializer accepts either or
83+
/// both. After enroll, the agent picks `quic_port` when available, otherwise
84+
/// it parses the port off `quic_endpoint`.
7485
#[derive(Deserialize)]
7586
struct EnrollResponse {
7687
agent_id: Uuid,
7788
client_cert_pem: String,
7889
gateway_ca_cert_pem: String,
79-
quic_endpoint: String,
90+
#[serde(default)]
91+
quic_endpoint: Option<String>,
92+
#[serde(default)]
93+
quic_port: Option<u16>,
8094
server_spki_sha256: String,
8195
}
8296

@@ -107,7 +121,23 @@ pub async fn enroll_agent(
107121
let (key_pem, csr_pem) = generate_key_and_csr(agent_name)?;
108122

109123
let enroll_response = request_enrollment(gateway_url, enrollment_token, agent_name, &csr_pem).await?;
110-
persist_enrollment_response(agent_name, advertise_subnets, enroll_response, &key_pem)
124+
125+
// The agent dials the QUIC tunnel at whichever host the operator already
126+
// proved is reachable from this agent's network — that's `gateway_url`'s
127+
// host. The Gateway tells the agent which *port* to dial (via `quic_port`),
128+
// not which host. For older Gateways the host is parsed off the legacy
129+
// `quic_endpoint` field.
130+
let enrollment_host = url::Url::parse(gateway_url)
131+
.ok()
132+
.and_then(|u| u.host_str().map(str::to_owned));
133+
134+
persist_enrollment_response(
135+
agent_name,
136+
advertise_subnets,
137+
enroll_response,
138+
enrollment_host.as_deref(),
139+
&key_pem,
140+
)
111141
}
112142

113143
/// Generate an ECDSA P-256 key pair and a CSR containing the agent name as CN.
@@ -169,10 +199,34 @@ fn persist_enrollment_response(
169199
client_cert_pem,
170200
gateway_ca_cert_pem,
171201
quic_endpoint,
202+
quic_port,
172203
server_spki_sha256,
173204
}: EnrollResponse,
205+
enrollment_host: Option<&str>,
174206
key_pem: &str,
175207
) -> Result<PersistedEnrollment> {
208+
// Pick the QUIC port: prefer the new `quic_port` field, otherwise parse
209+
// the port off the legacy `quic_endpoint` (compat with older gateways).
210+
let quic_port_resolved = if let Some(port) = quic_port {
211+
port
212+
} else {
213+
let endpoint = quic_endpoint
214+
.as_deref()
215+
.context("enrollment response carries neither `quic_port` nor `quic_endpoint`")?;
216+
parse_endpoint_port(endpoint).with_context(|| format!("parse legacy quic_endpoint {endpoint:?}"))?
217+
};
218+
219+
// Compose the gateway endpoint from `(enrollment_host, quic_port)` when we
220+
// know the enrollment host (new agents talking to new gateways and to old
221+
// gateways alike). If the caller did not pass it — only possible when
222+
// running against the unit tests or a malformed URL — fall back to the
223+
// legacy `quic_endpoint` verbatim.
224+
let resolved_endpoint = match enrollment_host {
225+
Some(host) => format_endpoint(host, quic_port_resolved),
226+
None => quic_endpoint
227+
.clone()
228+
.context("enrollment URL has no host and response did not include a usable quic_endpoint")?,
229+
};
176230
let config_path = config::get_conf_file_path();
177231
let config_dir = config_path
178232
.parent()
@@ -219,7 +273,7 @@ fn persist_enrollment_response(
219273

220274
let tunnel_conf = config::dto::TunnelConf {
221275
enabled: true,
222-
gateway_endpoint: quic_endpoint.clone(),
276+
gateway_endpoint: resolved_endpoint.clone(),
223277
client_cert_path: Some(client_cert_path.clone()),
224278
client_key_path: Some(client_key_path.clone()),
225279
gateway_ca_cert_path: Some(gateway_ca_path.clone()),
@@ -241,10 +295,55 @@ fn persist_enrollment_response(
241295
client_cert_path,
242296
client_key_path,
243297
gateway_ca_path,
244-
quic_endpoint,
298+
quic_endpoint: resolved_endpoint,
245299
})
246300
}
247301

302+
/// Format a `host:port` endpoint string, bracketing IPv6 literals so the
303+
/// resulting string is parseable as a `SocketAddr` and unambiguous to humans.
304+
///
305+
/// | host kind | output |
306+
/// |---|---|
307+
/// | DNS | `gateway.example.com:4433` |
308+
/// | IPv4 | `10.10.0.7:4433` |
309+
/// | IPv6 | `[fd00::7]:4433` |
310+
///
311+
/// The IPv6 case strips any pre-existing surrounding brackets first, so both
312+
/// `fd00::7` and `[fd00::7]` produce the same canonical bracketed form.
313+
pub fn format_endpoint(host: &str, port: u16) -> String {
314+
let trimmed = host.trim();
315+
// url::Url surfaces IPv6 hosts already bracketed; strip them here so we
316+
// can detect "it's an IPv6 literal" by trying to parse as Ipv6Addr.
317+
let unbracketed = trimmed
318+
.strip_prefix('[')
319+
.and_then(|s| s.strip_suffix(']'))
320+
.unwrap_or(trimmed);
321+
if unbracketed.parse::<std::net::Ipv6Addr>().is_ok() {
322+
format!("[{unbracketed}]:{port}")
323+
} else {
324+
format!("{trimmed}:{port}")
325+
}
326+
}
327+
328+
/// Parse the port off a legacy `quic_endpoint` string of the form
329+
/// `<host>:<port>` (DNS / IPv4) or `[<ipv6>]:<port>`.
330+
fn parse_endpoint_port(endpoint: &str) -> Result<u16> {
331+
let trimmed = endpoint.trim();
332+
let port_str = if let Some(rest) = trimmed.rsplit_once(']') {
333+
// IPv6: "[host]:port" — `rest.0` is "[host", `rest.1` is ":port".
334+
rest.1
335+
.strip_prefix(':')
336+
.context("missing ':' before port in bracketed endpoint")?
337+
} else {
338+
// DNS / IPv4: "host:port" — split on the last ':' since DNS / IPv4 have no colons in the host.
339+
trimmed
340+
.rsplit_once(':')
341+
.map(|(_, p)| p)
342+
.context("missing ':' between host and port in endpoint")?
343+
};
344+
port_str.parse::<u16>().context("endpoint port is not a valid u16")
345+
}
346+
248347
// ---------------------------------------------------------------------------
249348
// Certificate renewal helpers
250349
// ---------------------------------------------------------------------------
@@ -349,4 +448,84 @@ mod tests {
349448
}));
350449
assert!(parse_enrollment_jwt(&jwt).is_err());
351450
}
451+
452+
// ---- format_endpoint -----------------------------------------------------
453+
454+
#[test]
455+
fn format_endpoint_dns() {
456+
assert_eq!(format_endpoint("gateway.example.com", 4433), "gateway.example.com:4433");
457+
}
458+
459+
#[test]
460+
fn format_endpoint_ipv4() {
461+
assert_eq!(format_endpoint("10.10.0.7", 4433), "10.10.0.7:4433");
462+
}
463+
464+
#[test]
465+
fn format_endpoint_ipv6_bracketed() {
466+
assert_eq!(format_endpoint("fd00::7", 4433), "[fd00::7]:4433");
467+
}
468+
469+
#[test]
470+
fn format_endpoint_ipv6_already_bracketed_input() {
471+
// Defensive: if the caller already pre-bracketed (as `url::Url::host_str`
472+
// does for IPv6), the helper still produces the canonical form once.
473+
assert_eq!(format_endpoint("[fd00::7]", 4433), "[fd00::7]:4433");
474+
}
475+
476+
// ---- parse_endpoint_port -------------------------------------------------
477+
478+
#[test]
479+
fn parse_endpoint_port_dns() {
480+
assert_eq!(parse_endpoint_port("gateway.example.com:4433").unwrap(), 4433);
481+
}
482+
483+
#[test]
484+
fn parse_endpoint_port_ipv4() {
485+
assert_eq!(parse_endpoint_port("10.10.0.7:4433").unwrap(), 4433);
486+
}
487+
488+
#[test]
489+
fn parse_endpoint_port_ipv6_bracketed() {
490+
assert_eq!(parse_endpoint_port("[fd00::7]:4433").unwrap(), 4433);
491+
}
492+
493+
#[test]
494+
fn parse_endpoint_port_rejects_no_colon() {
495+
assert!(parse_endpoint_port("gateway.example.com").is_err());
496+
}
497+
498+
// ---- EnrollResponse deserialization --------------------------------------
499+
500+
/// New gateway: both `quic_endpoint` and `quic_port` present. Agent prefers
501+
/// `quic_port`.
502+
#[test]
503+
fn enroll_response_accepts_new_compat_bridge_payload() {
504+
let body = serde_json::json!({
505+
"agent_id": "00000000-0000-0000-0000-000000000001",
506+
"client_cert_pem": "stub",
507+
"gateway_ca_cert_pem": "stub",
508+
"quic_endpoint": "10.10.0.7:4433",
509+
"quic_port": 4433,
510+
"server_spki_sha256": "deadbeef",
511+
});
512+
let parsed: EnrollResponse = serde_json::from_value(body).expect("parse new payload");
513+
assert_eq!(parsed.quic_port, Some(4433));
514+
assert_eq!(parsed.quic_endpoint.as_deref(), Some("10.10.0.7:4433"));
515+
}
516+
517+
/// Legacy gateway: only `quic_endpoint`. Agent must fall back to parsing it.
518+
#[test]
519+
fn enroll_response_accepts_legacy_payload_without_quic_port() {
520+
let body = serde_json::json!({
521+
"agent_id": "00000000-0000-0000-0000-000000000001",
522+
"client_cert_pem": "stub",
523+
"gateway_ca_cert_pem": "stub",
524+
"quic_endpoint": "10.10.0.7:4433",
525+
"server_spki_sha256": "deadbeef",
526+
});
527+
let parsed: EnrollResponse = serde_json::from_value(body).expect("parse legacy payload");
528+
assert_eq!(parsed.quic_port, None);
529+
assert_eq!(parsed.quic_endpoint.as_deref(), Some("10.10.0.7:4433"));
530+
}
352531
}

0 commit comments

Comments
 (0)