@@ -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 ) ]
7586struct 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