Skip to content

Commit 63b8c09

Browse files
feat(installer): wire post-enroll tunnel verification and drop gateway-url override
After `agent.exe up` succeeds, the agent-tunnel installation now invokes `agent.exe verify-tunnel --timeout 10` and only reports success if a real QUIC handshake + RouteAdvertise/Heartbeat round-trip completes. The CA parses the structured JSON triple from agent stderr and surfaces `{kind, detail, next_step}` via `session.Message(InstallMessage.Error, ...)` so installer failure dialogs contain an actionable next step instead of "setup failed". The 10s timeout is hardcoded by design (no MSI property, no escape hatch); a few extra seconds of wall-clock budget guard against a misbehaving process. MSI rollbacks engage on any non-zero exit. Also drops the Gateway URL override field from the AgentTunnel dialog (textbox, label, hint, RowCount/RowStyles, property declaration, deferred-CA UsesProperties wiring, localization strings in en-us and fr-fr). With the identity refactor the enrollment JWT is the single source of truth for the agent-facing URL — overriding it server-side would defeat the host validation against AdvertisedNames on the gateway.
1 parent 8bf3010 commit 63b8c09

8 files changed

Lines changed: 143 additions & 62 deletions

File tree

package/AgentWindowsManaged/Actions/AgentActions.cs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,14 +294,39 @@ internal static class AgentActions
294294
UsesProperties = string.Join(";", new[]
295295
{
296296
AgentProperties.AgentTunnelEnrollmentString,
297-
AgentProperties.AgentTunnelGatewayUrl,
298297
AgentProperties.AgentTunnelAgentName,
299298
AgentProperties.AgentTunnelAdvertiseSubnets,
300299
AgentProperties.AgentTunnelAdvertiseDomains,
301300
AgentProperties.InstallDir,
302301
}.Select(p => $"{p}=[{p}]")),
303302
};
304303

304+
/// <summary>
305+
/// After `up` returns success, run `agent.exe verify-tunnel` with a hardcoded 10s timeout
306+
/// to confirm the tunnel is actually reachable.
307+
/// </summary>
308+
/// <remarks>
309+
/// Per the identity refactor, the installer's "success" means "the tunnel is up", not just
310+
/// "a cert was written to disk". The CA parses the agent's structured JSON triple from
311+
/// stderr and surfaces it through `session.Message(InstallMessage.Error, ...)` so the
312+
/// dialog box contains an actionable `next_step` for the operator.
313+
/// </remarks>
314+
private static readonly ElevatedManagedAction verifyAgentTunnel = new(
315+
new Id($"CA.{nameof(verifyAgentTunnel)}"),
316+
CustomActions.VerifyAgentTunnel,
317+
Return.check,
318+
When.After, new Step(enrollAgentTunnel.Id),
319+
Features.AGENT_TUNNEL_FEATURE.BeingInstall(),
320+
Sequence.InstallExecuteSequence)
321+
{
322+
Execute = Execute.deferred,
323+
Impersonate = false,
324+
UsesProperties = string.Join(";", new[]
325+
{
326+
AgentProperties.InstallDir,
327+
}.Select(p => $"{p}=[{p}]")),
328+
};
329+
305330
private static readonly ElevatedManagedAction registerExplorerCommand = new(
306331
CustomActions.RegisterExplorerCommand
307332
)
@@ -376,6 +401,7 @@ private static string UseProperties(IEnumerable<IWixProperty> properties)
376401
setFeaturesToConfigure,
377402
configureFeatures,
378403
enrollAgentTunnel,
404+
verifyAgentTunnel,
379405
createProgramDataDirectory,
380406
setProgramDataDirectoryPermissions,
381407
createProgramDataPedmDirectories,

package/AgentWindowsManaged/Actions/CustomActions.cs

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,6 @@ public static ActionResult EnrollAgentTunnel(Session session)
325325
string enrollmentString = session.Property(AgentProperties.AgentTunnelEnrollmentString)?.Trim() ?? string.Empty;
326326
string subnetsArg = session.Property(AgentProperties.AgentTunnelAdvertiseSubnets)?.Trim() ?? string.Empty;
327327
string domainsArg = session.Property(AgentProperties.AgentTunnelAdvertiseDomains)?.Trim() ?? string.Empty;
328-
string gatewayUrlArg = session.Property(AgentProperties.AgentTunnelGatewayUrl)?.Trim() ?? string.Empty;
329328
string agentNameArg = session.Property(AgentProperties.AgentTunnelAgentName)?.Trim() ?? string.Empty;
330329

331330
ActionResult Fail(string msg)
@@ -361,7 +360,6 @@ ActionResult Fail(string msg)
361360
}
362361

363362
string arguments = $"up --enrollment-string \"{enrollmentString}\"";
364-
if (gatewayUrlArg.Length != 0) arguments += $" --gateway \"{gatewayUrlArg}\"";
365363
if (resolvedName.Length != 0) arguments += $" --name \"{resolvedName}\"";
366364
if (subnetsArg.Length != 0) arguments += $" --advertise-subnets \"{subnetsArg}\"";
367365

@@ -472,6 +470,117 @@ private static void WriteAdvertiseDomainsToConfig(Session session, string domain
472470
}
473471
}
474472

473+
/// <summary>
474+
/// After `EnrollAgentTunnel` succeeds, exercises one real QUIC handshake +
475+
/// `RouteAdvertise`/`Heartbeat` round-trip via `agent.exe verify-tunnel` so the
476+
/// installer only reports success when the tunnel is actually reachable.
477+
///
478+
/// The agent emits a single-line JSON triple `{kind, detail, next_step}` on stderr
479+
/// when verification fails. The CA parses that triple, logs it, and surfaces
480+
/// `next_step` (the operator-facing help text) through `session.Message(InstallMessage.Error, ...)`.
481+
/// If the agent exits with a non-zero status but stderr contains no parseable triple,
482+
/// the CA falls back to a generic "unexpected_error" message — it never silently
483+
/// swallows a failure.
484+
/// </summary>
485+
[CustomAction]
486+
public static ActionResult VerifyAgentTunnel(Session session)
487+
{
488+
ActionResult Fail(string title, string detail, string nextStep)
489+
{
490+
string composed = string.IsNullOrEmpty(nextStep)
491+
? $"{title}\n\n{detail}"
492+
: $"{title}\n\n{detail}\n\n{nextStep}";
493+
session.Log($"verify-tunnel failure: kind={title}; detail={detail}; next_step={nextStep}");
494+
using Record record = new(0) { FormatString = composed };
495+
session.Message(InstallMessage.Error, record);
496+
return ActionResult.Failure;
497+
}
498+
499+
try
500+
{
501+
string installDir = session.Property(AgentProperties.InstallDir);
502+
string exePath = Path.Combine(installDir, Includes.EXECUTABLE_NAME);
503+
504+
// The 10s budget is hardcoded by design: no MSI property, no escape hatch.
505+
// If real deployments later need a longer budget for slow customer networks,
506+
// expose a property then — not pre-emptively.
507+
const int VerifyTimeoutSeconds = 10;
508+
string arguments = $"verify-tunnel --timeout {VerifyTimeoutSeconds}";
509+
510+
session.Log($"Running verify-tunnel: {exePath} {arguments}");
511+
512+
ProcessStartInfo startInfo = new(exePath, arguments)
513+
{
514+
UseShellExecute = false,
515+
RedirectStandardOutput = true,
516+
RedirectStandardError = true,
517+
CreateNoWindow = true,
518+
WorkingDirectory = ProgramDataDirectory,
519+
};
520+
521+
using Process process = Process.Start(startInfo);
522+
// Hard wall-clock cap a few seconds beyond the agent's own --timeout so a
523+
// misbehaving process can't hang the installer.
524+
if (!process.WaitForExit((VerifyTimeoutSeconds + 5) * 1000))
525+
{
526+
try { process.Kill(); } catch { /* already gone */ }
527+
return Fail(
528+
"quic_handshake_timeout",
529+
$"verify-tunnel exceeded {VerifyTimeoutSeconds + 5}s wall-clock budget without exiting.",
530+
"Re-run the installer. If the failure repeats, network path likely drops UDP mid-flow; check Windows Firewall, NAT, and EDR network inspection.");
531+
}
532+
533+
string stdout = process.StandardOutput.ReadToEnd();
534+
string stderr = process.StandardError.ReadToEnd();
535+
536+
if (!string.IsNullOrEmpty(stdout)) session.Log($"verify-tunnel stdout: {stdout}");
537+
if (!string.IsNullOrEmpty(stderr)) session.Log($"verify-tunnel stderr: {stderr}");
538+
539+
if (process.ExitCode == 0)
540+
{
541+
session.Log("Agent tunnel verification succeeded");
542+
return ActionResult.Success;
543+
}
544+
545+
// Failure path: parse the last non-blank stderr line as the JSON triple.
546+
string triple = (stderr ?? string.Empty)
547+
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
548+
.Reverse()
549+
.FirstOrDefault(l => l.TrimStart().StartsWith("{"));
550+
551+
if (string.IsNullOrEmpty(triple))
552+
{
553+
return Fail(
554+
"unexpected_error",
555+
$"verify-tunnel exited with code {process.ExitCode} but emitted no parseable JSON triple on stderr.",
556+
"Collect the agent log and the installer log (.msi /l*v) and file a support issue.");
557+
}
558+
559+
try
560+
{
561+
JObject parsed = JObject.Parse(triple);
562+
string kind = parsed.Value<string>("kind") ?? "unexpected_error";
563+
string detail = parsed.Value<string>("detail") ?? string.Empty;
564+
string nextStep = parsed.Value<string>("next_step") ?? string.Empty;
565+
return Fail(kind, detail, nextStep);
566+
}
567+
catch (Exception e)
568+
{
569+
return Fail(
570+
"unexpected_error",
571+
$"verify-tunnel emitted unparseable stderr ({e.Message}). Raw: {triple}",
572+
"Collect the agent log and the installer log (.msi /l*v) and file a support issue.");
573+
}
574+
}
575+
catch (Exception e)
576+
{
577+
return Fail(
578+
"unexpected_error",
579+
$"Failed to invoke verify-tunnel: {e.Message}",
580+
"Collect the agent log and the installer log (.msi /l*v) and file a support issue.");
581+
}
582+
}
583+
475584
[CustomAction]
476585
public static ActionResult ConfigureFeatures(Session session)
477586
{

package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.Designer.cs

Lines changed: 1 addition & 45 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,14 @@ public AgentTunnelDialog()
2121

2222
public override bool ToProperties()
2323
{
24+
// The Gateway URL override field was removed in the identity refactor: the JWT
25+
// is now the single source of truth for the agent-facing URL. Overriding it
26+
// server-side would defeat the whole point of validating that the agent reached
27+
// the gateway through one of `AgentTunnel.AdvertisedNames`.
2428
Runtime.Session[AgentProperties.AgentTunnelEnrollmentString] = enrollmentString.Text.Trim();
2529
Runtime.Session[AgentProperties.AgentTunnelAgentName] = agentName.Text.Trim();
2630
Runtime.Session[AgentProperties.AgentTunnelAdvertiseSubnets] = advertiseSubnets.Text.Trim();
2731
Runtime.Session[AgentProperties.AgentTunnelAdvertiseDomains] = advertiseDomains.Text.Trim();
28-
Runtime.Session[AgentProperties.AgentTunnelGatewayUrl] = gatewayUrl.Text.Trim();
2932

3033
return true;
3134
}
@@ -38,7 +41,6 @@ public override void OnLoad(object sender, EventArgs e)
3841
agentName.Text = Runtime.Session.Property(AgentProperties.AgentTunnelAgentName);
3942
advertiseSubnets.Text = Runtime.Session.Property(AgentProperties.AgentTunnelAdvertiseSubnets);
4043
advertiseDomains.Text = Runtime.Session.Property(AgentProperties.AgentTunnelAdvertiseDomains);
41-
gatewayUrl.Text = Runtime.Session.Property(AgentProperties.AgentTunnelGatewayUrl);
4244

4345
base.OnLoad(sender, e);
4446
}

package/AgentWindowsManaged/Program.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,6 @@ static void Main()
352352
// Agent tunnel properties: must be declared Secure so the values set in the wizard UI
353353
// survive the UAC boundary and reach the deferred CA via CustomActionData.
354354
projectProperties.Add(new Property(AgentProperties.AgentTunnelEnrollmentString, "") { Hidden = true, Secure = true });
355-
projectProperties.Add(new Property(AgentProperties.AgentTunnelGatewayUrl, "") { Secure = true });
356355
projectProperties.Add(new Property(AgentProperties.AgentTunnelAgentName, "") { Secure = true });
357356
projectProperties.Add(new Property(AgentProperties.AgentTunnelAdvertiseSubnets, "") { Secure = true });
358357
projectProperties.Add(new Property(AgentProperties.AgentTunnelAdvertiseDomains, "") { Secure = true });

package/AgentWindowsManaged/Properties/AgentProperties.cs

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,6 @@ internal partial class AgentProperties
3131
/// </summary>
3232
public static string AgentTunnelAdvertiseDomains = "AGENT_TUNNEL_ADVERTISE_DOMAINS";
3333

34-
/// <summary>
35-
/// Optional gateway URL override. When set, the agent uses this URL instead of the JWT's
36-
/// jet_gw_url claim. Useful when the JWT was minted with a URL that isn't reachable from
37-
/// the agent's network (e.g. DVLS embedded "localhost" but the agent is remote).
38-
/// </summary>
39-
public static string AgentTunnelGatewayUrl = "AGENT_TUNNEL_GATEWAY_URL";
40-
4134
/// <summary>
4235
/// Optional agent display name. Resolution order at install time:
4336
/// dialog value (if non-empty) > JWT's jet_agent_name claim (if present) > local computer name.

package/AgentWindowsManaged/Resources/DevolutionsAgent_en-us.wxl

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,4 @@ If it appears minimized then active it from the taskbar.</String>
6969
<String Id="AgentTunnelDlgDomainsHint">Comma-separated DNS suffixes the agent can resolve, e.g. corp.example.com, lab.example.com. Leave blank to skip.</String>
7070
<String Id="AgentTunnelDlgAgentNameLabel">Agent name (optional):</String>
7171
<String Id="AgentTunnelDlgAgentNameHint">Identifier for this agent. Leave blank to use the name in the JWT, or the local computer name as a final fallback.</String>
72-
<String Id="AgentTunnelDlgGatewayUrlLabel">Gateway URL (advanced, optional):</String>
73-
<String Id="AgentTunnelDlgGatewayUrlHint">Override the URL embedded in the enrollment JWT. Leave blank to use the JWT's value.</String>
7472
</WixLocalization>

package/AgentWindowsManaged/Resources/DevolutionsAgent_fr-fr.wxl

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@
1414
<String Id="AgentTunnelDlgDomainsHint">Suffixes DNS séparés par des virgules que l'agent peut résoudre, p. ex. corp.example.com, lab.example.com. Laissez vide pour ignorer.</String>
1515
<String Id="AgentTunnelDlgAgentNameLabel">Nom de l'agent (facultatif) :</String>
1616
<String Id="AgentTunnelDlgAgentNameHint">Identifiant de cet agent. Laissez vide pour utiliser le nom inscrit dans le JWT, ou le nom de l'ordinateur local comme dernier recours.</String>
17-
<String Id="AgentTunnelDlgGatewayUrlLabel">URL de la passerelle (avancé, facultatif) :</String>
18-
<String Id="AgentTunnelDlgGatewayUrlHint">Remplace l'URL incluse dans le JWT d'enrôlement. Laissez vide pour utiliser la valeur du JWT.</String>
1917
<String Id="Language">1036</String>
2018
<String Id="ProductDescription">Service à l’échelle du système pour étendre les fonctionnalités de Devolutions Gateway.</String>
2119
<String Id="VendorFullName">Devolutions Inc.</String>

0 commit comments

Comments
 (0)