-
Notifications
You must be signed in to change notification settings - Fork 26
feat(agent-installer): add Agent Tunnel configuration dialog #1789
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bae944a
91a30e4
df0ee6d
f105f7c
53309dc
763cb73
81fd4da
e06e67d
5065ce8
bfad43c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
| // immediate action expands [PROP] for each entry below before the deferred CA runs. | ||
| UsesProperties = string.Join(";", new[] | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's a helper function that provides error checking, use e.g.
|
||
| { | ||
| AgentProperties.AgentTunnelEnrollmentString, | ||
| AgentProperties.AgentTunnelAgentName, | ||
| AgentProperties.AgentTunnelAdvertiseSubnets, | ||
| AgentProperties.AgentTunnelAdvertiseDomains, | ||
| AgentProperties.InstallDir, | ||
| }.Select(p => $"{p}=[{p}]")), | ||
| }; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
| private static readonly ElevatedManagedAction registerExplorerCommand = new( | ||
| CustomActions.RegisterExplorerCommand | ||
| ) | ||
|
|
@@ -352,6 +374,7 @@ private static string UseProperties(IEnumerable<IWixProperty> properties) | |
| setArpInstallLocation, | ||
| setFeaturesToConfigure, | ||
| configureFeatures, | ||
| enrollAgentTunnel, | ||
| createProgramDataDirectory, | ||
| setProgramDataDirectoryPermissions, | ||
| createProgramDataPedmDirectories, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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; | ||
|
|
@@ -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."); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not acceptable
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. (The genuinely separate bug nearby — |
||
| } | ||
|
|
||
| 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) | ||
| { | ||
|
|
||
There was a problem hiding this comment.
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