Skip to content

Commit 460aeab

Browse files
feat(agent-installer): add Agent Tunnel configuration dialog
Adds an optional Agent Tunnel wizard step to the Devolutions Agent installer so admins can enroll the agent in a Gateway QUIC tunnel as part of MSI install (UI or unattended). Surfaces three MSI public properties for unattended installs: - AGENT_TUNNEL_ENROLLMENT_STRING (dgw-enroll:v1:<base64> from DVLS/Hub/Gateway) - AGENT_TUNNEL_ADVERTISE_SUBNETS (CSV CIDR; empty = none) - AGENT_TUNNEL_ADVERTISE_DOMAINS (CSV DNS suffixes; empty = auto-detect only) Wires a new deferred elevated custom action (EnrollAgentTunnel) that runs Before StartServices when AGENT_TUNNEL_FEATURE is being installed. It base64-decodes the enrollment payload, shells out to `devolutions-agent.exe enroll <url> <token> <name> [subnets]` with a 60s timeout, and redacts the token in the session log. Advertise domains are persisted by patching `Tunnel.AdvertiseDomains` in agent.json post-enrollment, matching the agreed direction that domain config lives in the file rather than as a CLI flag. The Tunnel feature itself is opt-in (isEnabled:false, allowChange:true); the dialog is skipped when the feature isn't selected. An empty enrollment string also skips tunnel setup, allowing the installer to be used without touching the tunnel.
1 parent 6f692e7 commit 460aeab

10 files changed

Lines changed: 655 additions & 2 deletions

File tree

package/AgentWindowsManaged/Actions/AgentActions.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,18 @@ 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+
};
293+
282294
private static readonly ElevatedManagedAction registerExplorerCommand = new(
283295
CustomActions.RegisterExplorerCommand
284296
)
@@ -352,6 +364,7 @@ private static string UseProperties(IEnumerable<IWixProperty> properties)
352364
setArpInstallLocation,
353365
setFeaturesToConfigure,
354366
configureFeatures,
367+
enrollAgentTunnel,
355368
createProgramDataDirectory,
356369
setProgramDataDirectoryPermissions,
357370
createProgramDataPedmDirectories,

package/AgentWindowsManaged/Actions/CustomActions.cs

Lines changed: 117 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;
@@ -318,6 +319,122 @@ public static ActionResult SetFeaturesToConfigure(Session session)
318319
return ActionResult.Success;
319320
}
320321

322+
[CustomAction]
323+
public static ActionResult EnrollAgentTunnel(Session session)
324+
{
325+
string enrollmentString = session.Property(AgentProperties.AgentTunnelEnrollmentString)?.Trim() ?? string.Empty;
326+
string subnetsArg = session.Property(AgentProperties.AgentTunnelAdvertiseSubnets)?.Trim() ?? string.Empty;
327+
string domainsArg = session.Property(AgentProperties.AgentTunnelAdvertiseDomains)?.Trim() ?? string.Empty;
328+
329+
if (enrollmentString.Length == 0)
330+
{
331+
session.Log("Agent tunnel enrollment string not provided, skipping tunnel setup");
332+
return ActionResult.Success;
333+
}
334+
335+
try
336+
{
337+
// The enrollment string is the DVLS-signed JWT verbatim. The agent's
338+
// `up --enrollment-string` parses `jet_gw_url` and `jet_agent_name` from the JWT
339+
// claims itself, so we just hand the JWT through. Advertise domains aren't a CLI
340+
// flag — agent.json carries them — so we patch that after enrollment succeeds.
341+
string installDir = session.Property(AgentProperties.InstallDir);
342+
string exePath = Path.Combine(installDir, Includes.EXECUTABLE_NAME);
343+
344+
string arguments = $"up --enrollment-string \"{enrollmentString}\"";
345+
if (subnetsArg.Length != 0) arguments += $" --advertise-subnets \"{subnetsArg}\"";
346+
347+
string Redact(string s) => s.Replace(enrollmentString, "***");
348+
session.Log($"Running enrollment: {exePath} {Redact(arguments)}");
349+
350+
ProcessStartInfo startInfo = new(exePath, arguments)
351+
{
352+
UseShellExecute = false,
353+
RedirectStandardOutput = true,
354+
RedirectStandardError = true,
355+
CreateNoWindow = true,
356+
WorkingDirectory = ProgramDataDirectory,
357+
};
358+
359+
using Process process = Process.Start(startInfo);
360+
if (!process.WaitForExit(60_000))
361+
{
362+
try { process.Kill(); } catch { /* already gone */ }
363+
session.Log("Enrollment process timed out after 60 seconds");
364+
return ActionResult.Failure;
365+
}
366+
string stdout = process.StandardOutput.ReadToEnd();
367+
string stderr = process.StandardError.ReadToEnd();
368+
369+
if (!string.IsNullOrEmpty(stdout)) session.Log($"enrollment stdout: {Redact(stdout)}");
370+
if (!string.IsNullOrEmpty(stderr)) session.Log($"enrollment stderr: {Redact(stderr)}");
371+
372+
if (process.ExitCode != 0)
373+
{
374+
session.Log($"Enrollment failed with exit code {process.ExitCode}");
375+
return ActionResult.Failure;
376+
}
377+
378+
if (domainsArg.Length != 0)
379+
{
380+
WriteAdvertiseDomainsToConfig(session, domainsArg);
381+
}
382+
383+
session.Log("Agent tunnel enrollment completed successfully");
384+
return ActionResult.Success;
385+
}
386+
catch (Exception e)
387+
{
388+
session.Log($"Agent tunnel enrollment failed: {e}");
389+
return ActionResult.Failure;
390+
}
391+
}
392+
393+
private static void WriteAdvertiseDomainsToConfig(Session session, string domainsCsv)
394+
{
395+
string configPath = Path.Combine(ProgramDataDirectory, "agent.json");
396+
if (!File.Exists(configPath))
397+
{
398+
session.Log($"agent.json not found at {configPath}; cannot persist advertise_domains");
399+
return;
400+
}
401+
402+
try
403+
{
404+
string[] domains = domainsCsv
405+
.Split(',')
406+
.Select(d => d.Trim())
407+
.Where(d => !string.IsNullOrEmpty(d))
408+
.ToArray();
409+
410+
if (domains.Length == 0)
411+
{
412+
return;
413+
}
414+
415+
JObject root = JObject.Parse(File.ReadAllText(configPath));
416+
417+
// ConfFile uses serde rename_all = "PascalCase", so the tunnel section is keyed
418+
// "Tunnel" and the field is "AdvertiseDomains".
419+
if (root["Tunnel"] is not JObject tunnel)
420+
{
421+
session.Log("agent.json has no Tunnel section after enrollment; skipping advertise_domains write");
422+
return;
423+
}
424+
425+
tunnel["AdvertiseDomains"] = new JArray(domains);
426+
427+
File.WriteAllText(configPath, root.ToString(Formatting.Indented));
428+
session.Log($"Wrote {domains.Length} advertise_domains entries to agent.json");
429+
}
430+
catch (Exception e)
431+
{
432+
// Don't fail the install over this — the tunnel works fine without domain
433+
// advertisements (subnets cover IP routing on their own).
434+
session.Log($"Failed to write advertise_domains to agent.json: {e}");
435+
}
436+
}
437+
321438
[CustomAction]
322439
public static ActionResult ConfigureFeatures(Session session)
323440
{

0 commit comments

Comments
 (0)