Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
536 changes: 536 additions & 0 deletions AGENT_TUNNEL_IDENTITY_DESIGN.md

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions package/AgentWindowsManaged/Actions/AgentActions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,29 @@ internal static class AgentActions
UsesProperties = UseProperties(new[] { AgentProperties.featuresToConfigure })
};

private static readonly ElevatedManagedAction enrollAgentTunnel = new(
new Id($"CA.{nameof(enrollAgentTunnel)}"),
CustomActions.EnrollAgentTunnel,
Return.check,
When.Before, Step.StartServices,
Features.AGENT_TUNNEL_FEATURE.BeingInstall(),
Sequence.InstallExecuteSequence)
{
Execute = Execute.deferred,
Impersonate = false,
// Deferred CAs only see properties bubbled through CustomActionData. The Set_<CA>_Props
// immediate action expands [PROP] for each entry below before the deferred CA runs.
UsesProperties = string.Join(";", new[]
{
AgentProperties.AgentTunnelEnrollmentString,
AgentProperties.AgentTunnelGatewayUrl,
AgentProperties.AgentTunnelAgentName,
AgentProperties.AgentTunnelAdvertiseSubnets,
AgentProperties.AgentTunnelAdvertiseDomains,
AgentProperties.InstallDir,
}.Select(p => $"{p}=[{p}]")),
};

private static readonly ElevatedManagedAction registerExplorerCommand = new(
CustomActions.RegisterExplorerCommand
)
Expand Down Expand Up @@ -352,6 +375,7 @@ private static string UseProperties(IEnumerable<IWixProperty> properties)
setArpInstallLocation,
setFeaturesToConfigure,
configureFeatures,
enrollAgentTunnel,
createProgramDataDirectory,
setProgramDataDirectoryPermissions,
createProgramDataPedmDirectories,
Expand Down
154 changes: 154 additions & 0 deletions package/AgentWindowsManaged/Actions/CustomActions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Microsoft.Deployment.WindowsInstaller;
using Microsoft.Win32;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.ComponentModel;
Expand Down Expand Up @@ -318,6 +319,159 @@ public static ActionResult SetFeaturesToConfigure(Session session)
return ActionResult.Success;
}

[CustomAction]
public static ActionResult EnrollAgentTunnel(Session session)
{
string enrollmentString = session.Property(AgentProperties.AgentTunnelEnrollmentString)?.Trim() ?? string.Empty;
string subnetsArg = session.Property(AgentProperties.AgentTunnelAdvertiseSubnets)?.Trim() ?? string.Empty;
string domainsArg = session.Property(AgentProperties.AgentTunnelAdvertiseDomains)?.Trim() ?? string.Empty;
string gatewayUrlArg = session.Property(AgentProperties.AgentTunnelGatewayUrl)?.Trim() ?? string.Empty;
string agentNameArg = session.Property(AgentProperties.AgentTunnelAgentName)?.Trim() ?? string.Empty;

ActionResult Fail(string msg)
{
session.Log(msg);
using Record record = new(0) { FormatString = msg };
session.Message(InstallMessage.Error, record);
return ActionResult.Failure;
}

if (enrollmentString.Length == 0)
{
return Fail("Agent tunnel feature was selected but no enrollment string was provided. " +
"Paste a JWT from Devolutions Server, Hub, or Gateway, or deselect the Agent Tunnel feature.");
}

try
{
// The enrollment string is the DVLS-signed JWT verbatim. The agent's
// `up --enrollment-string` parses `jet_gw_url` and `jet_agent_name` from the JWT
// claims itself, so we just hand the JWT through. Advertise domains aren't a CLI
// flag — agent.json carries them — so we patch that after enrollment succeeds.
string installDir = session.Property(AgentProperties.InstallDir);
string exePath = Path.Combine(installDir, Includes.EXECUTABLE_NAME);

// agent.exe `up` requires an agent name. Resolution: dialog value > JWT's
// jet_agent_name (left to the agent CLI by omitting --name) > local computer name.
string resolvedName = agentNameArg;
if (resolvedName.Length == 0 && !JwtHasAgentName(enrollmentString))
{
resolvedName = Environment.MachineName;
session.Log($"JWT carried no jet_agent_name and no name was provided in the wizard; falling back to computer name '{resolvedName}'");
}

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

string Redact(string s) => s.Replace(enrollmentString, "***");
session.Log($"Running enrollment: {exePath} {Redact(arguments)}");

ProcessStartInfo startInfo = new(exePath, arguments)
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
WorkingDirectory = ProgramDataDirectory,
};

using Process process = Process.Start(startInfo);
if (!process.WaitForExit(60_000))
{
try { process.Kill(); } catch { /* already gone */ }
return Fail("Agent tunnel enrollment timed out after 60 seconds.");
}
string stdout = process.StandardOutput.ReadToEnd();
string stderr = process.StandardError.ReadToEnd();

if (!string.IsNullOrEmpty(stdout)) session.Log($"enrollment stdout: {Redact(stdout)}");
if (!string.IsNullOrEmpty(stderr)) session.Log($"enrollment stderr: {Redact(stderr)}");

if (process.ExitCode != 0)
{
string detail = !string.IsNullOrWhiteSpace(stderr) ? Redact(stderr).Trim() : $"exit code {process.ExitCode}";
return Fail($"Agent tunnel enrollment failed: {detail}");
}

if (domainsArg.Length != 0)
{
WriteAdvertiseDomainsToConfig(session, domainsArg);
}

session.Log("Agent tunnel enrollment completed successfully");
return ActionResult.Success;
}
catch (Exception e)
{
return Fail($"Agent tunnel enrollment failed: {e.Message}");
}
}

private static bool JwtHasAgentName(string jwt)
{
try
{
string[] parts = jwt.Split('.');
if (parts.Length != 3) return false;
string payload = parts[1].Replace('-', '+').Replace('_', '/');
payload = payload.PadRight((payload.Length + 3) & ~3, '=');
string json = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(payload));
string name = JObject.Parse(json)["jet_agent_name"]?.ToString();
return !string.IsNullOrWhiteSpace(name);
}
catch
{
return false;
}
}

private static void WriteAdvertiseDomainsToConfig(Session session, string domainsCsv)
{
string configPath = Path.Combine(ProgramDataDirectory, "agent.json");
if (!File.Exists(configPath))
{
session.Log($"agent.json not found at {configPath}; cannot persist advertise_domains");
return;
}

try
{
string[] domains = domainsCsv
.Split(',')
.Select(d => d.Trim())
.Where(d => !string.IsNullOrEmpty(d))
.ToArray();

if (domains.Length == 0)
{
return;
}

JObject root = JObject.Parse(File.ReadAllText(configPath));

// ConfFile uses serde rename_all = "PascalCase", so the tunnel section is keyed
// "Tunnel" and the field is "AdvertiseDomains".
if (root["Tunnel"] is not JObject tunnel)
{
session.Log("agent.json has no Tunnel section after enrollment; skipping advertise_domains write");
return;
}

tunnel["AdvertiseDomains"] = new JArray(domains);

File.WriteAllText(configPath, root.ToString(Formatting.Indented));
session.Log($"Wrote {domains.Length} advertise_domains entries to agent.json");
}
catch (Exception e)
{
// Don't fail the install over this — the tunnel works fine without domain
// advertisements (subnets cover IP routing on their own).
session.Log($"Failed to write advertise_domains to agent.json: {e}");
}
}

[CustomAction]
public static ActionResult ConfigureFeatures(Session session)
{
Expand Down
Loading
Loading