@@ -138,8 +138,7 @@ impl Task for TunnelTask {
138138 return Ok ( ( ) ) ;
139139 }
140140 Ok ( ConnectionOutcome :: CertRenewed ) => {
141- // Renewal is a successful "completion", not a failure — skip
142- // the backoff and reconnect immediately with the new cert.
141+ // Renewal is a completion, not a failure.
143142 info ! ( "Certificate renewed; reconnecting with new cert immediately" ) ;
144143 backoff. reset ( ) ;
145144 continue ;
@@ -190,25 +189,10 @@ enum ConnectionOutcome {
190189 CertRenewed ,
191190}
192191
193- /// Run a single QUIC tunnel connection lifetime: config → connect → event loop.
194- ///
195- /// - `Ok(Shutdown)`: graceful shutdown, exit the task.
196- /// - `Ok(CertRenewed)`: certificate renewed; caller should reconnect immediately.
197- /// - `Err(...)`: connection lost or handshake failed — caller should retry with backoff.
198- async fn run_single_connection (
199- conf_handle : & ConfHandle ,
200- shutdown_signal : & mut ShutdownSignal ,
201- ) -> anyhow:: Result < ConnectionOutcome > {
202- // Ensure rustls crypto provider is installed (ring).
203- let _ = rustls:: crypto:: ring:: default_provider ( ) . install_default ( ) ;
204-
205- let agent_conf = conf_handle. get_conf ( ) ;
206- let tunnel_conf = & agent_conf. tunnel ;
207-
208- let cert_path = & tunnel_conf. client_cert_path ;
209- let key_path = & tunnel_conf. client_key_path ;
210- let ca_path = & tunnel_conf. gateway_ca_cert_path ;
211-
192+ /// Build the route advertisement payload from the current tunnel configuration.
193+ fn route_advertisements (
194+ tunnel_conf : & crate :: config:: TunnelConf ,
195+ ) -> anyhow:: Result < ( Vec < Ipv4Network > , Vec < agent_tunnel_proto:: DomainAdvertisement > ) > {
212196 let advertise_subnets: Vec < Ipv4Network > = tunnel_conf
213197 . advertise_subnets
214198 . iter ( )
@@ -260,23 +244,33 @@ async fn run_single_connection(
260244 "Advertising subnets and domains"
261245 ) ;
262246
247+ Ok ( ( advertise_subnets, advertise_domains) )
248+ }
249+
250+ /// Build the mTLS client config, resolve the gateway endpoint, and perform the
251+ /// QUIC handshake, returning the live endpoint and connection.
252+ async fn connect_to_gateway (
253+ tunnel_conf : & crate :: config:: TunnelConf ,
254+ ) -> anyhow:: Result < ( quinn:: Endpoint , quinn:: Connection ) > {
255+ // Ensure rustls crypto provider is installed (ring).
256+ let _ = rustls:: crypto:: ring:: default_provider ( ) . install_default ( ) ;
263257 // -- Build rustls ClientConfig --
264258
265259 let certs: Vec < rustls_pki_types:: CertificateDer < ' static > > = rustls_pemfile:: certs ( & mut std:: io:: BufReader :: new (
266- std:: fs:: File :: open ( cert_path . as_str ( ) ) . context ( "open client cert file" ) ?,
260+ std:: fs:: File :: open ( tunnel_conf . client_cert_path . as_str ( ) ) . context ( "open client cert file" ) ?,
267261 ) )
268262 . collect :: < Result < Vec < _ > , _ > > ( )
269263 . context ( "parse client certificates" ) ?;
270264
271265 let key = rustls_pemfile:: private_key ( & mut std:: io:: BufReader :: new (
272- std:: fs:: File :: open ( key_path . as_str ( ) ) . context ( "open client key file" ) ?,
266+ std:: fs:: File :: open ( tunnel_conf . client_key_path . as_str ( ) ) . context ( "open client key file" ) ?,
273267 ) )
274268 . context ( "parse private key file" ) ?
275269 . context ( "no private key found in file" ) ?;
276270
277271 let mut roots = rustls:: RootCertStore :: empty ( ) ;
278272 let ca_certs: Vec < rustls_pki_types:: CertificateDer < ' static > > = rustls_pemfile:: certs ( & mut std:: io:: BufReader :: new (
279- std:: fs:: File :: open ( ca_path . as_str ( ) ) . context ( "open CA cert file" ) ?,
273+ std:: fs:: File :: open ( tunnel_conf . gateway_ca_cert_path . as_str ( ) ) . context ( "open CA cert file" ) ?,
280274 ) )
281275 . collect :: < Result < Vec < _ > , _ > > ( )
282276 . context ( "parse CA certificates" ) ?;
@@ -363,6 +357,47 @@ async fn run_single_connection(
363357
364358 info ! ( "QUIC connection established" ) ;
365359
360+ Ok ( ( endpoint, connection) )
361+ }
362+
363+ /// Confirm the QUIC/UDP path to the gateway is open by completing one mTLS+QUIC handshake, then
364+ /// draining the connection, bounded by `timeout`.
365+ pub async fn probe_connectivity ( tunnel_conf : & crate :: config:: TunnelConf , timeout : Duration ) -> anyhow:: Result < ( ) > {
366+ if !tunnel_conf. enabled {
367+ bail ! ( "agent tunnel is not enabled" ) ;
368+ }
369+
370+ let ( endpoint, connection) = tokio:: time:: timeout ( timeout, connect_to_gateway ( tunnel_conf) )
371+ . await
372+ . context ( "tunnel connectivity probe timed out" ) ??;
373+
374+ // Flush the CONNECTION_CLOSE so the gateway unregisters this probe's connection promptly
375+ // (keyed by agent_id) rather than after its idle timeout.
376+ connection. close ( 0u32 . into ( ) , b"probe-complete" ) ;
377+ let _ = tokio:: time:: timeout ( Duration :: from_secs ( 3 ) , endpoint. wait_idle ( ) ) . await ;
378+
379+ Ok ( ( ) )
380+ }
381+
382+ /// Run a single QUIC tunnel connection lifetime: config → connect → event loop.
383+ ///
384+ /// - `Ok(Shutdown)`: graceful shutdown, exit the task.
385+ /// - `Ok(CertRenewed)`: certificate renewed; caller should reconnect immediately.
386+ /// - `Err(_)`: connection lost or handshake failed — caller should retry with backoff.
387+ async fn run_single_connection (
388+ conf_handle : & ConfHandle ,
389+ shutdown_signal : & mut ShutdownSignal ,
390+ ) -> anyhow:: Result < ConnectionOutcome > {
391+ let agent_conf = conf_handle. get_conf ( ) ;
392+ let tunnel_conf = & agent_conf. tunnel ;
393+
394+ let cert_path = & tunnel_conf. client_cert_path ;
395+ let key_path = & tunnel_conf. client_key_path ;
396+ let ca_path = & tunnel_conf. gateway_ca_cert_path ;
397+
398+ let ( advertise_subnets, advertise_domains) = route_advertisements ( tunnel_conf) ?;
399+ let ( _endpoint, connection) = connect_to_gateway ( tunnel_conf) . await ?;
400+
366401 // -- Open control stream --
367402
368403 let mut ctrl: ControlStream < _ , _ > = connection. open_bi ( ) . await . context ( "open control stream" ) ?. into ( ) ;
@@ -638,3 +673,67 @@ async fn run_session_proxy(advertise_subnets: Vec<Ipv4Network>, send: quinn::Sen
638673 . await
639674 . inspect_err ( |e| error ! ( %e, "Session proxy failed" ) ) ;
640675}
676+
677+ #[ cfg( test) ]
678+ mod tests {
679+ use camino:: Utf8PathBuf ;
680+
681+ use super :: * ;
682+ use crate :: config:: TunnelConf ;
683+
684+ fn tunnel_conf_template ( ) -> TunnelConf {
685+ TunnelConf {
686+ enabled : true ,
687+ gateway_endpoint : String :: new ( ) ,
688+ client_cert_path : Utf8PathBuf :: new ( ) ,
689+ client_key_path : Utf8PathBuf :: new ( ) ,
690+ gateway_ca_cert_path : Utf8PathBuf :: new ( ) ,
691+ advertise_subnets : Vec :: new ( ) ,
692+ advertise_domains : Vec :: new ( ) ,
693+ auto_detect_domain : false ,
694+ heartbeat_interval_secs : 15 ,
695+ route_advertise_interval_secs : 60 ,
696+ server_spki_sha256 : None ,
697+ }
698+ }
699+
700+ #[ tokio:: test]
701+ async fn probe_fails_fast_when_tunnel_disabled ( ) {
702+ let mut conf = tunnel_conf_template ( ) ;
703+ conf. enabled = false ;
704+
705+ let error = probe_connectivity ( & conf, Duration :: from_secs ( 5 ) )
706+ . await
707+ . expect_err ( "probe must fail when the tunnel is disabled" ) ;
708+
709+ assert ! ( format!( "{error:#}" ) . contains( "not enabled" ) , "unexpected error: {error:#}" ) ;
710+ }
711+
712+ #[ tokio:: test]
713+ async fn probe_times_out_when_gateway_unreachable ( ) {
714+ // Throwaway PEMs so the pre-connect file reads succeed; nothing listens on the target
715+ // port, so the handshake never completes and the probe must hit its own timeout.
716+ let cert_key =
717+ rcgen:: generate_simple_self_signed ( vec ! [ "localhost" . to_owned( ) ] ) . expect ( "generate self-signed cert" ) ;
718+ let dir = tempfile:: tempdir ( ) . expect ( "temp dir" ) ;
719+ let cert_path = dir. path ( ) . join ( "client.crt" ) ;
720+ let key_path = dir. path ( ) . join ( "client.key" ) ;
721+ let ca_path = dir. path ( ) . join ( "ca.crt" ) ;
722+ std:: fs:: write ( & cert_path, cert_key. cert . pem ( ) ) . expect ( "write client cert" ) ;
723+ std:: fs:: write ( & key_path, cert_key. key_pair . serialize_pem ( ) ) . expect ( "write client key" ) ;
724+ std:: fs:: write ( & ca_path, cert_key. cert . pem ( ) ) . expect ( "write ca cert" ) ;
725+
726+ let mut conf = tunnel_conf_template ( ) ;
727+ // 127.0.0.1:1 is reserved and unbound; the QUIC handshake cannot complete.
728+ conf. gateway_endpoint = "127.0.0.1:1" . to_owned ( ) ;
729+ conf. client_cert_path = Utf8PathBuf :: from_path_buf ( cert_path) . expect ( "utf8 cert path" ) ;
730+ conf. client_key_path = Utf8PathBuf :: from_path_buf ( key_path) . expect ( "utf8 key path" ) ;
731+ conf. gateway_ca_cert_path = Utf8PathBuf :: from_path_buf ( ca_path) . expect ( "utf8 ca path" ) ;
732+
733+ let started = std:: time:: Instant :: now ( ) ;
734+ let result = probe_connectivity ( & conf, Duration :: from_secs ( 2 ) ) . await ;
735+
736+ assert ! ( result. is_err( ) , "probe must fail when the gateway is unreachable" ) ;
737+ assert ! ( started. elapsed( ) < Duration :: from_secs( 15 ) , "probe must fail fast" ) ;
738+ }
739+ }
0 commit comments