Skip to content
Merged
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
23 changes: 23 additions & 0 deletions package/AgentWindowsManaged/Actions/AgentActions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,28 @@ 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just documented Windows Installer, I'd remove this comment

// immediate action expands [PROP] for each entry below before the deferred CA runs.
UsesProperties = string.Join(";", new[]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a helper function that provides error checking, use e.g.

UsesProperties = UseProperties(new [] { AgentProperties.installId })

{
AgentProperties.AgentTunnelEnrollmentString,
AgentProperties.AgentTunnelAgentName,
AgentProperties.AgentTunnelAdvertiseSubnets,
AgentProperties.AgentTunnelAdvertiseDomains,
AgentProperties.InstallDir,
}.Select(p => $"{p}=[{p}]")),
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not exactly sure what is being passed in these properties, but are you certain it doesn't/won't contain a semi-colon? Semi-colons break CustomActionData and need special handling. This is a known Windows Installer quirk. If you're sure it's ok, that's fine; if you're not I can explain how to fix it.


private static readonly ElevatedManagedAction registerExplorerCommand = new(
CustomActions.RegisterExplorerCommand
)
Expand Down Expand Up @@ -352,6 +374,7 @@ private static string UseProperties(IEnumerable<IWixProperty> properties)
setArpInstallLocation,
setFeaturesToConfigure,
configureFeatures,
enrollAgentTunnel,
createProgramDataDirectory,
setProgramDataDirectoryPermissions,
createProgramDataPedmDirectories,
Expand Down
192 changes: 192 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 All @@ -12,6 +13,7 @@
using System.Linq;
using System.Runtime.InteropServices;
using System.Security.Claims;
using System.Text;
using System.Threading;
using WixSharp;
using File = System.IO.File;
Expand Down Expand Up @@ -318,6 +320,196 @@ 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 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("An enrollment string is required. Paste the enrollment string provided by your gateway operator, or deselect the Agent Tunnel feature.");

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not acceptable

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi — quick correction on this one. We initially acted on this suggestion and switched the empty-string case to a no-op (skip), but we've since reverted it: an empty enrollment string keeps failing the install intentionally.

The "non-tunnel / unattended deployments" concern doesn't actually apply here. EnrollAgentTunnel only runs under the AGENT_TUNNEL_FEATURE.BeingInstall() condition, and the Agent Tunnel feature is opt-in (Level 2 — not installed by default). A deployment that doesn't select the tunnel feature never reaches this code, so it can't trigger the failure. The only way to hit the empty-string branch is to explicitly select the Agent Tunnel feature (ADDLOCAL=...,F.Tunnel or ALL) while omitting the enrollment string — which is a misconfiguration we'd rather surface than silently skip.

(The genuinely separate bug nearby — ADDLOCAL=ALL wrongly skipping the dialog — is fixed in #1804.)

}

try
{
// Hand the enrollment string through verbatim. The agent's
// `up --enrollment-string` parses the gateway URL and agent name out of it.
// 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}'");
}

// Only `--enrollment-string` is mandatory at enroll time — the gateway needs the
// JWT to authenticate. `--name` is conditionally passed because the gateway
// embeds it in the issued client cert and registers the agent under it; agent.json
// can't carry it before `up` runs because the file doesn't exist yet. Everything
// else (advertise subnets, advertise domains) is patched into agent.json *after*
// enrollment, so we don't accumulate parallel CLI surfaces for what is ultimately
// configuration data.
string arguments = $"up --enrollment-string \"{enrollmentString}\"";
if (resolvedName.Length != 0)
{
arguments += $" --name \"{resolvedName}\"";
}
Comment on lines +369 to +373

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();
Comment on lines +388 to +392
}
catch
{
// Already exited between WaitForExit timing out and Kill firing.
}
return Fail("Agent tunnel enrollment timed out. Verify your Devolutions Gateway is reachable from this machine.");
}
Comment on lines +398 to +399
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 (subnetsArg.Length != 0 || domainsArg.Length != 0)
{
WriteTunnelAdvertisementsToConfig(session, subnetsArg, 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 = Encoding.UTF8.GetString(Convert.FromBase64String(payload));
string name = JObject.Parse(json)["jet_agent_name"]?.ToString();
return !string.IsNullOrWhiteSpace(name);
}
catch
{
return false;
}
}

/// <summary>
/// Patch the freshly-written agent.json's <c>Tunnel</c> section with the operator's
/// advertised subnets and DNS suffixes from the wizard. Keeping this out of the
/// <c>agent.exe up</c> command line means we only carry mandatory enroll inputs on the
/// CLI; everything else flows through the same configuration file the agent reads at
/// runtime.
/// </summary>
private static void WriteTunnelAdvertisementsToConfig(Session session, string subnetsCsv, string domainsCsv)
{
string configPath = Path.Combine(ProgramDataDirectory, "agent.json");
if (!File.Exists(configPath))
{
session.Log($"agent.json not found at {configPath}; cannot persist tunnel advertisements");
return;
}

try
{
string[] subnets = SplitCsv(subnetsCsv);
string[] domains = SplitCsv(domainsCsv);

if (subnets.Length == 0 && 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 fields are "AdvertiseSubnets" / "AdvertiseDomains".
if (root["Tunnel"] is not JObject tunnel)
{
session.Log("agent.json has no Tunnel section after enrollment; skipping tunnel advertisements write");
return;
}

if (subnets.Length != 0)
{
tunnel["AdvertiseSubnets"] = new JArray(subnets);
}
if (domains.Length != 0)
{
tunnel["AdvertiseDomains"] = new JArray(domains);
}

File.WriteAllText(configPath, root.ToString(Formatting.Indented));
session.Log($"Wrote {subnets.Length} advertise_subnets and {domains.Length} advertise_domains entries to agent.json");
}
catch (Exception e)
{
// Don't fail the install over this — the tunnel works fine without these
// advertisements; the agent simply won't route additional traffic for them.
session.Log($"Failed to write tunnel advertisements to agent.json: {e}");
}
}

private static string[] SplitCsv(string csv) =>
(csv ?? string.Empty)
.Split(',')
.Select(s => s.Trim())
.Where(s => !string.IsNullOrEmpty(s))
.ToArray();

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