Skip to content

Commit 26f23c0

Browse files
feat(agent): add Agent Tunnel configuration dialog (#1789)
## Summary Adds an optional **Agent Tunnel** wizard step to the Devolutions Agent MSI installer so admins can enrol the agent in a Gateway QUIC tunnel during install — UI or unattended. Three text fields in the dialog: enrolment string, advertise subnets, advertise domains. The feature is opt-in (`isEnabled: false, allowChange: true`); the dialog is skipped when the feature isn't selected, and an empty enrolment string skips tunnel setup even if the feature is on, so the installer remains usable for non-tunnel deployments. Smoke-tested end-to-end against a local DVLS + Gateway + Agent stack: MSI built clean, agent enrolled successfully, RDP TCP traffic routed through the agent tunnel to `IT-HELP-DC:3389`. ## MSI public properties (unattended install) ``` msiexec /i DevolutionsAgent.msi /qn ADDLOCAL="...,AgentTunnel" \ AGENT_TUNNEL_ENROLLMENT_STRING="dgw-enroll:v1:<base64>" \ AGENT_TUNNEL_ADVERTISE_SUBNETS="10.10.0.0/24" \ AGENT_TUNNEL_ADVERTISE_DOMAINS="corp.example.com" ``` ## Custom action `EnrollAgentTunnel` is deferred + elevated + `Impersonate=false`, runs `Before StartServices`, gated by `Features.AGENT_TUNNEL_FEATURE.BeingInstall()`. It: 1. Validates the `dgw-enroll:v1:` prefix, strips whitespace, pads base64url, decodes JSON payload (`api_base_url`, `enrollment_token`, optional `name`). 2. Shells out to `devolutions-agent.exe enroll <url> <token> <name> [subnets]` with a 60s timeout; on timeout the child is killed. 3. Token is redacted (`***`) symmetrically across the command-line log and child stdout/stderr. 4. After enrolment, advertise domains are patched into `Tunnel.AdvertiseDomains` of `%ProgramData%\Devolutions\Agent\agent.json`. This matches the design that advertise domains live in config, not on the CLI (replaces the closed #1774). ## Depends on - #1773 — DVLS-signed JWT enrolment flow (`/jet/tunnel/enroll`) - #1775 — agent cert renewal (referenced by the bundled agent binary) Both merged to master 2026-05-20. ## Test plan - [x] Cold MSI build clean, no errors (8 preexisting `CNDL1138`/`CNDL1006` warnings from existing code, not introduced here) - [x] Wizard order: Welcome → Features → AgentTunnel → InstallDir → VerifyReady - [x] AgentTunnel dialog skipped when feature not selected - [x] Empty enrolment string skips enrolment (no failure) - [x] Invalid `dgw-enroll:v1:` prefix → validation error in dialog - [x] Invalid base64 → validation error in dialog (caught client-side, not at CA time) - [x] Smoke test against local DVLS-signed enrolment JWT — agent registered, cert issued - [x] Smoke test routing: RDP TCP through agent tunnel to `IT-HELP-DC:3389` succeeds ## Review history Pre-review and codex-review passes folded into the single commit. Notable points addressed: - Robust JSON payload parsing (`JObject`, `Value<string>()`, `IsNullOrWhiteSpace` validation) - Symmetric token redaction (cmdline + stdout + stderr) - `WaitForExit(60_000)` return-value check with child kill on timeout - Whitespace-stripped + padded base64url decoding on both dialog validation and CA - ADDLOCAL CSV value trimming for admin-supplied lists with whitespace - Dropped 2 stray `InstallDirDlg*` strings leaked into en-us - Added 5 missing fr-fr translations
1 parent c606a52 commit 26f23c0

10 files changed

Lines changed: 790 additions & 4 deletions

File tree

package/AgentWindowsManaged/Actions/AgentActions.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,28 @@ internal static class AgentActions
279279
UsesProperties = UseProperties(new[] { AgentProperties.featuresToConfigure })
280280
};
281281

282+
private static readonly ElevatedManagedAction enrollAgentTunnel = new(
283+
new Id($"CA.{nameof(enrollAgentTunnel)}"),
284+
CustomActions.EnrollAgentTunnel,
285+
Return.check,
286+
When.Before, Step.StartServices,
287+
Features.AGENT_TUNNEL_FEATURE.BeingInstall(),
288+
Sequence.InstallExecuteSequence)
289+
{
290+
Execute = Execute.deferred,
291+
Impersonate = false,
292+
// Deferred CAs only see properties bubbled through CustomActionData. The Set_<CA>_Props
293+
// immediate action expands [PROP] for each entry below before the deferred CA runs.
294+
UsesProperties = string.Join(";", new[]
295+
{
296+
AgentProperties.AgentTunnelEnrollmentString,
297+
AgentProperties.AgentTunnelAgentName,
298+
AgentProperties.AgentTunnelAdvertiseSubnets,
299+
AgentProperties.AgentTunnelAdvertiseDomains,
300+
AgentProperties.InstallDir,
301+
}.Select(p => $"{p}=[{p}]")),
302+
};
303+
282304
private static readonly ElevatedManagedAction registerExplorerCommand = new(
283305
CustomActions.RegisterExplorerCommand
284306
)
@@ -352,6 +374,7 @@ private static string UseProperties(IEnumerable<IWixProperty> properties)
352374
setArpInstallLocation,
353375
setFeaturesToConfigure,
354376
configureFeatures,
377+
enrollAgentTunnel,
355378
createProgramDataDirectory,
356379
setProgramDataDirectoryPermissions,
357380
createProgramDataPedmDirectories,

package/AgentWindowsManaged/Actions/CustomActions.cs

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Microsoft.Deployment.WindowsInstaller;
44
using Microsoft.Win32;
55
using Newtonsoft.Json;
6+
using Newtonsoft.Json.Linq;
67
using System;
78
using System.Collections.Generic;
89
using System.ComponentModel;
@@ -12,6 +13,7 @@
1213
using System.Linq;
1314
using System.Runtime.InteropServices;
1415
using System.Security.Claims;
16+
using System.Text;
1517
using System.Threading;
1618
using WixSharp;
1719
using File = System.IO.File;
@@ -318,6 +320,196 @@ public static ActionResult SetFeaturesToConfigure(Session session)
318320
return ActionResult.Success;
319321
}
320322

323+
[CustomAction]
324+
public static ActionResult EnrollAgentTunnel(Session session)
325+
{
326+
string enrollmentString = session.Property(AgentProperties.AgentTunnelEnrollmentString)?.Trim() ?? string.Empty;
327+
string subnetsArg = session.Property(AgentProperties.AgentTunnelAdvertiseSubnets)?.Trim() ?? string.Empty;
328+
string domainsArg = session.Property(AgentProperties.AgentTunnelAdvertiseDomains)?.Trim() ?? string.Empty;
329+
string agentNameArg = session.Property(AgentProperties.AgentTunnelAgentName)?.Trim() ?? string.Empty;
330+
331+
ActionResult Fail(string msg)
332+
{
333+
session.Log(msg);
334+
using Record record = new(0) { FormatString = msg };
335+
session.Message(InstallMessage.Error, record);
336+
return ActionResult.Failure;
337+
}
338+
339+
if (enrollmentString.Length == 0)
340+
{
341+
return Fail("An enrollment string is required. Paste the enrollment string provided by your gateway operator, or deselect the Agent Tunnel feature.");
342+
}
343+
344+
try
345+
{
346+
// Hand the enrollment string through verbatim. The agent's
347+
// `up --enrollment-string` parses the gateway URL and agent name out of it.
348+
// Advertise domains aren't a CLI flag — agent.json carries them — so we patch
349+
// that after enrollment succeeds.
350+
string installDir = session.Property(AgentProperties.InstallDir);
351+
string exePath = Path.Combine(installDir, Includes.EXECUTABLE_NAME);
352+
353+
// agent.exe `up` requires an agent name. Resolution: dialog value > JWT's
354+
// jet_agent_name (left to the agent CLI by omitting --name) > local computer name.
355+
string resolvedName = agentNameArg;
356+
if (resolvedName.Length == 0 && !JwtHasAgentName(enrollmentString))
357+
{
358+
resolvedName = Environment.MachineName;
359+
session.Log($"JWT carried no jet_agent_name and no name was provided in the wizard; falling back to computer name '{resolvedName}'");
360+
}
361+
362+
// Only `--enrollment-string` is mandatory at enroll time — the gateway needs the
363+
// JWT to authenticate. `--name` is conditionally passed because the gateway
364+
// embeds it in the issued client cert and registers the agent under it; agent.json
365+
// can't carry it before `up` runs because the file doesn't exist yet. Everything
366+
// else (advertise subnets, advertise domains) is patched into agent.json *after*
367+
// enrollment, so we don't accumulate parallel CLI surfaces for what is ultimately
368+
// configuration data.
369+
string arguments = $"up --enrollment-string \"{enrollmentString}\"";
370+
if (resolvedName.Length != 0)
371+
{
372+
arguments += $" --name \"{resolvedName}\"";
373+
}
374+
375+
string Redact(string s) => s.Replace(enrollmentString, "***");
376+
session.Log($"Running enrollment: {exePath} {Redact(arguments)}");
377+
378+
ProcessStartInfo startInfo = new(exePath, arguments)
379+
{
380+
UseShellExecute = false,
381+
RedirectStandardOutput = true,
382+
RedirectStandardError = true,
383+
CreateNoWindow = true,
384+
WorkingDirectory = ProgramDataDirectory,
385+
};
386+
387+
using Process process = Process.Start(startInfo);
388+
if (!process.WaitForExit(60_000))
389+
{
390+
try
391+
{
392+
process.Kill();
393+
}
394+
catch
395+
{
396+
// Already exited between WaitForExit timing out and Kill firing.
397+
}
398+
return Fail("Agent tunnel enrollment timed out. Verify your Devolutions Gateway is reachable from this machine.");
399+
}
400+
string stdout = process.StandardOutput.ReadToEnd();
401+
string stderr = process.StandardError.ReadToEnd();
402+
403+
if (!string.IsNullOrEmpty(stdout))
404+
{
405+
session.Log($"enrollment stdout: {Redact(stdout)}");
406+
}
407+
if (!string.IsNullOrEmpty(stderr))
408+
{
409+
session.Log($"enrollment stderr: {Redact(stderr)}");
410+
}
411+
412+
if (process.ExitCode != 0)
413+
{
414+
string detail = !string.IsNullOrWhiteSpace(stderr) ? Redact(stderr).Trim() : $"exit code {process.ExitCode}";
415+
return Fail($"Agent tunnel enrollment failed: {detail}");
416+
}
417+
418+
if (subnetsArg.Length != 0 || domainsArg.Length != 0)
419+
{
420+
WriteTunnelAdvertisementsToConfig(session, subnetsArg, domainsArg);
421+
}
422+
423+
session.Log("Agent tunnel enrollment completed successfully");
424+
return ActionResult.Success;
425+
}
426+
catch (Exception e)
427+
{
428+
return Fail($"Agent tunnel enrollment failed: {e.Message}");
429+
}
430+
}
431+
432+
private static bool JwtHasAgentName(string jwt)
433+
{
434+
try
435+
{
436+
string[] parts = jwt.Split('.');
437+
if (parts.Length != 3) return false;
438+
string payload = parts[1].Replace('-', '+').Replace('_', '/');
439+
payload = payload.PadRight((payload.Length + 3) & ~3, '=');
440+
string json = Encoding.UTF8.GetString(Convert.FromBase64String(payload));
441+
string name = JObject.Parse(json)["jet_agent_name"]?.ToString();
442+
return !string.IsNullOrWhiteSpace(name);
443+
}
444+
catch
445+
{
446+
return false;
447+
}
448+
}
449+
450+
/// <summary>
451+
/// Patch the freshly-written agent.json's <c>Tunnel</c> section with the operator's
452+
/// advertised subnets and DNS suffixes from the wizard. Keeping this out of the
453+
/// <c>agent.exe up</c> command line means we only carry mandatory enroll inputs on the
454+
/// CLI; everything else flows through the same configuration file the agent reads at
455+
/// runtime.
456+
/// </summary>
457+
private static void WriteTunnelAdvertisementsToConfig(Session session, string subnetsCsv, string domainsCsv)
458+
{
459+
string configPath = Path.Combine(ProgramDataDirectory, "agent.json");
460+
if (!File.Exists(configPath))
461+
{
462+
session.Log($"agent.json not found at {configPath}; cannot persist tunnel advertisements");
463+
return;
464+
}
465+
466+
try
467+
{
468+
string[] subnets = SplitCsv(subnetsCsv);
469+
string[] domains = SplitCsv(domainsCsv);
470+
471+
if (subnets.Length == 0 && domains.Length == 0)
472+
{
473+
return;
474+
}
475+
476+
JObject root = JObject.Parse(File.ReadAllText(configPath));
477+
478+
// ConfFile uses serde rename_all = "PascalCase", so the tunnel section is keyed
479+
// "Tunnel" and the fields are "AdvertiseSubnets" / "AdvertiseDomains".
480+
if (root["Tunnel"] is not JObject tunnel)
481+
{
482+
session.Log("agent.json has no Tunnel section after enrollment; skipping tunnel advertisements write");
483+
return;
484+
}
485+
486+
if (subnets.Length != 0)
487+
{
488+
tunnel["AdvertiseSubnets"] = new JArray(subnets);
489+
}
490+
if (domains.Length != 0)
491+
{
492+
tunnel["AdvertiseDomains"] = new JArray(domains);
493+
}
494+
495+
File.WriteAllText(configPath, root.ToString(Formatting.Indented));
496+
session.Log($"Wrote {subnets.Length} advertise_subnets and {domains.Length} advertise_domains entries to agent.json");
497+
}
498+
catch (Exception e)
499+
{
500+
// Don't fail the install over this — the tunnel works fine without these
501+
// advertisements; the agent simply won't route additional traffic for them.
502+
session.Log($"Failed to write tunnel advertisements to agent.json: {e}");
503+
}
504+
}
505+
506+
private static string[] SplitCsv(string csv) =>
507+
(csv ?? string.Empty)
508+
.Split(',')
509+
.Select(s => s.Trim())
510+
.Where(s => !string.IsNullOrEmpty(s))
511+
.ToArray();
512+
321513
[CustomAction]
322514
public static ActionResult ConfigureFeatures(Session session)
323515
{

0 commit comments

Comments
 (0)