Skip to content

Commit 8f18e97

Browse files
fix(agent,agent-installer): fail the install if the agent tunnel can't reach the gateway
Enrollment proves only the HTTPS/TCP path; a firewall blocking the QUIC/UDP tunnel (UDP 4433) could let enrollment succeed yet leave the tunnel dead, while the installer still reported success. `agent up` now performs a one-shot QUIC + mTLS connectivity probe to the gateway right after enrolling, and exits non-zero on failure — which the enrollment custom action already turns into a failed (and rolled-back) install. - agent: `probe_connectivity` reuses the live connect path (one handshake + bounded drain); no standalone subcommand or heartbeat round-trip. - agent-installer: on a failed `up`, roll back a freshly-persisted enrollment only when `up` actually wrote new certs (guarded, fails safe), never the prior install's. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5f9e395 commit 8f18e97

3 files changed

Lines changed: 165 additions & 36 deletions

File tree

devolutions-agent/src/main.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ mod service;
3838
use std::env;
3939
use std::io::{self, BufRead};
4040
use std::sync::mpsc;
41+
use std::time::Duration;
4142

4243
use anyhow::{Context as _, Result, bail};
4344
use ceviche::Service;
@@ -277,7 +278,12 @@ fn main() {
277278
&command.enrollment_token,
278279
command.advertise_subnets,
279280
)
280-
.await
281+
.await?;
282+
283+
// Enrollment only proves HTTPS/TCP; fail the install now if the QUIC/UDP tunnel
284+
// path is blocked, while the operator is still here to fix the firewall.
285+
let conf = ConfHandle::init().context("load agent configuration for connectivity probe")?;
286+
devolutions_agent::tunnel::probe_connectivity(&conf.get_conf().tunnel, Duration::from_secs(15)).await
281287
});
282288

283289
if let Err(error) = result {

devolutions-agent/src/tunnel.rs

Lines changed: 123 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

package/AgentWindowsManaged/Actions/CustomActions.cs

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -498,17 +498,10 @@ ActionResult Fail(string msg)
498498
// Observed.
499499
}
500500

501-
// A hard Kill() bypasses BOTH recovery layers: the agent's transactional
502-
// rollback never runs (we killed it, it didn't gracefully error), and no marker
503-
// has been written yet (the marker write happens after the exit-code-0 check
504-
// below). So if `up` wrote agent.json + cert files but then hung, those would be
505-
// orphaned. Mirror the marker-failure path: best-effort read whatever cert paths
506-
// landed in agent.json and clean them up + restore the pre-snapshot state. The
507-
// snapshot locals (originalTunnel/originalGatewayCaB64/originalStateCaptured) were
508-
// captured before `up` started, so they're valid here. ReadTunnelCertPaths can't
509-
// throw, so this can't escape the timeout path.
510-
List<string> timeoutCertPaths = ReadTunnelCertPaths(agentJsonPath);
511-
CleanUpEnrollmentArtifacts(session, timeoutCertPaths, originalTunnel, originalGatewayCaB64, originalStateCaptured);
501+
// A hard Kill() bypasses the agent's own rollback and no marker exists yet, so a hang
502+
// after `up` persisted its enrollment would orphan it; undo it (guarded so an early
503+
// hang that wrote nothing can't delete the prior install's certs).
504+
RollBackFailedEnrollment(session, agentJsonPath, originalTunnel, originalGatewayCaB64, originalStateCaptured);
512505

513506
return Fail("Agent tunnel enrollment timed out. Verify your Devolutions Gateway is reachable from this machine.");
514507
}
@@ -532,6 +525,11 @@ ActionResult Fail(string msg)
532525
if (process.ExitCode != 0)
533526
{
534527
string detail = !string.IsNullOrWhiteSpace(stderr) ? Redact(stderr).Trim() : $"exit code {process.ExitCode}";
528+
529+
// `up` enrolls then probes, so a non-zero exit can leave a freshly-persisted
530+
// enrollment on disk with no marker yet; undo it (guarded against early failures).
531+
RollBackFailedEnrollment(session, agentJsonPath, originalTunnel, originalGatewayCaB64, originalStateCaptured);
532+
535533
return Fail($"Agent tunnel enrollment failed: {detail}");
536534
}
537535

@@ -914,6 +912,32 @@ public static ActionResult RollbackConfig(Session session)
914912
/// cleanup when it cannot record the rollback marker. Best-effort: logs and continues past
915913
/// individual failures so it never aborts a rollback.
916914
/// </summary>
915+
// Only undo when `up` actually persisted a NEW enrollment (client cert path changed from the
916+
// pre-`up` snapshot); else an early failure would delete the prior install's still-referenced certs.
917+
private static void RollBackFailedEnrollment(Session session, string agentJsonPath, JToken originalTunnel, string originalGatewayCaB64, bool originalStateCaptured)
918+
{
919+
if (!originalStateCaptured)
920+
{
921+
// Snapshot failed, so we can't tell new artifacts from the prior install's — skip
922+
// rather than risk deleting cert/key we never observed (a harmless orphan beats deletion).
923+
session.Log("skipping enrollment cleanup: pre-enrollment state was not captured");
924+
return;
925+
}
926+
927+
List<string> certPaths = ReadTunnelCertPaths(agentJsonPath);
928+
string originalClientCert = originalTunnel?["ClientCertPath"]?.Value<string>();
929+
string currentClientCert = certPaths.FirstOrDefault(p => p.EndsWith("-cert.pem", StringComparison.OrdinalIgnoreCase));
930+
931+
if (currentClientCert != null && !string.Equals(currentClientCert, originalClientCert, StringComparison.OrdinalIgnoreCase))
932+
{
933+
CleanUpEnrollmentArtifacts(session, certPaths, originalTunnel, originalGatewayCaB64, originalStateCaptured);
934+
}
935+
else
936+
{
937+
session.Log("skipping enrollment cleanup: `up` did not persist a new enrollment (client cert unchanged)");
938+
}
939+
}
940+
917941
private static void CleanUpEnrollmentArtifacts(Session session, List<string> newCertPaths, JToken originalTunnel, string originalGatewayCaB64, bool originalStateCaptured)
918942
{
919943
// The client cert/key are uniquely named per enrollment, so they're always deleted —

0 commit comments

Comments
 (0)