Skip to content

Commit a521b61

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 a521b61

10 files changed

Lines changed: 687 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: 151 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.RegularExpressions;
1517
using System.Threading;
1618
using WixSharp;
1719
using File = System.IO.File;
@@ -318,6 +320,155 @@ 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);
327+
string subnetsRaw = session.Property(AgentProperties.AgentTunnelAdvertiseSubnets);
328+
string domainsRaw = session.Property(AgentProperties.AgentTunnelAdvertiseDomains);
329+
330+
if (string.IsNullOrWhiteSpace(enrollmentString))
331+
{
332+
session.Log("Agent tunnel enrollment string not provided, skipping tunnel setup");
333+
return ActionResult.Success;
334+
}
335+
336+
try
337+
{
338+
// Parse enrollment string to extract gateway URL, token, and name.
339+
// Format: dgw-enroll:v1:<base64 JSON payload>
340+
const string prefix = "dgw-enroll:v1:";
341+
if (!enrollmentString.StartsWith(prefix))
342+
{
343+
session.Log("Invalid enrollment string prefix");
344+
return ActionResult.Failure;
345+
}
346+
347+
// base64url -> base64. Strip whitespace (line breaks from copy-paste across wrapped
348+
// terminal output are common) and pad to length % 4 == 0 (RFC 4648 §5 allows omitting `=`).
349+
string base64 = Regex.Replace(enrollmentString.Substring(prefix.Length), @"\s+", "")
350+
.Replace('-', '+').Replace('_', '/');
351+
base64 = base64.PadRight((base64.Length + 3) & ~3, '=');
352+
string json = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(base64));
353+
354+
JObject payload = JsonConvert.DeserializeObject<JObject>(json);
355+
string apiBaseUrl = payload?["api_base_url"]?.Value<string>();
356+
string enrollmentToken = payload?["enrollment_token"]?.Value<string>();
357+
string agentName = payload?["name"]?.Value<string>();
358+
359+
if (string.IsNullOrWhiteSpace(apiBaseUrl) || string.IsNullOrWhiteSpace(enrollmentToken))
360+
{
361+
session.Log("Enrollment payload missing api_base_url or enrollment_token");
362+
return ActionResult.Failure;
363+
}
364+
if (string.IsNullOrWhiteSpace(agentName)) agentName = Environment.MachineName;
365+
366+
// Build CLI arguments for: devolutions-agent.exe enroll <url> <token> <name> [subnets]
367+
// Advertise domains are not a CLI flag — the agent reads them from the Tunnel
368+
// section of agent.json. We persist them after enrollment writes the file.
369+
string installDir = session.Property(AgentProperties.InstallDir);
370+
string exePath = Path.Combine(installDir, Includes.EXECUTABLE_NAME);
371+
372+
string subnetsArg = subnetsRaw?.Trim() ?? string.Empty;
373+
string domainsArg = domainsRaw?.Trim() ?? string.Empty;
374+
375+
string arguments = $"enroll \"{apiBaseUrl}\" \"{enrollmentToken}\" \"{agentName}\"";
376+
if (subnetsArg.Length != 0)
377+
{
378+
arguments += $" \"{subnetsArg}\"";
379+
}
380+
381+
string Redact(string s) => s.Replace(enrollmentToken, "***");
382+
session.Log($"Running enrollment: {exePath} {Redact(arguments)}");
383+
384+
ProcessStartInfo startInfo = new(exePath, arguments)
385+
{
386+
UseShellExecute = false,
387+
RedirectStandardOutput = true,
388+
RedirectStandardError = true,
389+
CreateNoWindow = true,
390+
WorkingDirectory = ProgramDataDirectory,
391+
};
392+
393+
using Process process = Process.Start(startInfo);
394+
if (!process.WaitForExit(60_000))
395+
{
396+
try { process.Kill(); } catch { /* already gone */ }
397+
session.Log("Enrollment process timed out after 60 seconds");
398+
return ActionResult.Failure;
399+
}
400+
string stdout = process.StandardOutput.ReadToEnd();
401+
string stderr = process.StandardError.ReadToEnd();
402+
403+
if (!string.IsNullOrEmpty(stdout)) session.Log($"enrollment stdout: {Redact(stdout)}");
404+
if (!string.IsNullOrEmpty(stderr)) session.Log($"enrollment stderr: {Redact(stderr)}");
405+
406+
if (process.ExitCode != 0)
407+
{
408+
session.Log($"Enrollment failed with exit code {process.ExitCode}");
409+
return ActionResult.Failure;
410+
}
411+
412+
if (domainsArg.Length != 0)
413+
{
414+
WriteAdvertiseDomainsToConfig(session, domainsArg);
415+
}
416+
417+
session.Log("Agent tunnel enrollment completed successfully");
418+
return ActionResult.Success;
419+
}
420+
catch (Exception e)
421+
{
422+
session.Log($"Agent tunnel enrollment failed: {e}");
423+
return ActionResult.Failure;
424+
}
425+
}
426+
427+
private static void WriteAdvertiseDomainsToConfig(Session session, string domainsCsv)
428+
{
429+
string configPath = Path.Combine(ProgramDataDirectory, "agent.json");
430+
if (!File.Exists(configPath))
431+
{
432+
session.Log($"agent.json not found at {configPath}; cannot persist advertise_domains");
433+
return;
434+
}
435+
436+
try
437+
{
438+
string[] domains = domainsCsv
439+
.Split(',')
440+
.Select(d => d.Trim())
441+
.Where(d => !string.IsNullOrEmpty(d))
442+
.ToArray();
443+
444+
if (domains.Length == 0)
445+
{
446+
return;
447+
}
448+
449+
JObject root = JObject.Parse(File.ReadAllText(configPath));
450+
451+
// ConfFile uses serde rename_all = "PascalCase", so the tunnel section is keyed
452+
// "Tunnel" and the field is "AdvertiseDomains".
453+
if (root["Tunnel"] is not JObject tunnel)
454+
{
455+
session.Log("agent.json has no Tunnel section after enrollment; skipping advertise_domains write");
456+
return;
457+
}
458+
459+
tunnel["AdvertiseDomains"] = new JArray(domains);
460+
461+
File.WriteAllText(configPath, root.ToString(Formatting.Indented));
462+
session.Log($"Wrote {domains.Length} advertise_domains entries to agent.json");
463+
}
464+
catch (Exception e)
465+
{
466+
// Don't fail the install over this — the tunnel works fine without domain
467+
// advertisements (subnets cover IP routing on their own).
468+
session.Log($"Failed to write advertise_domains to agent.json: {e}");
469+
}
470+
}
471+
321472
[CustomAction]
322473
public static ActionResult ConfigureFeatures(Session session)
323474
{

0 commit comments

Comments
 (0)