Skip to content

Commit cf4abdf

Browse files
merge: pull in feat/agent-installer-tunnel as base for PR3 installer work
2 parents add8746 + 2185192 commit cf4abdf

11 files changed

Lines changed: 1361 additions & 4 deletions

AGENT_TUNNEL_IDENTITY_DESIGN.md

Lines changed: 536 additions & 0 deletions
Large diffs are not rendered by default.

package/AgentWindowsManaged/Actions/AgentActions.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,29 @@ 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.AgentTunnelGatewayUrl,
298+
AgentProperties.AgentTunnelAgentName,
299+
AgentProperties.AgentTunnelAdvertiseSubnets,
300+
AgentProperties.AgentTunnelAdvertiseDomains,
301+
AgentProperties.InstallDir,
302+
}.Select(p => $"{p}=[{p}]")),
303+
};
304+
282305
private static readonly ElevatedManagedAction registerExplorerCommand = new(
283306
CustomActions.RegisterExplorerCommand
284307
)
@@ -352,6 +375,7 @@ private static string UseProperties(IEnumerable<IWixProperty> properties)
352375
setArpInstallLocation,
353376
setFeaturesToConfigure,
354377
configureFeatures,
378+
enrollAgentTunnel,
355379
createProgramDataDirectory,
356380
setProgramDataDirectoryPermissions,
357381
createProgramDataPedmDirectories,

package/AgentWindowsManaged/Actions/CustomActions.cs

Lines changed: 154 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,159 @@ 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+
string gatewayUrlArg = session.Property(AgentProperties.AgentTunnelGatewayUrl)?.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("Agent tunnel feature was selected but no enrollment string was provided. " +
342+
"Paste a JWT from Devolutions Server, Hub, or Gateway, or deselect the Agent Tunnel feature.");
343+
}
344+
345+
try
346+
{
347+
// The enrollment string is the DVLS-signed JWT verbatim. The agent's
348+
// `up --enrollment-string` parses `jet_gw_url` and `jet_agent_name` from the JWT
349+
// claims itself, so we just hand the JWT through. Advertise domains aren't a CLI
350+
// flag — agent.json carries them — so we patch that after enrollment succeeds.
351+
string installDir = session.Property(AgentProperties.InstallDir);
352+
string exePath = Path.Combine(installDir, Includes.EXECUTABLE_NAME);
353+
354+
// agent.exe `up` requires an agent name. Resolution: dialog value > JWT's
355+
// jet_agent_name (left to the agent CLI by omitting --name) > local computer name.
356+
string resolvedName = agentNameArg;
357+
if (resolvedName.Length == 0 && !JwtHasAgentName(enrollmentString))
358+
{
359+
resolvedName = Environment.MachineName;
360+
session.Log($"JWT carried no jet_agent_name and no name was provided in the wizard; falling back to computer name '{resolvedName}'");
361+
}
362+
363+
string arguments = $"up --enrollment-string \"{enrollmentString}\"";
364+
if (gatewayUrlArg.Length != 0) arguments += $" --gateway \"{gatewayUrlArg}\"";
365+
if (resolvedName.Length != 0) arguments += $" --name \"{resolvedName}\"";
366+
if (subnetsArg.Length != 0) arguments += $" --advertise-subnets \"{subnetsArg}\"";
367+
368+
string Redact(string s) => s.Replace(enrollmentString, "***");
369+
session.Log($"Running enrollment: {exePath} {Redact(arguments)}");
370+
371+
ProcessStartInfo startInfo = new(exePath, arguments)
372+
{
373+
UseShellExecute = false,
374+
RedirectStandardOutput = true,
375+
RedirectStandardError = true,
376+
CreateNoWindow = true,
377+
WorkingDirectory = ProgramDataDirectory,
378+
};
379+
380+
using Process process = Process.Start(startInfo);
381+
if (!process.WaitForExit(60_000))
382+
{
383+
try { process.Kill(); } catch { /* already gone */ }
384+
return Fail("Agent tunnel enrollment timed out after 60 seconds.");
385+
}
386+
string stdout = process.StandardOutput.ReadToEnd();
387+
string stderr = process.StandardError.ReadToEnd();
388+
389+
if (!string.IsNullOrEmpty(stdout)) session.Log($"enrollment stdout: {Redact(stdout)}");
390+
if (!string.IsNullOrEmpty(stderr)) session.Log($"enrollment stderr: {Redact(stderr)}");
391+
392+
if (process.ExitCode != 0)
393+
{
394+
string detail = !string.IsNullOrWhiteSpace(stderr) ? Redact(stderr).Trim() : $"exit code {process.ExitCode}";
395+
return Fail($"Agent tunnel enrollment failed: {detail}");
396+
}
397+
398+
if (domainsArg.Length != 0)
399+
{
400+
WriteAdvertiseDomainsToConfig(session, domainsArg);
401+
}
402+
403+
session.Log("Agent tunnel enrollment completed successfully");
404+
return ActionResult.Success;
405+
}
406+
catch (Exception e)
407+
{
408+
return Fail($"Agent tunnel enrollment failed: {e.Message}");
409+
}
410+
}
411+
412+
private static bool JwtHasAgentName(string jwt)
413+
{
414+
try
415+
{
416+
string[] parts = jwt.Split('.');
417+
if (parts.Length != 3) return false;
418+
string payload = parts[1].Replace('-', '+').Replace('_', '/');
419+
payload = payload.PadRight((payload.Length + 3) & ~3, '=');
420+
string json = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(payload));
421+
string name = JObject.Parse(json)["jet_agent_name"]?.ToString();
422+
return !string.IsNullOrWhiteSpace(name);
423+
}
424+
catch
425+
{
426+
return false;
427+
}
428+
}
429+
430+
private static void WriteAdvertiseDomainsToConfig(Session session, string domainsCsv)
431+
{
432+
string configPath = Path.Combine(ProgramDataDirectory, "agent.json");
433+
if (!File.Exists(configPath))
434+
{
435+
session.Log($"agent.json not found at {configPath}; cannot persist advertise_domains");
436+
return;
437+
}
438+
439+
try
440+
{
441+
string[] domains = domainsCsv
442+
.Split(',')
443+
.Select(d => d.Trim())
444+
.Where(d => !string.IsNullOrEmpty(d))
445+
.ToArray();
446+
447+
if (domains.Length == 0)
448+
{
449+
return;
450+
}
451+
452+
JObject root = JObject.Parse(File.ReadAllText(configPath));
453+
454+
// ConfFile uses serde rename_all = "PascalCase", so the tunnel section is keyed
455+
// "Tunnel" and the field is "AdvertiseDomains".
456+
if (root["Tunnel"] is not JObject tunnel)
457+
{
458+
session.Log("agent.json has no Tunnel section after enrollment; skipping advertise_domains write");
459+
return;
460+
}
461+
462+
tunnel["AdvertiseDomains"] = new JArray(domains);
463+
464+
File.WriteAllText(configPath, root.ToString(Formatting.Indented));
465+
session.Log($"Wrote {domains.Length} advertise_domains entries to agent.json");
466+
}
467+
catch (Exception e)
468+
{
469+
// Don't fail the install over this — the tunnel works fine without domain
470+
// advertisements (subnets cover IP routing on their own).
471+
session.Log($"Failed to write advertise_domains to agent.json: {e}");
472+
}
473+
}
474+
321475
[CustomAction]
322476
public static ActionResult ConfigureFeatures(Session session)
323477
{

0 commit comments

Comments
 (0)